stet_viewer/lib.rs
1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Interactive egui/winit desktop viewer for stet — displays PostScript,
6//! EPS, and PDF pages with zoom, pan, page navigation, and a minimap.
7//!
8//! The viewer consumes
9//! `stet_graphics::display_list::DisplayList` values over a channel, so it
10//! is agnostic about where the display list came from: the
11//! stet PostScript interpreter and
12//! [`stet-pdf-reader`](https://crates.io/crates/stet-pdf-reader) both
13//! produce the same type, and a single viewer window handles PS, EPS,
14//! and PDF input interchangeably. Zoom / pan / page changes re-rasterize
15//! the stored display list via
16//! [`stet-render`](https://crates.io/crates/stet-render) — the source is
17//! never re-interpreted.
18//!
19//! # Architecture
20//!
21//! The viewer always runs on the main thread (egui/winit requirement).
22//! The PS interpreter or PDF reader runs on a background thread and
23//! streams display lists in over [`create_channels`]:
24//!
25//! ```text
26//! background thread main thread
27//! ┌────────────────────┐ DisplayList ┌──────────────────┐
28//! │ stet::Interpreter │ messages │ run_viewer() │
29//! │ stet_pdf_reader │ ───────────────► │ egui event loop │
30//! └────────────────────┘ └──────────────────┘
31//! ```
32//!
33//! # Typical use
34//!
35//! Most users drive the viewer through the
36//! [`stet-cli`](https://crates.io/crates/stet-cli) binary rather than
37//! embedding it directly. See
38//! [`stet-cli`'s `run_viewer_mode`](https://github.com/AndyCappDev/stet/blob/main/crates/stet-cli/src/main.rs)
39//! for a worked example of wiring the PS interpreter thread and the PDF
40//! thread to a single viewer.
41
42mod viewer;
43
44use std::sync::mpsc;
45
46use stet_graphics::display_list::DisplayList;
47
48/// Raw display list tuple sent by Context at each showpage:
49/// `(DisplayList, dpi, page_width, page_height, effective_cmyk_bytes,
50/// cmyk_proofing)`.
51///
52/// The 5th element carries the CMYK ICC profile that was *effectively* used
53/// to build the display list (e.g. a PDF's OutputIntent when
54/// `--use-output-intent` is active). The viewer uses these bytes to build its
55/// render-time ICC cache so runtime overprint math stays consistent with the
56/// baked RGB values in the display list. `None` means "use the CLI-level
57/// default" (typically the system CMYK profile).
58///
59/// The 6th element (`cmyk_proofing`) is `true` when the bake-time ICC cache
60/// had PDF/X proofing enabled — i.e. ICCBased profiles in the display list
61/// were color-managed *through* the OutputIntent before reaching sRGB. The
62/// render-thread cache must run with the same flag so its image conversions
63/// produce the same RGB the bake produced for vector fills. PostScript
64/// pages always pass `false` (no PDF/X concept).
65pub type DisplayListMsg = (
66 DisplayList,
67 f64,
68 u32,
69 u32,
70 Option<std::sync::Arc<Vec<u8>>>,
71 bool,
72);
73
74/// Message from interpreter to viewer via the relay thread.
75pub enum ViewerMsg {
76 /// A page is ready for display.
77 Page(PageReady),
78 /// A new job is starting — clear accumulated pages.
79 NewJob,
80 /// Current job is finished — all pages for this job have been sent.
81 JobDone,
82 /// An encrypted PDF needs a password. The viewer should prompt the
83 /// user and send the response via `ViewerEnd::password_response_sender`.
84 /// `retry` is true when a previous password was rejected.
85 PasswordRequired { filename: String, retry: bool },
86}
87
88/// A page ready for display, carrying its resolution-independent display list.
89pub struct PageReady {
90 pub display_list: DisplayList,
91 pub width: u32,
92 pub height: u32,
93 pub dpi: f64,
94 pub page_num: u32,
95 /// CMYK ICC profile bytes that were used when building this page's display
96 /// list, when different from the CLI-level default. The viewer uses these
97 /// per-page bytes so overprint math at render time matches the baked RGB.
98 pub cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
99 /// Whether the bake-time ICC cache had PDF/X proofing enabled. Render
100 /// threads pass this through to `build_icc_cache_for_list` so image
101 /// conversions chain through the OutputIntent the same way bake-time
102 /// vector fills did. See [`DisplayListMsg`].
103 pub cmyk_proofing: bool,
104}
105
106/// Screen information sent from viewer to interpreter for DPI calculation.
107pub enum ScreenInfo {
108 /// User specified an explicit DPI override via --dpi.
109 DpiOverride(f64),
110 /// Available pixel height for rendering (monitor_h * 0.85, in physical pixels).
111 /// The interpreter calculates DPI from this and the actual page height.
112 AvailableHeight(f64),
113}
114
115/// Interpreter-side channel endpoints.
116pub struct InterpreterEnd {
117 /// Receives raw display list tuples from Context's display_list_sender.
118 pub dl_receiver: mpsc::Receiver<DisplayListMsg>,
119 /// Sends wrapped ViewerMsg to the viewer.
120 pub page_sender: mpsc::Sender<ViewerMsg>,
121 /// Receives screen info from the viewer for DPI calculation.
122 pub screen_info_receiver: mpsc::Receiver<ScreenInfo>,
123}
124
125/// Viewer-side channel endpoints.
126pub struct ViewerEnd {
127 pub page_receiver: mpsc::Receiver<ViewerMsg>,
128 /// Sends screen info to the interpreter.
129 pub screen_info_sender: mpsc::SyncSender<ScreenInfo>,
130 /// Signals the interpreter to advance to the next job.
131 pub advance_sender: mpsc::SyncSender<()>,
132 /// Sends dropped file paths to the interpreter for processing.
133 pub file_drop_sender: mpsc::Sender<String>,
134 /// Shared flag set by the viewer when a new file is dropped while
135 /// another is still being parsed; the interpreter aborts the
136 /// in-flight job and picks up the newly queued path.
137 pub interrupt_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
138 /// Sends the user's response to a `PasswordRequired` prompt.
139 /// `Some(pw)` submits a password; `None` cancels and the interpreter
140 /// gives up on that file.
141 pub password_response_sender: mpsc::Sender<Option<String>>,
142}
143
144/// Create matched channel pairs for interpreter <-> viewer communication.
145///
146/// Returns `(InterpreterEnd, ViewerEnd, dl_sender, advance_receiver,
147/// file_drop_receiver, interrupt_flag, password_response_receiver)`.
148/// - `dl_sender` should be set on `Context.display_list_sender`.
149/// - `advance_receiver` is used by the interpreter to wait between jobs.
150/// - `file_drop_receiver` receives file paths dropped onto the viewer window.
151/// - `interrupt_flag` should be set on `Context.interrupt_flag`; the viewer
152/// sets it when a new file is dropped so the interpreter can abort the
153/// in-flight job. The same `Arc` is also stored in `ViewerEnd` for the
154/// viewer-app side.
155/// - `password_response_receiver` receives `Some(password)` or `None`
156/// from the viewer after a `ViewerMsg::PasswordRequired` prompt.
157pub fn create_channels() -> (
158 InterpreterEnd,
159 ViewerEnd,
160 mpsc::Sender<DisplayListMsg>,
161 mpsc::Receiver<()>,
162 mpsc::Receiver<String>,
163 std::sync::Arc<std::sync::atomic::AtomicBool>,
164 mpsc::Receiver<Option<String>>,
165) {
166 // Display list pipe: unbounded (interpreter never blocks at showpage)
167 let (dl_tx, dl_rx) = mpsc::channel();
168 // Page pipe: unbounded (display lists are lightweight metadata)
169 let (page_tx, page_rx) = mpsc::channel();
170 // Screen info: bounded (single message)
171 let (info_tx, info_rx) = mpsc::sync_channel(1);
172 // Job advance: bounded (interpreter blocks until viewer signals)
173 let (advance_tx, advance_rx) = mpsc::sync_channel(0);
174 // File drop: unbounded (viewer sends dropped file paths to interpreter)
175 let (file_drop_tx, file_drop_rx) = mpsc::channel();
176 // Interrupt flag: viewer sets, interpreter polls
177 let interrupt_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
178 // Password response: viewer → interpreter, unbounded (typically 0-1
179 // messages in flight, but no reason to block the viewer UI on send).
180 let (password_response_tx, password_response_rx) = mpsc::channel();
181
182 (
183 InterpreterEnd {
184 dl_receiver: dl_rx,
185 page_sender: page_tx,
186 screen_info_receiver: info_rx,
187 },
188 ViewerEnd {
189 page_receiver: page_rx,
190 screen_info_sender: info_tx,
191 advance_sender: advance_tx,
192 file_drop_sender: file_drop_tx,
193 interrupt_flag: interrupt_flag.clone(),
194 password_response_sender: password_response_tx,
195 },
196 dl_tx,
197 advance_rx,
198 file_drop_rx,
199 interrupt_flag,
200 password_response_rx,
201 )
202}
203
204/// Default page dimensions in points (US Letter).
205const DEFAULT_PAGE_W: f64 = 612.0;
206const DEFAULT_PAGE_H: f64 = 792.0;
207
208/// Run the viewer window on the current thread (must be main thread).
209///
210/// `dpi_override`: if `Some`, use this DPI instead of auto-calculating from
211/// monitor size. The chosen DPI is sent to the interpreter via the channel.
212///
213/// `page_size`: optional (width, height) in PostScript points for the first
214/// page. Used to compute the initial window aspect ratio so the compositor
215/// (especially Wayland, which ignores client-side repositioning) places the
216/// window correctly from the start.
217///
218/// This function blocks until the viewer window is closed.
219pub fn run_viewer(
220 viewer_end: ViewerEnd,
221 dpi_override: Option<f64>,
222 filename: Option<&str>,
223 page_size: Option<(f64, f64)>,
224 system_cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
225 no_aa: bool,
226) {
227 run_viewer_inner(
228 viewer_end,
229 dpi_override,
230 filename,
231 page_size,
232 system_cmyk_bytes,
233 no_aa,
234 )
235}
236
237/// Inner implementation of `run_viewer`.
238fn run_viewer_inner(
239 viewer_end: ViewerEnd,
240 dpi_override: Option<f64>,
241 filename: Option<&str>,
242 page_size: Option<(f64, f64)>,
243 system_cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
244 no_aa: bool,
245) {
246 let app = viewer::ViewerApp::new(viewer_end, dpi_override, system_cmyk_bytes, no_aa);
247
248 let title = match filename {
249 Some(name) => {
250 let base = std::path::Path::new(name)
251 .file_name()
252 .map(|n| n.to_string_lossy().to_string())
253 .unwrap_or_else(|| name.to_string());
254 format!("stet — {}", base)
255 }
256 None => "stet".to_string(),
257 };
258
259 // Compute initial window size from the first page's dimensions.
260 // This ensures the compositor (especially Wayland) centers the window
261 // at the correct aspect ratio — we cannot reposition after creation.
262 // Estimate status bar at ~32 logical pixels; content area fills 85% of
263 // monitor height minus that overhead.
264 let (page_w, page_h) = page_size.unwrap_or((DEFAULT_PAGE_W, DEFAULT_PAGE_H));
265 let aspect = page_w / page_h;
266 let status_bar_est = 32.0_f32;
267 let est_mon_h = 1440.0_f32;
268 let est_mon_w = 2560.0_f32;
269 let max_content_h = est_mon_h * 0.85 - status_bar_est;
270 let max_content_w = est_mon_w * 0.85;
271 let mut content_h = max_content_h;
272 let mut content_w = content_h * aspect as f32;
273 if content_w > max_content_w {
274 content_w = max_content_w;
275 content_h = content_w / aspect as f32;
276 }
277 let init_w = content_w;
278 let init_h = content_h + status_bar_est;
279
280 let options = eframe::NativeOptions {
281 viewport: egui::ViewportBuilder::default()
282 .with_title(&title)
283 .with_inner_size([init_w, init_h])
284 .with_drag_and_drop(true),
285 centered: true,
286 persist_window: false,
287 ..Default::default()
288 };
289 eframe::run_native("stet", options, Box::new(|_cc| Ok(Box::new(app))))
290 .expect("Failed to start viewer");
291}