From 0656e49151ff4e8474d26e87cdfcabd9e88c35eb Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sun, 31 May 2026 08:05:45 +0200
Subject: [PATCH] feat: export boards as PDF
---
Cargo.toml | 2 +-
README.md | 1 +
config.example.toml | 1 +
.../models/keybindings/field/config/read.rs | 1 +
.../models/keybindings/field/config/write.rs | 1 +
.../src/models/keybindings/field/labels.rs | 2 +
.../src/models/keybindings/field/list.rs | 1 +
.../src/models/keybindings/field/mod.rs | 1 +
.../src/models/keybindings/field/tab.rs | 1 +
configurator/src/models/keybindings/tests.rs | 26 +++
docs/CONFIG.md | 7 +-
.../wayland/backend/event_loop/capture.rs | 6 +
src/backend/wayland/state.rs | 9 +-
src/backend/wayland/state/capture.rs | 200 ++++++++++++++++++
src/canvas_export.rs | 179 +++++++++++++++-
src/capture/manager.rs | 20 +-
src/capture/mod.rs | 3 +-
src/capture/pipeline.rs | 74 ++++++-
src/capture/tests/manager.rs | 116 +++++++++-
src/capture/tests/perform_capture.rs | 173 ++++++++++++++-
src/capture/types.rs | 34 +++
src/config/action_meta/entries/capture.rs | 11 +
src/config/action_meta/tests.rs | 2 +
src/config/keybindings/actions.rs | 1 +
src/config/keybindings/config/map/capture.rs | 4 +
.../config/types/bindings/capture.rs | 4 +
src/config/keybindings/defaults/capture.rs | 4 +
src/config/keybindings/tests.rs | 11 +
.../state/actions/action_capture_zoom.rs | 9 +
src/input/state/core/base/types.rs | 1 +
src/input/state/core/command_palette/mod.rs | 46 ++++
src/input/state/core/utility/toasts.rs | 1 +
src/input/state/interaction/actions.rs | 1 +
src/input/state/tests/text_input/actions.rs | 14 ++
.../help_overlay/sections/builder/sections.rs | 4 +
src/ui/help_overlay/sections/tests.rs | 1 +
36 files changed, 938 insertions(+), 34 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index a10007c7..fc27d8b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,7 +26,7 @@ wayland-protocols-wlr = { version = "0.3", features = ["client"] }
smithay-client-toolkit = { version = "0.20", default-features = false, features = ["calloop", "xkbcommon"] }
# Cairo for drawing
-cairo-rs = { version = "0.22", features = ["png"] }
+cairo-rs = { version = "0.22", features = ["png", "pdf"] }
# Pango for advanced text rendering and font support
pango = "0.22"
diff --git a/README.md b/README.md
index 0dc6151a..b75c4f44 100644
--- a/README.md
+++ b/README.md
@@ -568,6 +568,7 @@ Press F1/F10 for help or Shift+F1 for quick ref
| Ctrl+Alt+O | Open last capture folder |
Requires `wl-clipboard`, `grim`, `slurp` (installed automatically by deb/rpm/AUR packages). Falls back to xdg-desktop-portal if missing.
+Canvas export commands are available in the command palette and keybindings. `export_board_pdf_file` saves the active board as a multi-page PDF, one PDF page per Wayscriber page, and is unbound by default.
Use `--exit-after-capture` / `--no-exit-after-capture` to override whether the overlay closes after a capture. `--about` opens the About window.
---
diff --git a/config.example.toml b/config.example.toml
index 55b905a7..ef5445af 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -185,6 +185,7 @@ capture_file_region = ["Ctrl+Alt+6"]
export_canvas_file = []
export_canvas_clipboard = []
export_canvas_clipboard_and_file = []
+export_board_pdf_file = []
# Open the most recent capture folder
open_capture_folder = ["Ctrl+Alt+O"]
diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs
index d4d553ad..cf86124f 100644
--- a/configurator/src/models/keybindings/field/config/read.rs
+++ b/configurator/src/models/keybindings/field/config/read.rs
@@ -111,6 +111,7 @@ impl KeybindingField {
Self::ExportCanvasFile => &config.capture.export_canvas_file,
Self::ExportCanvasClipboard => &config.capture.export_canvas_clipboard,
Self::ExportCanvasClipboardAndFile => &config.capture.export_canvas_clipboard_and_file,
+ Self::ExportBoardPdfFile => &config.capture.export_board_pdf_file,
Self::OpenCaptureFolder => &config.capture.open_capture_folder,
Self::ToggleFrozenMode => &config.zoom.toggle_frozen_mode,
Self::ZoomIn => &config.zoom.zoom_in,
diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs
index 4c335f79..be308e61 100644
--- a/configurator/src/models/keybindings/field/config/write.rs
+++ b/configurator/src/models/keybindings/field/config/write.rs
@@ -114,6 +114,7 @@ impl KeybindingField {
Self::ExportCanvasClipboardAndFile => {
config.capture.export_canvas_clipboard_and_file = value;
}
+ Self::ExportBoardPdfFile => config.capture.export_board_pdf_file = value,
Self::OpenCaptureFolder => config.capture.open_capture_folder = value,
Self::ToggleFrozenMode => config.zoom.toggle_frozen_mode = value,
Self::ZoomIn => config.zoom.zoom_in = value,
diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs
index 90195412..409b2aa4 100644
--- a/configurator/src/models/keybindings/field/labels.rs
+++ b/configurator/src/models/keybindings/field/labels.rs
@@ -106,6 +106,7 @@ impl KeybindingField {
Self::ExportCanvasFile => "Export canvas: file",
Self::ExportCanvasClipboard => "Export canvas: clipboard",
Self::ExportCanvasClipboardAndFile => "Export canvas: clipboard and file",
+ Self::ExportBoardPdfFile => "Export board: PDF file",
Self::OpenCaptureFolder => "Open capture folder",
Self::ToggleFrozenMode => "Toggle freeze",
Self::ZoomIn => "Zoom in",
@@ -238,6 +239,7 @@ impl KeybindingField {
Self::ExportCanvasFile => "export_canvas_file",
Self::ExportCanvasClipboard => "export_canvas_clipboard",
Self::ExportCanvasClipboardAndFile => "export_canvas_clipboard_and_file",
+ Self::ExportBoardPdfFile => "export_board_pdf_file",
Self::OpenCaptureFolder => "open_capture_folder",
Self::ToggleFrozenMode => "toggle_frozen_mode",
Self::ZoomIn => "zoom_in",
diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs
index 0ad35b62..e5119e5f 100644
--- a/configurator/src/models/keybindings/field/list.rs
+++ b/configurator/src/models/keybindings/field/list.rs
@@ -106,6 +106,7 @@ impl KeybindingField {
Self::ExportCanvasFile,
Self::ExportCanvasClipboard,
Self::ExportCanvasClipboardAndFile,
+ Self::ExportBoardPdfFile,
Self::OpenCaptureFolder,
Self::ToggleFrozenMode,
Self::ZoomIn,
diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs
index efe4ea67..dbe4a4fa 100644
--- a/configurator/src/models/keybindings/field/mod.rs
+++ b/configurator/src/models/keybindings/field/mod.rs
@@ -93,6 +93,7 @@ pub enum KeybindingField {
ExportCanvasFile,
ExportCanvasClipboard,
ExportCanvasClipboardAndFile,
+ ExportBoardPdfFile,
OpenCaptureFolder,
ToggleFrozenMode,
ZoomIn,
diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs
index 9802b1bc..85ff5bb9 100644
--- a/configurator/src/models/keybindings/field/tab.rs
+++ b/configurator/src/models/keybindings/field/tab.rs
@@ -108,6 +108,7 @@ impl KeybindingField {
| Self::ExportCanvasFile
| Self::ExportCanvasClipboard
| Self::ExportCanvasClipboardAndFile
+ | Self::ExportBoardPdfFile
| Self::OpenCaptureFolder
| Self::ToggleFrozenMode
| Self::ZoomIn
diff --git a/configurator/src/models/keybindings/tests.rs b/configurator/src/models/keybindings/tests.rs
index c378be01..0b668b87 100644
--- a/configurator/src/models/keybindings/tests.rs
+++ b/configurator/src/models/keybindings/tests.rs
@@ -3,6 +3,7 @@ use wayscriber::config::keybindings::KeybindingsConfig;
use super::draft::KeybindingsDraft;
use super::field::KeybindingField;
use super::parse::parse_keybinding_list;
+use crate::models::KeybindingsTabId;
#[test]
fn parse_keybinding_list_trims_and_ignores_empty() {
@@ -21,3 +22,28 @@ fn keybindings_draft_to_config_updates_fields() {
vec!["Ctrl+Q".to_string(), "Escape".to_string()]
);
}
+
+#[test]
+fn board_pdf_export_keybinding_field_is_visible_and_in_capture_tab() {
+ assert!(
+ KeybindingField::all().contains(&KeybindingField::ExportBoardPdfFile),
+ "PDF export field should appear in ordered keybinding list"
+ );
+ assert_eq!(
+ KeybindingField::ExportBoardPdfFile.tab(),
+ KeybindingsTabId::CaptureView
+ );
+}
+
+#[test]
+fn board_pdf_export_keybinding_field_reads_and_writes_config() {
+ let mut config = KeybindingsConfig::default();
+ assert!(KeybindingField::ExportBoardPdfFile.get(&config).is_empty());
+
+ KeybindingField::ExportBoardPdfFile.set(&mut config, vec!["Ctrl+Alt+P".to_string()]);
+
+ assert_eq!(
+ config.capture.export_board_pdf_file,
+ vec!["Ctrl+Alt+P".to_string()]
+ );
+}
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 08f762f8..6d5c2ad9 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -709,9 +709,10 @@ mappings = [
- Set `apply_to_ui = false` to preview remapped canvas content while keeping screen-space UI text and controls in the normal theme.
- Profiles do not recolor the compositor-owned live desktop seen through a transparent overlay.
- Explicit canvas PNG export applies its resolved export profile to persisted Wayscriber canvas content only, uses the current panned board viewport, respects output scale, and excludes frozen/zoom desktop pixels.
+- Board PDF export writes the active board to a file with one logical viewport-sized PDF page per Wayscriber page. PDF export preserves page order and solid board backgrounds, but does not apply export render profiles.
- Explicit canvas export and its clipboard-failure fallback save PNG data as `.png`; screenshot clipboard fallback still uses `[capture].format`.
-- `[capture].enabled` disables compositor screenshot capture actions, not explicit canvas export actions.
-- PDF export is out of scope.
+- `[capture].enabled` disables compositor screenshot capture actions, not explicit export actions.
+- Board PDF export is file-only; clipboard PDF export is not supported yet.
**Runtime actions:**
- `render_profile_next`
@@ -722,6 +723,7 @@ mappings = [
- `export_canvas_file`
- `export_canvas_clipboard`
- `export_canvas_clipboard_and_file`
+- `export_board_pdf_file`
### `[capture]` - Screenshot Capture
@@ -1023,6 +1025,7 @@ capture_file_region = ["Ctrl+Alt+6"]
export_canvas_file = []
export_canvas_clipboard = []
export_canvas_clipboard_and_file = []
+export_board_pdf_file = []
# Open the most recent capture folder
open_capture_folder = ["Ctrl+Alt+O"]
diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs
index 78610c09..b21e30aa 100644
--- a/src/backend/wayland/backend/event_loop/capture.rs
+++ b/src/backend/wayland/backend/event_loop/capture.rs
@@ -36,6 +36,9 @@ pub(super) fn handle_pending_actions(
match action {
PendingBackendAction::Screenshot(action) => state.handle_capture_action(action),
PendingBackendAction::CanvasExport(action) => state.handle_canvas_export_action(action),
+ PendingBackendAction::BoardPdfExport(action) => {
+ state.handle_board_pdf_export_action(action);
+ }
}
}
if let Some(action) = state.input_state.take_pending_output_focus_action() {
@@ -171,6 +174,9 @@ fn handle_capture_results(state: &mut WaylandState) {
crate::capture::ImageOperationKind::CanvasExport => {
"Canvas exported".to_string()
}
+ crate::capture::ImageOperationKind::BoardPdfExport => {
+ "Board exported".to_string()
+ }
}
} else {
message_parts.join(" - ")
diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs
index d4823f5a..b7f33a5a 100644
--- a/src/backend/wayland/state.rs
+++ b/src/backend/wayland/state.rs
@@ -45,12 +45,13 @@ use crate::input::tablet::TabletSettings;
use crate::{
backend::ExitAfterCaptureMode,
canvas_export::{
- BoardExportSnapshot, CanvasExportBackdropSnapshot, CanvasExportSnapshot,
- CanvasExportViewport, render_canvas_png,
+ BoardExportSnapshot, BoardPdfExportSnapshot, CanvasExportBackdropSnapshot,
+ CanvasExportSnapshot, CanvasExportViewport, CanvasPageExportSnapshot, render_board_pdf,
+ render_canvas_png,
},
capture::{
- CaptureDestination, CaptureManager, ImageDeliveryRequest, ImageFormatMetadata,
- ImageOperationKind,
+ CaptureDestination, CaptureManager, DocumentDeliveryRequest, ImageDeliveryRequest,
+ ImageFormatMetadata, ImageOperationKind, RenderedDocument,
file::{FileSaveConfig, expand_tilde},
types::CaptureType,
},
diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs
index 4d4cd870..1b1d8c3c 100644
--- a/src/backend/wayland/state/capture.rs
+++ b/src/backend/wayland/state/capture.rs
@@ -214,6 +214,70 @@ impl WaylandState {
}
}
+ pub(in crate::backend::wayland) fn handle_board_pdf_export_action(&mut self, action: Action) {
+ if self.capture.is_in_progress() {
+ log::warn!(
+ "Board PDF export action {:?} requested while another image operation is running; ignoring",
+ action
+ );
+ return;
+ }
+
+ if !matches!(action, Action::ExportBoardPdfFile) {
+ log::error!(
+ "Non-board-PDF-export action passed to handle_board_pdf_export_action: {:?}",
+ action
+ );
+ return;
+ }
+
+ let snapshot = self.board_pdf_export_snapshot();
+ let bytes = match render_board_pdf(&snapshot) {
+ Ok(bytes) => bytes,
+ Err(err) => {
+ let message = ImageOperationKind::BoardPdfExport.format_error(&err);
+ log::error!("Board PDF export failed: {}", message);
+ self.input_state
+ .set_ui_toast(crate::input::state::UiToastKind::Error, message);
+ return;
+ }
+ };
+
+ let destination = CaptureDestination::FileOnly;
+ self.capture
+ .set_exit_on_success(self.should_exit_after_capture(destination));
+ self.capture.mark_in_progress();
+
+ let request = DocumentDeliveryRequest {
+ document: RenderedDocument {
+ bytes,
+ extension: "pdf".to_string(),
+ mime_type: "application/pdf".to_string(),
+ },
+ destination,
+ save_config: Some(FileSaveConfig {
+ save_directory: expand_tilde(&self.config.capture.save_directory),
+ filename_template: self.config.capture.filename_template.clone(),
+ format: "pdf".to_string(),
+ }),
+ operation: ImageOperationKind::BoardPdfExport,
+ };
+
+ if let Err(err) = self
+ .capture
+ .manager_mut()
+ .request_document_delivery(request)
+ {
+ log::error!("Failed to request board PDF export delivery: {}", err);
+ self.capture.clear_in_progress();
+ self.capture.clear_exit_on_success();
+ self.input_state.set_ui_toast(
+ crate::input::state::UiToastKind::Error,
+ format!("Board PDF export failed: {err}"),
+ );
+ }
+ }
+
fn canvas_export_snapshot(&self) -> CanvasExportSnapshot {
let (origin_x, origin_y) = self.board_view_offset();
CanvasExportSnapshot {
@@ -243,6 +307,16 @@ impl WaylandState {
}
}
+ fn board_pdf_export_snapshot(&self) -> BoardPdfExportSnapshot {
+ build_board_pdf_export_snapshot(
+ self.surface.width(),
+ self.surface.height(),
+ self.input_state.boards.active_background(),
+ self.input_state.boards.pan_enabled(),
+ self.input_state.boards.active_pages().pages(),
+ )
+ }
+
pub(in crate::backend::wayland) fn begin_pending_capture(&mut self, request: CaptureRequest) {
log::info!("Requesting {:?} capture", request.capture_type);
if let Err(e) = self.capture.manager_mut().request_capture(
@@ -260,3 +334,129 @@ impl WaylandState {
}
}
}
+
+fn build_board_pdf_export_snapshot(
+ logical_width: u32,
+ logical_height: u32,
+ background: &crate::input::BoardBackground,
+ pan_enabled: bool,
+ frames: &[crate::draw::Frame],
+) -> BoardPdfExportSnapshot {
+ let backdrop = match background {
+ crate::input::BoardBackground::Transparent => CanvasExportBackdropSnapshot::Transparent,
+ crate::input::BoardBackground::Solid(color) => CanvasExportBackdropSnapshot::Solid(*color),
+ };
+ let use_page_offsets = pan_enabled && !background.is_transparent();
+
+ let mut pages = frames
+ .iter()
+ .map(|frame| {
+ let (origin_x, origin_y) = if use_page_offsets {
+ frame.view_offset()
+ } else {
+ (0, 0)
+ };
+ CanvasPageExportSnapshot {
+ frame: frame.clone_without_history(),
+ backdrop: backdrop.clone(),
+ viewport_width: logical_width,
+ viewport_height: logical_height,
+ origin_x,
+ origin_y,
+ }
+ })
+ .collect::>();
+
+ if pages.is_empty() {
+ pages.push(CanvasPageExportSnapshot {
+ frame: crate::draw::Frame::new(),
+ backdrop,
+ viewport_width: logical_width,
+ viewport_height: logical_height,
+ origin_x: 0,
+ origin_y: 0,
+ });
+ }
+
+ BoardPdfExportSnapshot {
+ logical_width,
+ logical_height,
+ pages,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::draw::{Frame, WHITE};
+ use crate::input::BoardBackground;
+
+ #[test]
+ fn board_pdf_snapshot_preserves_page_order() {
+ let mut first = Frame::new();
+ first.set_page_name(Some("first".to_string()));
+ let mut second = Frame::new();
+ second.set_page_name(Some("second".to_string()));
+
+ let snapshot = build_board_pdf_export_snapshot(
+ 800,
+ 600,
+ &BoardBackground::Solid(WHITE),
+ true,
+ &[first, second],
+ );
+
+ assert_eq!(snapshot.pages.len(), 2);
+ assert_eq!(snapshot.pages[0].frame.page_name(), Some("first"));
+ assert_eq!(snapshot.pages[1].frame.page_name(), Some("second"));
+ }
+
+ #[test]
+ fn board_pdf_snapshot_uses_per_page_view_offset_for_solid_pannable_boards() {
+ let mut first = Frame::new();
+ assert!(first.set_view_offset(100, -50));
+ let mut second = Frame::new();
+ assert!(second.set_view_offset(-25, 40));
+
+ let snapshot = build_board_pdf_export_snapshot(
+ 800,
+ 600,
+ &BoardBackground::Solid(WHITE),
+ true,
+ &[first, second],
+ );
+
+ assert_eq!(snapshot.pages[0].origin_x, 100);
+ assert_eq!(snapshot.pages[0].origin_y, -50);
+ assert_eq!(snapshot.pages[1].origin_x, -25);
+ assert_eq!(snapshot.pages[1].origin_y, 40);
+ }
+
+ #[test]
+ fn board_pdf_snapshot_forces_origin_for_transparent_boards() {
+ let mut frame = Frame::new();
+ assert!(frame.set_view_offset(100, -50));
+
+ let snapshot = build_board_pdf_export_snapshot(
+ 800,
+ 600,
+ &BoardBackground::Transparent,
+ true,
+ &[frame],
+ );
+
+ assert_eq!(snapshot.pages[0].origin_x, 0);
+ assert_eq!(snapshot.pages[0].origin_y, 0);
+ }
+
+ #[test]
+ fn board_pdf_snapshot_normalizes_empty_frame_list_to_blank_page() {
+ let snapshot =
+ build_board_pdf_export_snapshot(800, 600, &BoardBackground::Solid(WHITE), true, &[]);
+
+ assert_eq!(snapshot.pages.len(), 1);
+ assert!(snapshot.pages[0].frame.is_empty());
+ assert_eq!(snapshot.pages[0].viewport_width, 800);
+ assert_eq!(snapshot.pages[0].viewport_height, 600);
+ }
+}
diff --git a/src/canvas_export.rs b/src/canvas_export.rs
index da18b90a..caedc4d5 100644
--- a/src/canvas_export.rs
+++ b/src/canvas_export.rs
@@ -16,6 +16,23 @@ pub struct CanvasExportSnapshot {
pub render_profile: Option,
}
+#[derive(Debug, Clone)]
+pub struct CanvasPageExportSnapshot {
+ pub frame: Frame,
+ pub backdrop: CanvasExportBackdropSnapshot,
+ pub viewport_width: u32,
+ pub viewport_height: u32,
+ pub origin_x: i32,
+ pub origin_y: i32,
+}
+
+#[derive(Debug, Clone)]
+pub struct BoardPdfExportSnapshot {
+ pub logical_width: u32,
+ pub logical_height: u32,
+ pub pages: Vec,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CanvasExportViewport {
pub logical_width: u32,
@@ -90,8 +107,8 @@ fn render_canvas_surface(
CaptureError::ImageError(format!("Failed to create canvas context: {err}"))
})?;
- let backdrop = ExportBackdrop::new(&snapshot.backdrop)?;
- draw_canvas_snapshot(&ctx, snapshot, &backdrop, scale);
+ let page = canvas_page_from_snapshot(snapshot);
+ draw_canvas_page(&ctx, &page, scale as f64)?;
}
if let Some(profile) = snapshot.render_profile.as_ref() {
@@ -122,24 +139,88 @@ fn render_canvas_surface(
Ok(surface)
}
-fn draw_canvas_snapshot(
+pub fn draw_canvas_page(
ctx: &cairo::Context,
- snapshot: &CanvasExportSnapshot,
+ page: &CanvasPageExportSnapshot,
+ output_scale: f64,
+) -> Result<(), CaptureError> {
+ let backdrop = ExportBackdrop::new(&page.backdrop)?;
+ draw_canvas_page_contents(ctx, page, &backdrop, output_scale);
+ Ok(())
+}
+
+pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, CaptureError> {
+ if snapshot.logical_width == 0 || snapshot.logical_height == 0 {
+ return Err(CaptureError::ImageError(
+ "Board PDF export requires a configured non-empty surface".to_string(),
+ ));
+ }
+ if snapshot.pages.is_empty() {
+ return Err(CaptureError::ImageError(
+ "Board PDF export requires at least one page".to_string(),
+ ));
+ }
+
+ let width = snapshot.logical_width as f64;
+ let height = snapshot.logical_height as f64;
+ let surface = cairo::PdfSurface::for_stream(width, height, Vec::::new())
+ .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF surface: {err}")))?;
+ let ctx = cairo::Context::new(&surface)
+ .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF context: {err}")))?;
+
+ for page in &snapshot.pages {
+ surface.set_size(width, height).map_err(|err| {
+ CaptureError::ImageError(format!("Failed to set PDF page size: {err}"))
+ })?;
+ draw_canvas_page(&ctx, page, 1.0)?;
+ ctx.show_page()
+ .map_err(|err| CaptureError::ImageError(format!("Failed to finish PDF page: {err}")))?;
+ }
+
+ drop(ctx);
+
+ let stream = surface
+ .finish_output_stream()
+ .map_err(|err| CaptureError::ImageError(format!("Failed to finish PDF output: {err}")))?;
+ let bytes = stream.downcast::>().map_err(|_| {
+ CaptureError::ImageError("PDF output stream had unexpected type".to_string())
+ })?;
+ Ok(*bytes)
+}
+
+fn canvas_page_from_snapshot(snapshot: &CanvasExportSnapshot) -> CanvasPageExportSnapshot {
+ CanvasPageExportSnapshot {
+ frame: snapshot.board.frame.clone_without_history(),
+ backdrop: snapshot.backdrop.clone(),
+ viewport_width: snapshot.viewport.logical_width,
+ viewport_height: snapshot.viewport.logical_height,
+ origin_x: snapshot.viewport.origin_x,
+ origin_y: snapshot.viewport.origin_y,
+ }
+}
+
+fn draw_canvas_page_contents(
+ ctx: &cairo::Context,
+ page: &CanvasPageExportSnapshot,
backdrop: &ExportBackdrop,
- scale: i32,
+ output_scale: f64,
) {
let _ = ctx.save();
- if scale > 1 {
- ctx.scale(scale as f64, scale as f64);
+ if (output_scale - 1.0).abs() > f64::EPSILON {
+ ctx.scale(output_scale, output_scale);
}
- ctx.translate(
- -(snapshot.viewport.origin_x as f64),
- -(snapshot.viewport.origin_y as f64),
+ ctx.rectangle(
+ 0.0,
+ 0.0,
+ page.viewport_width as f64,
+ page.viewport_height as f64,
);
+ ctx.clip();
+ ctx.translate(-(page.origin_x as f64), -(page.origin_y as f64));
backdrop.paint(ctx);
let replay_ctx = backdrop.replay_context();
- for drawn_shape in &snapshot.board.frame.shapes {
+ for drawn_shape in &page.frame.shapes {
match &drawn_shape.shape {
Shape::EraserStroke { points, brush } => {
render_eraser_stroke(ctx, points, brush, &replay_ctx);
@@ -327,6 +408,17 @@ mod tests {
}
}
+ fn page_snapshot(frame: Frame) -> CanvasPageExportSnapshot {
+ CanvasPageExportSnapshot {
+ frame,
+ backdrop: CanvasExportBackdropSnapshot::Transparent,
+ viewport_width: 20,
+ viewport_height: 20,
+ origin_x: 0,
+ origin_y: 0,
+ }
+ }
+
fn pixel(surface: &mut cairo::ImageSurface, x: i32, y: i32) -> u32 {
surface.flush();
let stride = surface.stride() as usize;
@@ -392,6 +484,71 @@ mod tests {
assert_eq!(pixel(&mut surface, 0, 0), 0);
}
+ #[test]
+ fn draw_canvas_page_uses_explicit_output_scale() {
+ let mut frame = Frame::new();
+ frame.add_shape(Shape::Rect {
+ x: 4,
+ y: 4,
+ w: 2,
+ h: 2,
+ fill: true,
+ color: RED,
+ thick: 1.0,
+ });
+ let mut surface =
+ cairo::ImageSurface::create(cairo::Format::ARgb32, 20, 20).expect("surface");
+ {
+ let ctx = cairo::Context::new(&surface).expect("context");
+ draw_canvas_page(&ctx, &page_snapshot(frame), 2.0).expect("draw");
+ }
+
+ assert_ne!(pixel(&mut surface, 9, 9), 0);
+ assert_eq!(pixel(&mut surface, 1, 1), 0);
+ }
+
+ #[test]
+ fn render_board_pdf_returns_pdf_bytes() {
+ let pdf = render_board_pdf(&BoardPdfExportSnapshot {
+ logical_width: 64,
+ logical_height: 48,
+ pages: vec![page_snapshot(Frame::new())],
+ })
+ .expect("pdf");
+
+ assert!(pdf.starts_with(b"%PDF-"));
+ }
+
+ #[test]
+ fn render_board_pdf_rejects_zero_dimensions() {
+ let err = render_board_pdf(&BoardPdfExportSnapshot {
+ logical_width: 0,
+ logical_height: 48,
+ pages: vec![page_snapshot(Frame::new())],
+ })
+ .expect_err("zero width should fail");
+
+ assert!(
+ err.to_string().contains("non-empty surface"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn render_board_pdf_rejects_empty_pages() {
+ let err = render_board_pdf(&BoardPdfExportSnapshot {
+ logical_width: 64,
+ logical_height: 48,
+ pages: Vec::new(),
+ })
+ .expect_err("empty pages should fail");
+
+ assert!(
+ err.to_string().contains("at least one page"),
+ "unexpected error: {err}"
+ );
+ }
+
#[test]
fn export_applies_cloned_profile_to_pixels() {
let mut frame = Frame::new();
diff --git a/src/capture/manager.rs b/src/capture/manager.rs
index fedd82e2..9abe5824 100644
--- a/src/capture/manager.rs
+++ b/src/capture/manager.rs
@@ -5,10 +5,12 @@ use tokio::sync::{Mutex, mpsc};
use crate::capture::{
dependencies::CaptureDependencies,
file::FileSaveConfig,
- pipeline::{CaptureManagerRequest, CaptureRequest, deliver_image, perform_capture},
+ pipeline::{
+ CaptureManagerRequest, CaptureRequest, deliver_document, deliver_image, perform_capture,
+ },
types::{
CaptureDestination, CaptureError, CaptureOutcome, CaptureStatus, CaptureType,
- ImageDeliveryRequest,
+ DocumentDeliveryRequest, ImageDeliveryRequest,
},
};
@@ -67,6 +69,9 @@ impl CaptureManager {
CaptureManagerRequest::DeliverImage(request) => {
deliver_image(request, deps_clone.clone()).await
}
+ CaptureManagerRequest::DeliverDocument(request) => {
+ deliver_document(request, deps_clone.clone()).await
+ }
};
match outcome {
@@ -140,6 +145,17 @@ impl CaptureManager {
Ok(())
}
+ pub fn request_document_delivery(
+ &self,
+ request: DocumentDeliveryRequest,
+ ) -> Result<(), CaptureError> {
+ self.request_tx
+ .send(CaptureManagerRequest::DeliverDocument(request))
+ .map_err(|_| CaptureError::ImageError("Capture manager not running".to_string()))?;
+
+ Ok(())
+ }
+
/// Get the current capture status.
#[allow(dead_code)] // Will be used in Phase 2 for status UI
pub async fn get_status(&self) -> CaptureStatus {
diff --git a/src/capture/mod.rs b/src/capture/mod.rs
index 60c29935..348ce177 100644
--- a/src/capture/mod.rs
+++ b/src/capture/mod.rs
@@ -26,5 +26,6 @@ pub(crate) use pipeline::CaptureRequest;
#[allow(unused_imports)]
pub use types::{
CaptureDestination, CaptureError, CaptureOutcome, CaptureResult, CaptureStatus, CaptureType,
- ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedImage,
+ DocumentDeliveryRequest, ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind,
+ RenderedDocument, RenderedImage,
};
diff --git a/src/capture/pipeline.rs b/src/capture/pipeline.rs
index 6ccb57cd..cd4ca716 100644
--- a/src/capture/pipeline.rs
+++ b/src/capture/pipeline.rs
@@ -4,8 +4,8 @@ use crate::capture::{
dependencies::{CaptureClipboard, CaptureDependencies, CaptureFileSaver},
file::FileSaveConfig,
types::{
- CaptureDestination, CaptureError, CaptureResult, CaptureType, ImageDeliveryRequest,
- ImageOperationKind,
+ CaptureDestination, CaptureError, CaptureResult, CaptureType, DocumentDeliveryRequest,
+ ImageDeliveryRequest, ImageOperationKind,
},
};
use tokio::task;
@@ -37,6 +37,7 @@ impl fmt::Debug for CaptureRequest {
pub(crate) enum CaptureManagerRequest {
Capture(CaptureRequest),
DeliverImage(ImageDeliveryRequest),
+ DeliverDocument(DocumentDeliveryRequest),
}
impl CaptureManagerRequest {
@@ -44,6 +45,7 @@ impl CaptureManagerRequest {
match self {
Self::Capture(_) => ImageOperationKind::Screenshot,
Self::DeliverImage(request) => request.operation,
+ Self::DeliverDocument(request) => request.operation,
}
}
}
@@ -60,6 +62,13 @@ impl fmt::Debug for CaptureManagerRequest {
.field("height", &request.image.height)
.field("format", &request.image.format)
.finish(),
+ Self::DeliverDocument(request) => f
+ .debug_struct("DeliverDocument")
+ .field("destination", &request.destination)
+ .field("operation", &request.operation)
+ .field("extension", &request.document.extension)
+ .field("mime_type", &request.document.mime_type)
+ .finish(),
}
}
}
@@ -95,7 +104,7 @@ pub(crate) async fn perform_capture(
if let Some(save_config) = request.save_config.clone() {
if !save_config.save_directory.as_os_str().is_empty() {
Some(
- save_image(
+ save_bytes(
Arc::clone(&dependencies.saver),
image_data.clone(),
save_config,
@@ -112,7 +121,7 @@ pub(crate) async fn perform_capture(
CaptureDestination::ClipboardAndFile => {
if let Some(save_config) = request.save_config.clone() {
if !save_config.save_directory.as_os_str().is_empty() {
- match save_image(
+ match save_bytes(
Arc::clone(&dependencies.saver),
image_data.clone(),
save_config,
@@ -188,7 +197,7 @@ pub(crate) async fn deliver_image(
if let Some(config) =
save_config.filter(|config| !config.save_directory.as_os_str().is_empty())
{
- Some(save_image(Arc::clone(&dependencies.saver), image_data.clone(), config).await?)
+ Some(save_bytes(Arc::clone(&dependencies.saver), image_data.clone(), config).await?)
} else {
None
}
@@ -197,7 +206,7 @@ pub(crate) async fn deliver_image(
if let Some(config) =
save_config.filter(|config| !config.save_directory.as_os_str().is_empty())
{
- match save_image(Arc::clone(&dependencies.saver), image_data.clone(), config).await
+ match save_bytes(Arc::clone(&dependencies.saver), image_data.clone(), config).await
{
Ok(path) => Some(path),
Err(err) => {
@@ -240,12 +249,59 @@ pub(crate) async fn deliver_image(
})
}
-async fn save_image(
+pub(crate) async fn deliver_document(
+ request: DocumentDeliveryRequest,
+ dependencies: Arc,
+) -> Result {
+ log::info!(
+ "Starting document delivery: {:?} {} {} bytes",
+ request.operation,
+ request.document.mime_type,
+ request.document.bytes.len()
+ );
+
+ if !matches!(request.destination, CaptureDestination::FileOnly) {
+ return Err(CaptureError::ImageError(
+ "PDF clipboard export is not supported yet".to_string(),
+ ));
+ }
+
+ let Some(mut save_config) = request.save_config else {
+ return Err(CaptureError::ImageError(
+ "Board PDF export requires file save configuration".to_string(),
+ ));
+ };
+
+ if save_config.save_directory.as_os_str().is_empty() {
+ return Err(CaptureError::ImageError(
+ "Board PDF export requires a save directory".to_string(),
+ ));
+ }
+
+ save_config.format = request.document.extension.clone();
+ let document_bytes = request.document.bytes;
+ let saved_path = save_bytes(
+ Arc::clone(&dependencies.saver),
+ document_bytes.clone(),
+ save_config,
+ )
+ .await?;
+
+ Ok(CaptureResult {
+ image_data: document_bytes,
+ operation: request.operation,
+ fallback_format_override: None,
+ saved_path: Some(saved_path),
+ copied_to_clipboard: false,
+ })
+}
+
+async fn save_bytes(
saver: Arc,
- image_data: Vec,
+ bytes: Vec,
config: FileSaveConfig,
) -> Result {
- task::spawn_blocking(move || saver.save(&image_data, &config))
+ task::spawn_blocking(move || saver.save(&bytes, &config))
.await
.map_err(|e| CaptureError::ImageError(format!("Save task failed: {}", e)))?
}
diff --git a/src/capture/tests/manager.rs b/src/capture/tests/manager.rs
index 9ba30b4c..00b17c40 100644
--- a/src/capture/tests/manager.rs
+++ b/src/capture/tests/manager.rs
@@ -6,7 +6,8 @@ use std::{
use tokio::time::{Duration, sleep};
use crate::capture::{
- ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedImage,
+ DocumentDeliveryRequest, ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind,
+ RenderedDocument, RenderedImage,
dependencies::CaptureDependencies,
file::FileSaveConfig,
manager::CaptureManager,
@@ -34,6 +35,14 @@ fn rendered_png(bytes: Vec) -> RenderedImage {
}
}
+fn rendered_pdf(bytes: Vec) -> RenderedDocument {
+ RenderedDocument {
+ bytes,
+ extension: "pdf".to_string(),
+ mime_type: "application/pdf".to_string(),
+ }
+}
+
#[tokio::test]
async fn test_capture_manager_creation() {
let manager = CaptureManager::new(&tokio::runtime::Handle::current());
@@ -214,6 +223,56 @@ async fn request_image_delivery_queues_manager_backed_path() {
assert_eq!(manager.get_status().await, CaptureStatus::Success);
}
+#[tokio::test]
+async fn request_document_delivery_reports_board_pdf_success() {
+ let captured_types = Arc::new(Mutex::new(Vec::new()));
+ let source = MockSource {
+ data: vec![99],
+ error: Arc::new(Mutex::new(None)),
+ captured_types: captured_types.clone(),
+ };
+ let saver = MockSaver {
+ should_fail: false,
+ path: PathBuf::from("/tmp/board.pdf"),
+ calls: Arc::new(Mutex::new(0)),
+ };
+ let saver_handle = saver.clone();
+ let clipboard = MockClipboard {
+ should_fail: false,
+ calls: Arc::new(Mutex::new(0)),
+ };
+ let deps = CaptureDependencies {
+ source: Arc::new(source),
+ saver: Arc::new(saver),
+ clipboard: Arc::new(clipboard),
+ };
+ let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps);
+
+ manager
+ .request_document_delivery(DocumentDeliveryRequest {
+ document: rendered_pdf(b"%PDF-".to_vec()),
+ destination: CaptureDestination::FileOnly,
+ save_config: Some(FileSaveConfig::default()),
+ operation: ImageOperationKind::BoardPdfExport,
+ })
+ .unwrap();
+
+ let outcome = wait_for_manager_outcome(&manager).await;
+
+ match outcome {
+ Some(CaptureOutcome::Success(result)) => {
+ assert_eq!(result.operation, ImageOperationKind::BoardPdfExport);
+ assert_eq!(result.image_data, b"%PDF-".to_vec());
+ assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/board.pdf")));
+ assert!(!result.copied_to_clipboard);
+ }
+ other => panic!("Expected success outcome, got {other:?}"),
+ }
+ assert_eq!(*saver_handle.calls.lock().unwrap(), 1);
+ assert!(captured_types.lock().unwrap().is_empty());
+ assert_eq!(manager.get_status().await, CaptureStatus::Success);
+}
+
#[tokio::test]
async fn request_image_delivery_records_canvas_save_failure() {
let captured_types = Arc::new(Mutex::new(Vec::new()));
@@ -275,6 +334,61 @@ async fn request_image_delivery_records_canvas_save_failure() {
));
}
+#[tokio::test]
+async fn request_document_delivery_records_board_pdf_save_failure() {
+ let captured_types = Arc::new(Mutex::new(Vec::new()));
+ let source = MockSource {
+ data: vec![99],
+ error: Arc::new(Mutex::new(None)),
+ captured_types: captured_types.clone(),
+ };
+ let saver = MockSaver {
+ should_fail: true,
+ path: PathBuf::from("/tmp/board.pdf"),
+ calls: Arc::new(Mutex::new(0)),
+ };
+ let saver_handle = saver.clone();
+ let clipboard = MockClipboard {
+ should_fail: false,
+ calls: Arc::new(Mutex::new(0)),
+ };
+ let deps = CaptureDependencies {
+ source: Arc::new(source),
+ saver: Arc::new(saver),
+ clipboard: Arc::new(clipboard),
+ };
+ let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps);
+
+ manager
+ .request_document_delivery(DocumentDeliveryRequest {
+ document: rendered_pdf(b"%PDF-".to_vec()),
+ destination: CaptureDestination::FileOnly,
+ save_config: Some(FileSaveConfig::default()),
+ operation: ImageOperationKind::BoardPdfExport,
+ })
+ .unwrap();
+
+ let outcome = wait_for_manager_outcome(&manager).await;
+
+ match outcome {
+ Some(CaptureOutcome::Failed { operation, message }) => {
+ assert_eq!(operation, ImageOperationKind::BoardPdfExport);
+ assert!(
+ message.contains("Failed to save board PDF export"),
+ "unexpected failure message: {message}"
+ );
+ }
+ other => panic!("Expected failure outcome, got {other:?}"),
+ }
+ assert_eq!(*saver_handle.calls.lock().unwrap(), 1);
+ assert!(captured_types.lock().unwrap().is_empty());
+ assert!(matches!(
+ manager.get_status().await,
+ CaptureStatus::Failed(ref message)
+ if message.contains("Failed to save board PDF export")
+ ));
+}
+
#[tokio::test]
async fn request_image_delivery_preserves_clipboard_success_when_file_fails() {
let captured_types = Arc::new(Mutex::new(Vec::new()));
diff --git a/src/capture/tests/perform_capture.rs b/src/capture/tests/perform_capture.rs
index 75763cdb..e31d10d9 100644
--- a/src/capture/tests/perform_capture.rs
+++ b/src/capture/tests/perform_capture.rs
@@ -6,10 +6,11 @@ use std::{
use crate::capture::{
dependencies::{CaptureClipboard, CaptureDependencies, CaptureFileSaver},
file::FileSaveConfig,
- pipeline::{CaptureRequest, deliver_image, perform_capture},
+ pipeline::{CaptureRequest, deliver_document, deliver_image, perform_capture},
types::{
- CaptureDestination, CaptureError, CaptureType, ImageDeliveryRequest, ImageFormatMetadata,
- ImageOperationKind, RenderedImage,
+ CaptureDestination, CaptureError, CaptureType, DocumentDeliveryRequest,
+ ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedDocument,
+ RenderedImage,
},
};
@@ -67,6 +68,14 @@ fn rendered_png(bytes: Vec) -> RenderedImage {
}
}
+fn rendered_pdf(bytes: Vec) -> RenderedDocument {
+ RenderedDocument {
+ bytes,
+ extension: "pdf".to_string(),
+ mime_type: "application/pdf".to_string(),
+ }
+}
+
#[tokio::test]
async fn test_perform_capture_clipboard_only_success() {
let source = MockSource {
@@ -153,6 +162,164 @@ async fn deliver_image_file_only_saves_rendered_format_extension() {
assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/canvas.png")));
}
+#[tokio::test]
+async fn deliver_document_file_only_saves_pdf_bytes_with_pdf_extension() {
+ let configs = Arc::new(Mutex::new(Vec::new()));
+ let saver = RecordingSaver {
+ should_fail: false,
+ path: PathBuf::from("/tmp/board.pdf"),
+ calls: Arc::new(Mutex::new(0)),
+ configs: configs.clone(),
+ };
+ let clipboard = RecordingClipboard {
+ should_fail: false,
+ calls: Arc::new(Mutex::new(0)),
+ copied: Arc::new(Mutex::new(Vec::new())),
+ };
+ let deps = CaptureDependencies {
+ source: Arc::new(MockSource {
+ data: Vec::new(),
+ error: Arc::new(Mutex::new(None)),
+ captured_types: Arc::new(Mutex::new(Vec::new())),
+ }),
+ saver: Arc::new(saver.clone()),
+ clipboard: Arc::new(clipboard.clone()),
+ };
+ let request = DocumentDeliveryRequest {
+ document: rendered_pdf(b"%PDF-".to_vec()),
+ destination: CaptureDestination::FileOnly,
+ save_config: Some(FileSaveConfig {
+ format: "png".to_string(),
+ ..FileSaveConfig::default()
+ }),
+ operation: ImageOperationKind::BoardPdfExport,
+ };
+
+ let result = deliver_document(request, Arc::new(deps)).await.unwrap();
+
+ assert_eq!(result.operation, ImageOperationKind::BoardPdfExport);
+ assert_eq!(result.image_data, b"%PDF-".to_vec());
+ assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/board.pdf")));
+ assert!(!result.copied_to_clipboard);
+ assert_eq!(*saver.calls.lock().unwrap(), 1);
+ assert_eq!(configs.lock().unwrap()[0].format, "pdf");
+ assert_eq!(*clipboard.calls.lock().unwrap(), 0);
+}
+
+#[tokio::test]
+async fn deliver_document_rejects_clipboard_destinations() {
+ let deps = CaptureDependencies {
+ source: Arc::new(MockSource {
+ data: Vec::new(),
+ error: Arc::new(Mutex::new(None)),
+ captured_types: Arc::new(Mutex::new(Vec::new())),
+ }),
+ saver: Arc::new(RecordingSaver {
+ should_fail: false,
+ path: PathBuf::from("/tmp/unused.pdf"),
+ calls: Arc::new(Mutex::new(0)),
+ configs: Arc::new(Mutex::new(Vec::new())),
+ }),
+ clipboard: Arc::new(RecordingClipboard {
+ should_fail: false,
+ calls: Arc::new(Mutex::new(0)),
+ copied: Arc::new(Mutex::new(Vec::new())),
+ }),
+ };
+ let request = DocumentDeliveryRequest {
+ document: rendered_pdf(Vec::new()),
+ destination: CaptureDestination::ClipboardOnly,
+ save_config: Some(FileSaveConfig::default()),
+ operation: ImageOperationKind::BoardPdfExport,
+ };
+
+ let err = deliver_document(request, Arc::new(deps))
+ .await
+ .expect_err("clipboard PDF should fail");
+
+ assert!(
+ err.to_string().contains("not supported yet"),
+ "unexpected error: {err}"
+ );
+}
+
+#[tokio::test]
+async fn deliver_document_requires_save_config() {
+ let deps = CaptureDependencies {
+ source: Arc::new(MockSource {
+ data: Vec::new(),
+ error: Arc::new(Mutex::new(None)),
+ captured_types: Arc::new(Mutex::new(Vec::new())),
+ }),
+ saver: Arc::new(RecordingSaver {
+ should_fail: false,
+ path: PathBuf::from("/tmp/unused.pdf"),
+ calls: Arc::new(Mutex::new(0)),
+ configs: Arc::new(Mutex::new(Vec::new())),
+ }),
+ clipboard: Arc::new(RecordingClipboard {
+ should_fail: false,
+ calls: Arc::new(Mutex::new(0)),
+ copied: Arc::new(Mutex::new(Vec::new())),
+ }),
+ };
+ let request = DocumentDeliveryRequest {
+ document: rendered_pdf(Vec::new()),
+ destination: CaptureDestination::FileOnly,
+ save_config: None,
+ operation: ImageOperationKind::BoardPdfExport,
+ };
+
+ let err = deliver_document(request, Arc::new(deps))
+ .await
+ .expect_err("missing save config should fail");
+
+ assert!(
+ err.to_string().contains("file save configuration"),
+ "unexpected error: {err}"
+ );
+}
+
+#[tokio::test]
+async fn deliver_document_requires_save_directory() {
+ let deps = CaptureDependencies {
+ source: Arc::new(MockSource {
+ data: Vec::new(),
+ error: Arc::new(Mutex::new(None)),
+ captured_types: Arc::new(Mutex::new(Vec::new())),
+ }),
+ saver: Arc::new(RecordingSaver {
+ should_fail: false,
+ path: PathBuf::from("/tmp/unused.pdf"),
+ calls: Arc::new(Mutex::new(0)),
+ configs: Arc::new(Mutex::new(Vec::new())),
+ }),
+ clipboard: Arc::new(RecordingClipboard {
+ should_fail: false,
+ calls: Arc::new(Mutex::new(0)),
+ copied: Arc::new(Mutex::new(Vec::new())),
+ }),
+ };
+ let request = DocumentDeliveryRequest {
+ document: rendered_pdf(Vec::new()),
+ destination: CaptureDestination::FileOnly,
+ save_config: Some(FileSaveConfig {
+ save_directory: PathBuf::new(),
+ ..FileSaveConfig::default()
+ }),
+ operation: ImageOperationKind::BoardPdfExport,
+ };
+
+ let err = deliver_document(request, Arc::new(deps))
+ .await
+ .expect_err("empty save directory should fail");
+
+ assert!(
+ err.to_string().contains("save directory"),
+ "unexpected error: {err}"
+ );
+}
+
#[tokio::test]
async fn deliver_image_clipboard_only_copies_png_bytes() {
let copied = Arc::new(Mutex::new(Vec::new()));
diff --git a/src/capture/types.rs b/src/capture/types.rs
index e1096928..fb87998b 100644
--- a/src/capture/types.rs
+++ b/src/capture/types.rs
@@ -8,6 +8,7 @@ use std::path::PathBuf;
pub enum ImageOperationKind {
Screenshot,
CanvasExport,
+ BoardPdfExport,
}
impl ImageOperationKind {
@@ -15,6 +16,7 @@ impl ImageOperationKind {
match self {
Self::Screenshot => "Screenshot Captured",
Self::CanvasExport => "Canvas exported",
+ Self::BoardPdfExport => "Board exported",
}
}
@@ -22,6 +24,7 @@ impl ImageOperationKind {
match self {
Self::Screenshot => "Screenshot Failed",
Self::CanvasExport => "Canvas export failed",
+ Self::BoardPdfExport => "Board PDF export failed",
}
}
@@ -29,6 +32,7 @@ impl ImageOperationKind {
match self {
Self::Screenshot => "Screenshot Clipboard Failed",
Self::CanvasExport => "Canvas clipboard failed",
+ Self::BoardPdfExport => "Board PDF clipboard failed",
}
}
@@ -36,6 +40,7 @@ impl ImageOperationKind {
match self {
Self::Screenshot => "Clipboard failed",
Self::CanvasExport => "Canvas clipboard failed",
+ Self::BoardPdfExport => "Board PDF clipboard failed",
}
}
@@ -43,6 +48,7 @@ impl ImageOperationKind {
match self {
Self::Screenshot => "Screenshot",
Self::CanvasExport => "Canvas export",
+ Self::BoardPdfExport => "Board PDF export",
}
}
@@ -60,6 +66,19 @@ impl ImageOperationKind {
CaptureError::Cancelled(reason) => format!("Canvas export cancelled: {reason}"),
other => other.to_string(),
},
+ Self::BoardPdfExport => match err {
+ CaptureError::SaveError(err) => {
+ format!("Failed to save board PDF export: {err}")
+ }
+ CaptureError::ClipboardError(err) => {
+ format!("Board PDF export clipboard operation failed: {err}")
+ }
+ CaptureError::ImageError(err) => format!("Board PDF export failed: {err}"),
+ CaptureError::Cancelled(reason) => {
+ format!("Board PDF export cancelled: {reason}")
+ }
+ other => other.to_string(),
+ },
}
}
}
@@ -96,6 +115,21 @@ pub struct ImageDeliveryRequest {
pub fallback_format_override: Option,
}
+#[derive(Debug, Clone)]
+pub struct RenderedDocument {
+ pub bytes: Vec,
+ pub extension: String,
+ pub mime_type: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct DocumentDeliveryRequest {
+ pub document: RenderedDocument,
+ pub destination: CaptureDestination,
+ pub save_config: Option,
+ pub operation: ImageOperationKind,
+}
+
/// Type of screenshot capture to perform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureType {
diff --git a/src/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs
index 595e7d29..8cd6cb6a 100644
--- a/src/config/action_meta/entries/capture.rs
+++ b/src/config/action_meta/entries/capture.rs
@@ -130,6 +130,17 @@ pub const ENTRIES: &[ActionMeta] = &[
"clipboard"
]
),
+ meta!(
+ ExportBoardPdfFile,
+ "Export Board to PDF",
+ Some("Board to PDF"),
+ "Export active board pages as a PDF",
+ Capture,
+ true,
+ true,
+ false,
+ &["export board pdf", "board pdf", "pdf", "export pdf"]
+ ),
meta!(
OpenCaptureFolder,
"Open Capture Folder",
diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs
index f2d580d3..5bec9221 100644
--- a/src/config/action_meta/tests.rs
+++ b/src/config/action_meta/tests.rs
@@ -83,6 +83,7 @@ const HELP_ACTIONS: &[Action] = &[
Action::ExportCanvasFile,
Action::ExportCanvasClipboard,
Action::ExportCanvasClipboardAndFile,
+ Action::ExportBoardPdfFile,
Action::CaptureClipboardSelection,
Action::CaptureFileSelection,
Action::CaptureActiveWindow,
@@ -191,6 +192,7 @@ const PALETTE_ACTIONS: &[Action] = &[
Action::ExportCanvasFile,
Action::ExportCanvasClipboard,
Action::ExportCanvasClipboardAndFile,
+ Action::ExportBoardPdfFile,
Action::OpenCaptureFolder,
Action::ToggleFrozenMode,
Action::ZoomIn,
diff --git a/src/config/keybindings/actions.rs b/src/config/keybindings/actions.rs
index 3ea63eee..4b9b9104 100644
--- a/src/config/keybindings/actions.rs
+++ b/src/config/keybindings/actions.rs
@@ -148,6 +148,7 @@ pub enum Action {
ExportCanvasFile,
ExportCanvasClipboard,
ExportCanvasClipboardAndFile,
+ ExportBoardPdfFile,
OpenCaptureFolder,
ToggleFrozenMode,
ZoomIn,
diff --git a/src/config/keybindings/config/map/capture.rs b/src/config/keybindings/config/map/capture.rs
index c2810295..23a8aa0d 100644
--- a/src/config/keybindings/config/map/capture.rs
+++ b/src/config/keybindings/config/map/capture.rs
@@ -40,6 +40,10 @@ impl KeybindingsConfig {
&self.capture.export_canvas_clipboard_and_file,
Action::ExportCanvasClipboardAndFile,
)?;
+ inserter.insert_all(
+ &self.capture.export_board_pdf_file,
+ Action::ExportBoardPdfFile,
+ )?;
inserter.insert_all(&self.capture.open_capture_folder, Action::OpenCaptureFolder)?;
Ok(())
}
diff --git a/src/config/keybindings/config/types/bindings/capture.rs b/src/config/keybindings/config/types/bindings/capture.rs
index 194fd34d..dfaa2332 100644
--- a/src/config/keybindings/config/types/bindings/capture.rs
+++ b/src/config/keybindings/config/types/bindings/capture.rs
@@ -41,6 +41,9 @@ pub struct CaptureKeybindingsConfig {
#[serde(default = "default_export_canvas_clipboard_and_file")]
pub export_canvas_clipboard_and_file: Vec,
+ #[serde(default = "default_export_board_pdf_file")]
+ pub export_board_pdf_file: Vec,
+
#[serde(default = "default_open_capture_folder")]
pub open_capture_folder: Vec,
}
@@ -60,6 +63,7 @@ impl Default for CaptureKeybindingsConfig {
export_canvas_file: default_export_canvas_file(),
export_canvas_clipboard: default_export_canvas_clipboard(),
export_canvas_clipboard_and_file: default_export_canvas_clipboard_and_file(),
+ export_board_pdf_file: default_export_board_pdf_file(),
open_capture_folder: default_open_capture_folder(),
}
}
diff --git a/src/config/keybindings/defaults/capture.rs b/src/config/keybindings/defaults/capture.rs
index d518ab2a..e78c1e6b 100644
--- a/src/config/keybindings/defaults/capture.rs
+++ b/src/config/keybindings/defaults/capture.rs
@@ -46,6 +46,10 @@ pub(crate) fn default_export_canvas_clipboard_and_file() -> Vec {
Vec::new()
}
+pub(crate) fn default_export_board_pdf_file() -> Vec {
+ Vec::new()
+}
+
pub(crate) fn default_open_capture_folder() -> Vec {
vec!["Ctrl+Alt+O".to_string()]
}
diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs
index 9de5e654..5a08640c 100644
--- a/src/config/keybindings/tests.rs
+++ b/src/config/keybindings/tests.rs
@@ -254,6 +254,7 @@ fn build_action_map_includes_canvas_export_bindings() {
config.capture.export_canvas_file = vec!["Ctrl+Alt+Shift+F".to_string()];
config.capture.export_canvas_clipboard = vec!["Ctrl+Alt+Shift+C".to_string()];
config.capture.export_canvas_clipboard_and_file = vec!["Ctrl+Alt+Shift+B".to_string()];
+ config.capture.export_board_pdf_file = vec!["Ctrl+Alt+Shift+P".to_string()];
let map = config.build_action_map().unwrap();
@@ -269,6 +270,10 @@ fn build_action_map_includes_canvas_export_bindings() {
map.get(&KeyBinding::parse("Ctrl+Alt+Shift+B").unwrap()),
Some(&Action::ExportCanvasClipboardAndFile)
);
+ assert_eq!(
+ map.get(&KeyBinding::parse("Ctrl+Alt+Shift+P").unwrap()),
+ Some(&Action::ExportBoardPdfFile)
+ );
}
#[test]
@@ -296,4 +301,10 @@ fn canvas_export_actions_deserialize_from_config_names() {
.action,
Action::ExportCanvasClipboardAndFile
);
+ assert_eq!(
+ toml::from_str::("action = \"export_board_pdf_file\"")
+ .unwrap()
+ .action,
+ Action::ExportBoardPdfFile
+ );
}
diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs
index af603808..9693c464 100644
--- a/src/input/state/actions/action_capture_zoom.rs
+++ b/src/input/state/actions/action_capture_zoom.rs
@@ -37,6 +37,15 @@ impl InputState {
self.reset_modifiers();
true
}
+ Action::ExportBoardPdfFile => {
+ log::debug!("Board PDF export action {:?} pending for backend", action);
+ self.set_pending_backend_action(PendingBackendAction::BoardPdfExport(action));
+
+ // Clear modifiers to prevent them from being "stuck" after capture
+ // (portal dialog causes key releases to be missed or focus to flicker)
+ self.reset_modifiers();
+ true
+ }
Action::ToggleFrozenMode => {
log::info!("Toggle frozen mode requested");
self.request_frozen_toggle();
diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs
index 799fa9e6..ea659573 100644
--- a/src/input/state/core/base/types.rs
+++ b/src/input/state/core/base/types.rs
@@ -316,6 +316,7 @@ pub(crate) struct PendingClipboardFallback {
pub enum PendingBackendAction {
Screenshot(Action),
CanvasExport(Action),
+ BoardPdfExport(Action),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs
index 77b5a61c..af12fda0 100644
--- a/src/input/state/core/command_palette/mod.rs
+++ b/src/input/state/core/command_palette/mod.rs
@@ -295,6 +295,27 @@ mod tests {
);
}
+ #[test]
+ fn return_key_sets_pending_board_pdf_export_backend_action() {
+ let mut state = make_state();
+ state.toggle_command_palette();
+ state.command_palette_query = "export pdf".to_string();
+ let selected = state.selected_command().expect("selected command");
+ assert_eq!(
+ selected.action,
+ crate::config::keybindings::Action::ExportBoardPdfFile
+ );
+
+ assert!(state.handle_command_palette_key(crate::input::Key::Return));
+
+ assert_eq!(
+ state.take_pending_backend_action(),
+ Some(crate::input::state::PendingBackendAction::BoardPdfExport(
+ crate::config::keybindings::Action::ExportBoardPdfFile
+ ))
+ );
+ }
+
#[test]
fn clicking_outside_palette_closes_it() {
let mut state = make_state();
@@ -387,6 +408,31 @@ mod tests {
);
}
+ #[test]
+ fn clicking_visible_board_pdf_export_item_sets_pending_backend_action() {
+ let mut state = make_state();
+ state.toggle_command_palette();
+ state.command_palette_query = "export pdf".to_string();
+ state.command_palette_selected = 0;
+ let filtered = state.filtered_commands();
+ assert_eq!(
+ filtered.first().expect("selected command").action,
+ crate::config::keybindings::Action::ExportBoardPdfFile
+ );
+ let geometry = state.command_palette_geometry(1920, 1000, filtered.len());
+ let x = (geometry.x + geometry.inner_x + 4.0) as i32;
+ let y = (geometry.y + geometry.items_top + COMMAND_PALETTE_ITEM_HEIGHT * 0.5) as i32;
+
+ assert!(state.handle_command_palette_click(x, y, 1920, 1000));
+
+ assert_eq!(
+ state.take_pending_backend_action(),
+ Some(crate::input::state::PendingBackendAction::BoardPdfExport(
+ crate::config::keybindings::Action::ExportBoardPdfFile
+ ))
+ );
+ }
+
#[test]
fn cursor_hint_rejects_strip_below_clamped_panel_height() {
let mut state = make_state();
diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs
index b82dd886..d7868b74 100644
--- a/src/input/state/core/utility/toasts.rs
+++ b/src/input/state/core/utility/toasts.rs
@@ -227,6 +227,7 @@ impl InputState {
match fallback.operation {
ImageOperationKind::Screenshot => "Screenshot saved",
ImageOperationKind::CanvasExport => "Canvas exported",
+ ImageOperationKind::BoardPdfExport => "Board exported",
},
);
}
diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs
index 94c6b4cc..5a0a55c1 100644
--- a/src/input/state/interaction/actions.rs
+++ b/src/input/state/interaction/actions.rs
@@ -119,6 +119,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute {
| Action::ExportCanvasFile
| Action::ExportCanvasClipboard
| Action::ExportCanvasClipboardAndFile
+ | Action::ExportBoardPdfFile
| Action::ToggleFrozenMode
| Action::ZoomIn
| Action::ZoomOut
diff --git a/src/input/state/tests/text_input/actions.rs b/src/input/state/tests/text_input/actions.rs
index 5ca97d75..7a6106bb 100644
--- a/src/input/state/tests/text_input/actions.rs
+++ b/src/input/state/tests/text_input/actions.rs
@@ -69,3 +69,17 @@ fn canvas_export_action_sets_pending_backend_action() {
))
);
}
+
+#[test]
+fn board_pdf_export_action_sets_pending_backend_action() {
+ let mut state = create_test_input_state();
+
+ state.handle_action(Action::ExportBoardPdfFile);
+
+ assert_eq!(
+ state.take_pending_backend_action(),
+ Some(PendingBackendAction::BoardPdfExport(
+ Action::ExportBoardPdfFile
+ ))
+ );
+}
diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs
index af4db7ab..dd21bfb3 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -430,6 +430,10 @@ pub(super) fn build_main_sections(
),
action_label(Action::ExportCanvasClipboardAndFile),
),
+ row(
+ binding_or_fallback(bindings, Action::ExportBoardPdfFile, NOT_BOUND_LABEL),
+ action_label(Action::ExportBoardPdfFile),
+ ),
row(
binding_or_fallback(bindings, Action::OpenCaptureFolder, NOT_BOUND_LABEL),
action_label(Action::OpenCaptureFolder),
diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs
index dcf319c7..40294e96 100644
--- a/src/ui/help_overlay/sections/tests.rs
+++ b/src/ui/help_overlay/sections/tests.rs
@@ -46,6 +46,7 @@ fn canvas_export_rows_remain_visible_when_capture_context_is_disabled() {
Action::ExportCanvasClipboard,
Action::ExportCanvasFile,
Action::ExportCanvasClipboardAndFile,
+ Action::ExportBoardPdfFile,
] {
assert!(
rows.contains(&action_label(action)),