diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ce3b59..1843740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always RUSTBACKTRACE: 1 @@ -29,11 +33,20 @@ jobs: toolchain: stable components: rustfmt, clippy + - name: Cache Rust and native build dependencies + uses: Swatinem/rust-cache@v2 + with: + cache-directories: .tools + - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' + # Files no `mod` declaration reaches are skipped by fmt/clippy/test below. + - name: Check module wiring + run: python scripts/check-module-wiring.py + - name: Install build dependencies run: | pip install cmake ninja diff --git a/README.md b/README.md index 8e1ce5d..53b2452 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Bump the workspace version in `Cargo.toml`, then push a matching tag (for exampl Open PDF or Ctrl+O opens the native picker. Use the scrollbars or wheel to browse, enter a page number to jump, and use toolbar controls to zoom or fit the page. Ctrl +/− changes zoom, Ctrl+0 fits width, PgUp/PgDn changes page, arrows scroll, and Home/End navigates to the beginning/end. Zoom is limited to 25–400%. -Encrypted PDFs show a masked password form with retry and cancel. Ctrl+F or Find opens case-insensitive search; Enter/Next and Previous move between highlighted matches. Pages toggles lazy thumbnails; click one to jump. Use Bookmarks to navigate document outlines. Click an internal link to jump to its destination, or a web link to open the default browser. The view picker offers Continuous, Single page, and Facing pages (1–2, 3–4). Drag across text and page boundaries, then press Ctrl+C or Copy selection. Drag near the viewport edge to scroll. Selection is limited to 16 pages, with page boundaries copied as blank lines; highlighting remains single-page. Copy restrictions are respected. Image-only pages have no selectable text; form detection uses OCR without adding a searchable text layer. Passwords, queries, and copied text are not written to traces. +Encrypted PDFs show a masked password form with retry and cancel. Ctrl+F or Find opens case-insensitive search; Enter/Next and Previous move between highlighted matches. Pages toggles lazy thumbnails; click one to jump. Use Bookmarks to navigate document outlines. Click an internal link to jump to its destination, or a web link to open the default browser. The view picker offers Continuous, Single page, and Facing pages (1–2, 3–4). Drag across text and page boundaries, then press Ctrl+C or Copy selection. Drag near the viewport edge to scroll. Selection is limited to 16 pages, with page boundaries copied as blank lines; highlighting remains single-page. Copy restrictions are respected. Ctrl+P or Print writes a uniquely named copy of the current document next to the original and hands it to the Windows printer. Image-only pages have no selectable text; form detection uses OCR without adding a searchable text layer. Passwords, queries, and copied text are not written to traces. ## Validation @@ -73,7 +73,7 @@ Search is capped at 10,000 matches and 512 UTF-8 bytes per query. Selectable tex ## License -PDFree source: AGPL-3.0-only. MuPDF and mupdf-rs are AGPL licensed; Slint and other dependencies retain their own licenses. See LICENSE and THIRD_PARTY_NOTICES.md. No remote repository is configured by this prototype. +PDFree source: AGPL-3.0-only. MuPDF and mupdf-rs are AGPL licensed; Slint and other dependencies retain their own licenses. See LICENSE and THIRD_PARTY_NOTICES.md. Source is hosted at https://github.com/tonythethompson/PDFree, where CI runs fmt, clippy, and the test suite on every push and pull request. ## Forms and signatures diff --git a/crates/pdf-engine/src/fileops.rs b/crates/pdf-engine/src/fileops.rs deleted file mode 100644 index 467ac63..0000000 --- a/crates/pdf-engine/src/fileops.rs +++ /dev/null @@ -1,604 +0,0 @@ -//! FileOps subsystem: all file transaction handling (Save, SaveAs, Rename, -//! Extract, Split, RenameAndSave, Sign). Extracted from the coordinator -//! monolith. - -use crate::{ - detection, digital_signatures::SourceStamp, editing, files, forms, Event, FileAction, FileCommand, - FileCommandAction, Shared, -}; -use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError}; -use pdfium_render::{ - pdf::{Permission}, - PdfDocument, -}; -use std::{ - collections::{HashMap, HashSet, VecDeque}, - path::PathBuf, - sync::{Arc, atomic::Ordering}, - time::Duration, -}; -use viewer_core::{Annotation, PageSize, PixelTile, RenderRequest, SnapshotHistory, TileKey}; - -/// A completed render result (re-exported type alias from rendering). -pub(crate) type Completed = crate::rendering::Completed; - -/// Emits an event through the bounded channel, respecting shutdown. -fn emit(shared: &Shared, events: &crossbeam_channel::Sender, mut event: Event) -> bool { - loop { - match events.send_timeout(event, Duration::from_millis(crate::constants::CHANNEL_SEND_TIMEOUT_MILLIS)) { - Ok(()) => return true, - Err(SendTimeoutError::Timeout(value)) => { - if shared.stopping.load(Ordering::Acquire) { - return false; - } - event = value; - } - Err(_) => return false, - } - } -} - -/// Mutable state owned by the coordinator that FileOps needs to read and modify. -pub(crate) struct FileOpsState { - pub document: Option, - pub journal: Option, - pub source_path: PathBuf, - pub source_stamp: Option, - pub session_password: Option, - pub can_copy: bool, - pub content: u64, - pub detection_done: Option<(u64, usize, Vec)>, - pub detection_job: Option, - pub deferred_results: VecDeque, - pub notes_cache: HashMap, bool)>, -} - -impl FileOpsState { - pub(crate) fn new(source_path: PathBuf) -> Self { - Self { - document: None, - journal: None, - source_path, - source_stamp: None, - session_password: None, - can_copy: false, - content: 0, - detection_done: None, - detection_job: None, - deferred_results: VecDeque::new(), - notes_cache: HashMap::new(), - } - } -} - -/// The FileOps subsystem handles all file transaction commands. -pub(crate) struct FileOps { - shared: Arc, - events: crossbeam_channel::Sender, - document_id: u64, -} - -impl FileOps { - pub(crate) fn new( - shared: Arc, - events: crossbeam_channel::Sender, - document_id: u64, - ) -> Self { - Self { - shared, - events, - document_id, - } - } - - /// Handle a file command. Returns `(should_continue, document_changed)`. - pub(crate) fn handle( - &mut self, - state: &mut FileOpsState, - results: &Receiver, - inflight_len: usize, - command: FileCommand, - ) -> (bool, bool) { - if let FileCommandAction::Sign { revision, request } = &command.action { - let result = (|| { - if command.document != self.document_id - || *revision != state.journal.as_ref().map_or(0, |h| h.revision()) - || state.journal.as_ref().is_some_and(|h| h.dirty()) - { - return Err("Save and review your edits before signing.".to_string()); - } - let stamp = state - .source_stamp - .as_ref() - .ok_or("The original PDF file is unavailable")?; - crate::digital_signatures::sign( - &state.source_path, - state.session_password.as_deref(), - stamp, - request, - || { - self.shared.current_document(command.document) - && !self.shared.sign_cancel.load(Ordering::Acquire) - }, - ) - })(); - self.shared.file_busy.store(false, Ordering::Release); - match result { - Ok((path, integrity)) => { - let _ = emit( - &self.shared, - &self.events, - Event::Signed { - document: command.document, - operation: command.operation, - path, - integrity, - }, - ); - } - Err(message) => { - let _ = emit( - &self.shared, - &self.events, - Event::FileError { - document: command.document, - operation: command.operation, - message, - }, - ); - } - } - return (true, false); - } - - if let FileCommandAction::Detect { - revision, - generation, - pages, - } = &command.action - { - let result = (|| { - let doc = state.document.as_ref().ok_or("No PDF is open")?; - if command.document != self.document_id - || *revision != state.journal.as_ref().map_or(0, |h| h.revision()) - { - return Err("Document changed before detection started".to_string()); - } - forms::ensure_writable(doc)?; - if !forms::permissions(doc) - .contains(Permission::COPY | Permission::MODIFY) - { - return Err( - "This PDF does not permit field recognition and creation".into(), - ); - } - let count = doc.pages().len(); - let mut unique = HashSet::new(); - if pages.iter().any(|p| *p >= count || !unique.insert(*p)) { - return Err("Invalid detection page range".into()); - } - crate::detection::Detection::new( - command.document, - *revision, - command.operation, - *generation, - pages.clone(), - ) - })(); - match result { - Ok(job) => { - state.detection_job = Some(job); - state.detection_done = None; - } - Err(message) => { - self.shared.file_busy.store(false, Ordering::Release); - let _ = emit( - &self.shared, - &self.events, - Event::FileError { - document: command.document, - operation: command.operation, - message, - }, - ); - } - } - return (true, false); - } - - let direct_save = matches!(&command.action, FileCommandAction::Save); - if let FileCommandAction::Edit { - revision, - generation, - command: edit, - } = &command.action - { - let result = if command.document != self.document_id - || !self.shared.current_document(command.document) - { - Err("The document changed before editing started".into()) - } else if matches!( - edit, - crate::EditCommand::Form(viewer_core::FormEdit::CreateDetected(_)) - ) && self.shared.detect_cancel.load(Ordering::Acquire) - { - Err("Field detection cancelled; no fields were created.".into()) - } else if let Some(doc) = state.document.as_mut() { - editing::perform( - doc, - &mut state.journal, - state.session_password.as_deref(), - *revision, - edit, - || { - self.shared.current_document(command.document) - && (!matches!( - edit, - crate::EditCommand::Form( - viewer_core::FormEdit::CreateDetected(_) - ) - ) || !self.shared.detect_cancel.load(Ordering::Acquire)) - }, - ) - } else { - Err("No PDF is open".into()) - }; - match result { - Ok((doc, focus, revision)) => { - let info = editing::info(&doc).expect("validated edited PDF"); - state.can_copy = info.can_copy; - state.content = state.content.wrapping_add(1); - self.shared.content.store(state.content, Ordering::Release); - self.shared.generation.store(*generation, Ordering::Release); - let h = state.journal.as_ref().unwrap(); - let detection_summary = state.detection_done.take(); - let _ = emit( - &self.shared, - &self.events, - Event::Changed { - document: self.document_id, - operation: command.operation, - revision, - generation: *generation, - info, - dirty: h.dirty(), - can_undo: h.can_undo(), - can_redo: h.can_redo(), - focus_page: focus, - }, - ); - if let Some((operation, count, review_pages)) = detection_summary { - let _ = emit( - &self.shared, - &self.events, - Event::DetectionDone { - document: self.document_id, - operation, - count, - review_pages, - }, - ); - } - } - Err(message) => { - state.detection_done = None; - self.shared.file_busy.store(false, Ordering::Release); - let _ = emit( - &self.shared, - &self.events, - Event::FileError { - document: command.document, - operation: command.operation, - message, - }, - ); - } - } - return (true, true); - } - - let result = if command.document != self.document_id - || !self.shared.current_document(command.document) - { - Err("The document changed before the file operation started".into()) - } else if let Some(doc) = state.document.as_mut() { - let action = match command.action { - FileCommandAction::Save => FileCommandAction::RenameAndSave { - name: state - .source_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .into(), - annotations: vec![], - }, - FileCommandAction::Rename(name) if state.journal.is_some() => { - FileCommandAction::RenameAndSave { - name, - annotations: vec![], - } - } - action => action, - }; - match action { - FileCommandAction::Save => unreachable!(), - FileCommandAction::Edit { .. } - | FileCommandAction::Detect { .. } - | FileCommandAction::Sign { .. } => unreachable!(), - FileCommandAction::Extract { - revision, - target, - pages, - } => { - if revision != state.journal.as_ref().map_or(0, |h| h.revision()) { - Err("Document revision changed".into()) - } else { - editing::extract( - doc, - &state.source_path, - state.session_password.as_deref(), - &target, - &pages, - || self.shared.current_document(command.document), - ) - .map(|p| (p, FileAction::Extract)) - } - } - FileCommandAction::Split { - revision, - directory, - after, - } => { - if revision != state.journal.as_ref().map_or(0, |h| h.revision()) { - Err("Document revision changed".into()) - } else { - editing::split( - doc, - &state.source_path, - state.session_password.as_deref(), - &directory, - after, - || self.shared.current_document(command.document), - ) - .map(|p| (p, FileAction::Split)) - } - } - FileCommandAction::SaveAs { - target, - annotations, - } => { - let switch = state.journal.is_some() || annotations.is_empty(); - let baseline = if switch { - editing::serialize(doc).and_then(|bytes| { - let initial = if state.journal.is_none() { - Some(SnapshotHistory::new(bytes.clone())?) - } else { - None - }; - Ok(( - Some(editing::restore( - &bytes, - state.session_password.as_deref(), - )?), - initial, - )) - }) - } else { - Ok((None, None)) - }; - match baseline { - Err(message) => Err(message), - Ok((baseline, initial)) => { - let saved = files::save( - doc, - &state.source_path, - state.session_password.as_deref(), - &target, - if state.journal.is_some() { &[] } else { &annotations }, - || self.shared.current_document(command.document), - ); - match saved { - Err(message) => Err(message), - Ok(path) => { - if switch { - state.source_path = path.clone(); - state.document = baseline; - if let Some(h) = initial { - state.journal = Some(h); - } - if let Some(h) = state.journal.as_mut() { - h.mark_saved(); - } - } - Ok((path, FileAction::SaveAs)) - } - } - } - } - } - FileCommandAction::RenameAndSave { ref annotations, .. } - if state.journal.is_some() && !annotations.is_empty() => - { - Err( - "Use native editing commands before saving; external drafts cannot bypass undo history" - .into(), - ) - } - FileCommandAction::RenameAndSave { name, annotations } => { - match files::prepare_rename( - doc, - &state.source_path, - state.session_password.as_deref(), - &name, - &annotations, - || self.shared.current_document(command.document), - ) { - Err(e) => Err(e), - Ok(prepared) => { - while state.deferred_results.len() < inflight_len - && !self.shared.stopping.load(Ordering::Acquire) - { - match results.recv_timeout(Duration::from_millis(crate::constants::CHANNEL_RECV_TIMEOUT_MILLIS)) { - Ok(result) => state.deferred_results.push_back(result), - Err(RecvTimeoutError::Timeout) => {} - Err(_) => break, - } - } - if !self.shared.current_document(command.document) { - Err("Document changed before rename completed".into()) - } else { - drop(state.document.take()); - let (baseline, result) = prepared.commit(&state.source_path); - state.document = Some(baseline); - match result { - Ok(path) => { - state.source_path = path.clone(); - if let Some(h) = state.journal.as_mut() { - h.mark_saved(); - } - Ok(( - path, - if direct_save { - FileAction::Save - } else { - FileAction::Rename - }, - )) - } - Err(failure) => { - state.source_path = failure.path; - Err(failure.message) - } - } - } - } - } - } - FileCommandAction::Rename(name) => { - match files::renamed_path(&state.source_path, &name) { - Err(e) => Err(e), - Ok(target) if target == state.source_path => { - Ok((state.source_path.clone(), FileAction::Rename)) - } - Ok(target) => { - while state.deferred_results.len() < inflight_len - && !self.shared.stopping.load(Ordering::Acquire) - { - match results.recv_timeout(Duration::from_millis(crate::constants::CHANNEL_RECV_TIMEOUT_MILLIS)) { - Ok(result) => state.deferred_results.push_back(result), - Err(RecvTimeoutError::Timeout) => {} - Err(_) => break, - } - } - if !self.shared.current_document(command.document) { - Err("Document changed before rename completed".into()) - } else { - state.notes_cache.clear(); - drop(state.document.take()); - let moved = - files::move_no_replace(&state.source_path, &target); - let reopen_path = if moved.is_ok() { - target.clone() - } else { - state.source_path.clone() - }; - let reopened = - files::open(&reopen_path, state.session_password.as_deref()); - match reopened { - Ok(doc) => { - state.document = Some(doc); - match moved { - Ok(()) => { - state.source_path = target.clone(); - Ok((target, FileAction::Rename)) - } - Err(e) => Err(format!( - "Could not rename PDF: {e}. Original remains open." - )), - } - } - Err(e) => { - if moved.is_err() { - Err(format!( - "Rename failed and the original PDF could not be reopened: {e}. File remains at {}. Use Open to recover.", - state.source_path.display() - )) - } else { - match files::move_no_replace( - &target, - &state.source_path, - ) { - Ok(()) => match files::open( - &state.source_path, - state.session_password.as_deref(), - ) { - Ok(doc) => { - state.document = Some(doc); - Err(format!( - "Renamed PDF could not be reopened ({e}); rename was rolled back. Original remains open." - )) - } - Err(rollback_error) => Err(format!( - "Rename was rolled back but reopening failed: {rollback_error}. File remains at {}. Use Open to recover.", - state.source_path.display() - )), - }, - Err(rollback_error) => { - state.source_path = target; - Err(format!( - "Rename succeeded but reopening failed ({e}) and rollback failed ({rollback_error}). File is at {}. Use Open to recover.", - state.source_path.display() - )) - } - } - } - } - } - } - } - } - } - } - } else { - Err("No PDF is open".into()) - }; - self.shared.file_busy.store(false, Ordering::Release); - let event = match result { - Ok((path, kind)) => { - if matches!( - kind, - FileAction::Save | FileAction::SaveAs | FileAction::Rename - ) { - state.source_stamp = - crate::digital_signatures::SourceStamp::read(&state.source_path).ok(); - } - Event::FileDone { - document: command.document, - operation: command.operation, - path, - kind, - } - } - Err(message) => Event::FileError { - document: command.document, - operation: command.operation, - message, - }, - }; - let _ = emit(&self.shared, &self.events, event); - if state.document.is_none() && command.document == self.document_id { - let _ = emit( - &self.shared, - &self.events, - Event::Error { - document: self.document_id, - error: crate::EngineError::InvalidDocument(format!( - "The PDF at {} could not be reopened. Use Open to recover.", - state.source_path.display() - )), - }, - ); - } - (false, false) - } -} diff --git a/crates/pdf-engine/src/rendering.rs b/crates/pdf-engine/src/rendering.rs deleted file mode 100644 index c4ad4d6..0000000 --- a/crates/pdf-engine/src/rendering.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Rendering subsystem: render queue, tile dispatch, and geometry discovery. -//! pdfium-render does not cache display lists; pages are rendered on demand -//! with PdfRenderConfig specifying the tile region and scale. - -use crate::{Event, raster, Shared, View}; -use crossbeam_channel::Sender; -use pdfium_render::prelude::*; -use std::{ - collections::{HashMap, HashSet, VecDeque}, - sync::{Arc, atomic::Ordering}, - time::{Duration, Instant}, -}; -use viewer_core::{PageSize, PixelTile, RenderQueue, RenderRequest, TileKey}; - -/// A render job handed to the rendering subsystem. -pub(crate) struct Job { - pub content: u64, - pub request: RenderRequest, - pub thumbnail: bool, -} - -/// A completed render result. -pub(crate) struct Completed { - pub content: u64, - pub thumbnail: bool, - pub request: RenderRequest, - pub pixels: Option>, - pub elapsed: Duration, -} - -/// Emits an event through the bounded channel, respecting shutdown. -fn emit(shared: &Shared, events: &Sender, mut event: Event) -> bool { - loop { - match events.send_timeout(event, Duration::from_millis(crate::constants::CHANNEL_SEND_TIMEOUT_MILLIS)) { - Ok(()) => return true, - Err(crossbeam_channel::SendTimeoutError::Timeout(value)) => { - if shared.stopping.load(Ordering::Acquire) { - return false; - } - event = value; - } - Err(_) => return false, - } - } -} - -/// The rendering subsystem owns the render queue and tile dispatch. -/// Pages are held by the coordinator and passed in on each tick. -pub(crate) struct Rendering { - shared: Arc, - events: Sender, - document_id: u64, - queue: RenderQueue, - geometry: Vec>, - cursor: usize, - inflight: HashSet<(TileKey, u64, bool, u64)>, - deferred_results: VecDeque, - content: u64, -} - -impl Rendering { - pub(crate) fn new( - shared: Arc, - events: Sender, - document_id: u64, - ) -> Self { - Self { - shared, - events, - document_id, - queue: RenderQueue::new(crate::constants::RENDER_QUEUE_CAPACITY), - geometry: Vec::new(), - cursor: 0, - inflight: HashSet::new(), - deferred_results: VecDeque::new(), - content: 0, - } - } - - /// Reset all rendering state for a newly opened document. - pub(crate) fn reset(&mut self, pages: usize, first_page: PageSize) { - self.geometry = vec![None; pages]; - self.geometry[0] = Some(first_page); - self.cursor = 1; - self.queue.replace(Vec::new()); - self.inflight.clear(); - self.deferred_results.clear(); - } - - /// Reset rendering state after an edit (content changed). - pub(crate) fn reset_after_edit(&mut self, pages: usize, first_page: PageSize, content: u64) { - self.content = content; - self.geometry = vec![None; pages]; - self.geometry[0] = Some(first_page); - self.cursor = 1; - self.queue.replace(vec![]); - } - - /// Process a view request: replace the render queue with the requested tiles. - pub(crate) fn handle_view(&mut self, view: View, content: u64) { - self.queue.replace( - view.requests - .into_iter() - .filter(|r| !self.inflight.contains(&(r.key, r.generation, false, content))) - .collect(), - ); - } - - /// Process completed render results. Returns events to emit. - pub(crate) fn process_results(&mut self) -> Vec { - let mut events = Vec::new(); - - while let Some(result) = self - .deferred_results - .pop_front() - { - self.inflight.remove(&( - result.request.key, - result.request.generation, - result.thumbnail, - result.content, - )); - if !self - .shared - .current_content(result.request.key.document, result.content) - { - continue; - } - - if result.thumbnail { - if self.shared.current_document(result.request.key.document) { - match result.pixels { - Some(Ok(pixels)) => { - events.push(Event::Thumbnail { - key: result.request.key, - pixels: Arc::new(pixels), - elapsed: result.elapsed, - }); - } - Some(Err(error)) => { - events.push(Event::ReaderError { - document: self.document_id, - operation: "thumbnail", - page: Some(result.request.key.page), - message: error.to_string(), - }); - } - None => {} - } - } - continue; - } - - if !self - .shared - .current(result.request.key.document, result.request.generation) - { - continue; - } - match result.pixels { - Some(Ok(pixels)) => { - events.push(Event::Tile { - key: result.request.key, - generation: result.request.generation, - pixels: Arc::new(pixels), - elapsed: result.elapsed, - }); - } - Some(Err(error)) => { - events.push(Event::Error { - document: self.document_id, - error: error, - }); - } - None => {} - } - } - - events - } - - /// Main rendering tick: renders tiles from the queue synchronously. - /// pdfium-render serializes all calls internally, so parallel workers add no benefit. - pub(crate) fn tick( - &mut self, - doc: &PdfDocument, - can_copy: bool, - shared: &Shared, - ) -> Vec { - let mut events = Vec::new(); - - // Dispatch render jobs from the queue. - while self.inflight.is_empty() { - let Some(request) = self.queue.pop() else { - break; - }; - if !shared.current(request.key.document, request.generation) { - continue; - } - let page = request.key.page; - if page >= self.geometry.len() { - continue; - } - - let started = Instant::now(); - let pages = doc.pages(); - let pdf_page = match pages.get(page) { - Some(p) => p, - None => continue, - }; - - // Cache geometry on first render of this page. - let bounds = pdf_page.boundaries().map_err(|e| e.to_string()); - match bounds { - Ok(b) => { - let size = PageSize::new(b.width(), b.height()); - if self.geometry[page] != Some(size) { - self.geometry[page] = Some(size); - events.push(Event::Geometry { - document: self.document_id, - pages: vec![(page, size)], - }); - } - } - Err(error) => { - events.push(Event::Error { - document: self.document_id, - error: crate::EngineError::Render(error), - }); - continue; - } - } - - self.inflight.insert((request.key, request.generation, false, self.content)); - let result = raster::render(pdf_page, request.key); - self.inflight.remove(&(request.key, request.generation, false, self.content)); - - match result { - Ok(pixels) => { - events.push(Event::Tile { - key: request.key, - generation: request.generation, - pixels: Arc::new(pixels), - elapsed: started.elapsed(), - }); - } - Err(error) => { - events.push(Event::Error { - document: self.document_id, - error, - }); - } - } - } - - // Handle thumbnail rendering. - if self.queue.is_empty() && self.inflight.is_empty() { - // Thumbnails are rendered via the coordinator's reader subsystem. - // The rendering tick only handles main tile rendering. - } - - events - } - - /// Advance geometry discovery: one page per call. - pub(crate) fn advance_geometry(&mut self, doc: &PdfDocument) -> Vec { - let mut events = Vec::new(); - - while self.cursor < self.geometry.len() && self.geometry[self.cursor].is_some() { - self.cursor += 1; - } - if self.cursor < self.geometry.len() && self.queue.is_empty() { - let pages = doc.pages(); - if let Some(pdf_page) = pages.get(self.cursor) { - if let Ok(bounds) = pdf_page.boundaries() { - let size = PageSize::new(bounds.width(), bounds.height()); - self.geometry[self.cursor] = Some(size); - events.push(Event::Geometry { - document: self.document_id, - pages: vec![(self.cursor, size)], - }); - } - } - self.cursor += 1; - } - - events - } - - /// Access the render queue for the coordinator to inspect. - pub(crate) fn queue(&self) -> &RenderQueue { - &self.queue - } - - /// Access the inflight set for the coordinator to inspect. - pub(crate) fn inflight(&self) -> &HashSet<(TileKey, u64, bool, u64)> { - &self.inflight - } - - /// Access the geometry vector. - pub(crate) fn geometry(&self) -> &Vec> { - &self.geometry - } - - /// Set the content epoch. - pub(crate) fn set_content(&mut self, content: u64) { - self.content = content; - } - - pub(crate) fn spawn_workers( - &mut self, - _shared: Arc, - _job_rx: crossbeam_channel::Receiver, - _completed: crossbeam_channel::Sender, - ) -> Vec> { - // pdfium-render serializes all calls internally, so no worker threads are needed. - Vec::new() - } - - pub(crate) fn shutdown(&mut self) { - // No threads to join. - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rendering_module_compiles() { - let _ = std::ptr::null::(); - } -} diff --git a/docs/backlog.md b/docs/backlog.md index da01a87..6bd62b6 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -9,3 +9,5 @@ 7. Revisit direct GPU texture upload only after measurements identify upload/composition cost; compare the same viewport with PDFium if MuPDF remains a blocker. 8. Reuse one graft map per imported document to avoid duplicating shared fonts/images under the published insert_pdf implementation; measure import time and memory before relaxing the snapshot budget. + +9. Add automated dependency advisory checking (cargo audit or cargo deny) to CI for the locked dependency graph, including the signing/crypto path; also keep `scripts/check-module-wiring.py` green so no source file drifts outside the module tree. diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 0be4039..383b9b7 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -30,6 +30,7 @@ Statuses describe application behavior, not everything MuPDF could support. | Rotate / move / delete / blank page | Implemented, experimental | Shared undo; must retain one page; ASSEMBLE permission | | Insert / merge | Partial | Up to 16 unpassworded PDFs; supported links/destinations, popup/annotation graphs and AcroForm fields preserved; incompatible forms/actions rejected | | Extract / split | Implemented, experimental | New files only; COPY and ASSEMBLE; bookmarks removed; not secure redaction | +| Print (Ctrl+P, toolbar, context menu) | Implemented, experimental | Windows only: writes a uniquely named sibling copy, then `ShellExecuteW` with the `print` verb; other platforms keep the copy and report printing unsupported | | Existing AcroForm filling | Implemented, experimental | Owned field/widget identities, text/button/choice/list values, linked widgets, permissions, shared undo and verified save | | Manual form authoring | Implemented, experimental | Draw/move/resize/duplicate/delete, simple type conversion, labels and properties; imported complex trees remain fill-only | | Date / Number fields | Implemented | Standard text widgets with PDFree metadata and local validation; other readers see ordinary text fields | diff --git a/scripts/check-module-wiring.py b/scripts/check-module-wiring.py new file mode 100644 index 0000000..e3509f1 --- /dev/null +++ b/scripts/check-module-wiring.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Fail if any Rust source file under a crate's src/ tree is unreachable. + +Rust only compiles files that are part of the module tree, so a file that no +`mod` declaration reaches is silently skipped by `cargo fmt`, `cargo clippy`, +and `cargo test` — it can reference missing crates or APIs and still leave CI +green. This check closes that gap. + +Usage: python scripts/check-module-wiring.py [workspace-root] +Exit codes: 0 = every file is wired in, 1 = orphaned file or missing module. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +PATH_ATTR = re.compile(r'^#\[\s*path\s*=\s*"([^"]+)"\s*\]') +MOD_DECL = re.compile( + r'^(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;' +) + + +def parse_mods(file: Path) -> list[tuple[str, str | None]]: + """Return (module-name, path-override) for file-backed modules in `file`. + + A `#[path = "..."]` attribute applies to the next `mod` declaration; + intervening attributes (`#[cfg(...)]`) keep it, intervening code clears it. + """ + mods: list[tuple[str, str | None]] = [] + pending_path: str | None = None + for line in file.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + match = PATH_ATTR.match(stripped) + if match: + pending_path = match.group(1) + continue + match = MOD_DECL.match(stripped) + if match: + mods.append((match.group(1), pending_path)) + pending_path = None + continue + if not stripped or stripped.startswith(("#", "//", "/*", "*")): + continue + pending_path = None + return mods + + +def module_target(declaring_file: Path, name: str, path_override: str | None, + crate_root: Path) -> Path: + if path_override is not None: + return (declaring_file.parent / path_override).resolve() + if declaring_file.resolve() == crate_root.resolve(): + base = declaring_file.parent + else: + # `mod x;` inside src/foo.rs lives at src/foo/x.rs, not src/x.rs. + base = declaring_file.with_suffix("") + for candidate in (base / f"{name}.rs", base / name / "mod.rs"): + if candidate.exists(): + return candidate + return base / f"{name}.rs" + + +def reachable(crate_root: Path) -> tuple[set[Path], list[str]]: + seen: set[Path] = set() + missing: list[str] = [] + stack = [crate_root] + while stack: + current = stack.pop() + if current in seen: + continue + if not current.exists(): + missing.append(str(current)) + continue + seen.add(current) + for name, path_override in parse_mods(current): + target = module_target(current, name, path_override, crate_root) + stack.append(target) + return seen, missing + + +def main() -> int: + workspace = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() + crates_dir = workspace / "crates" + if not crates_dir.is_dir(): + print(f"error: no crates/ directory under {workspace}", file=sys.stderr) + return 1 + + failures = 0 + for crate_src in sorted(p for p in crates_dir.iterdir() if p.is_dir()): + src = crate_src / "src" + if not src.is_dir(): + continue + roots = [r for r in (src / "lib.rs", src / "main.rs") if r.exists()] + if not roots: + continue + seen: set[Path] = set() + missing: list[str] = [] + for root in roots: + reached, missing_roots = reachable(root) + seen |= reached + missing += missing_roots + all_files = {p.resolve() for p in src.rglob("*.rs")} + orphans = sorted(all_files - seen) + for problem in missing: + print(f"{crate_src.name}: declared module file not found: " + f"{problem}", file=sys.stderr) + failures += 1 + for orphan in orphans: + print(f"{crate_src.name}: orphaned file (never compiled): " + f"{orphan.relative_to(workspace)}", file=sys.stderr) + failures += 1 + if not orphans and not missing: + print(f"{crate_src.name}: {len(seen)} file(s) reachable from " + f"crate root") + + if failures: + print( + "\nThese files are skipped by rustfmt, clippy, and the test " + "suite because no `mod` declaration reaches them. Wire them in " + "or delete them.", + file=sys.stderr, + ) + return 1 + print("all src files are part of their crate's module tree") + return 0 + + +if __name__ == "__main__": + sys.exit(main())