Skip to main content

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}