Skip to main content

falcon_pdf/
lib.rs

1//! PDF documents for falcon's viewer, through the operating system's own
2//! PDF engine: PDFKit on macOS. The one third-party PDF library, lopdf (MIT),
3//! only reads a file's structure so that layers can be switched (`ocg`).
4//!
5//! [`Document`] opens a file and hands out what a reader needs: page
6//! sizes, pages or parts of them rendered to RGBA pixels at any scale, each page's text,
7//! search hits, the text and rectangles of a drag selection, and the table
8//! of contents.
9//!
10//! A `Document` may be opened and used on any thread, but only on the
11//! thread that opened it (it is not `Send`). The viewer opens it on a
12//! worker thread so a slow or not-yet-downloaded file never blocks the UI.
13//!
14//! All coordinates in this API are **display points**: PDF points (1/72
15//! inch) with the origin at the top-left of the page as shown, after the
16//! page's own rotation. The private `geom` module converts to and from
17//! PDFKit's page space (origin bottom-left, unrotated).
18//!
19//! All Objective-C interop sits in the private `mac` module.
20// Code only macOS calls so far; macOS builds keep the full check.
21#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
22
23mod geom;
24mod tree;
25pub use tree::{LayerNode, OrderItem, layer_tree};
26
27#[cfg(target_os = "macos")]
28mod mac;
29#[cfg(target_os = "macos")]
30mod ocg;
31#[cfg(target_os = "macos")]
32pub use mac::{Document, open_for_print, print};
33
34#[cfg(not(target_os = "macos"))]
35mod other;
36#[cfg(not(target_os = "macos"))]
37pub use other::{Document, open_for_print, print};
38
39/// An RGBA picture, 8 bits per channel, rows top to bottom, opaque.
40#[derive(Clone, Debug, PartialEq)]
41pub struct Bitmap {
42    pub width: usize,
43    pub height: usize,
44    pub rgba: Vec<u8>,
45}
46
47impl Bitmap {
48    /// The pixel at (x, y), as `[r, g, b, a]`.
49    pub fn pixel(&self, x: usize, y: usize) -> [u8; 4] {
50        let i = (y * self.width + x) * 4;
51        [self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
52    }
53}
54
55/// A rectangle in display points (origin top-left of the page as shown).
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub struct Rect {
58    pub x: f64,
59    pub y: f64,
60    pub w: f64,
61    pub h: f64,
62}
63
64/// Where a search matched: one page and one rectangle per line of the match.
65#[derive(Clone, Debug, PartialEq)]
66pub struct Hit {
67    /// 0-based page index.
68    pub page: usize,
69    pub rects: Vec<Rect>,
70}
71
72#[derive(Clone, Copy, Debug, Default, PartialEq)]
73pub struct SearchOptions {
74    /// Off by default, as in most readers: "Bravo" finds "bravo".
75    pub match_case: bool,
76    /// Off by default: "e" finds "é".
77    pub match_diacritics: bool,
78    /// Only matches with no letter or digit right before or after them.
79    pub whole_words: bool,
80}
81
82/// Whether the match `start..start + len` (UTF-16 units, as PDFKit counts)
83/// in `text` stands as whole words: no letter, digit or underscore touches
84/// either end.
85pub fn is_whole_word(text: &[u16], start: usize, len: usize) -> bool {
86    let wordy = |i: usize| text.get(i).and_then(|&u| char::from_u32(u32::from(u))).is_some_and(|c| c.is_alphanumeric() || c == '_');
87    let end = start + len;
88    if len == 0 || end > text.len() {
89        return false;
90    }
91    !(start > 0 && wordy(start - 1) && wordy(start)) && !(end < text.len() && wordy(end) && wordy(end - 1))
92}
93
94/// The text under a drag selection and the rectangles to highlight.
95#[derive(Clone, Debug, Default, PartialEq)]
96pub struct Selection {
97    pub text: String,
98    pub rects: Vec<Rect>,
99}
100
101/// One entry of the table of contents, flattened in reading order.
102#[derive(Clone, Debug, PartialEq)]
103pub struct OutlineEntry {
104    pub title: String,
105    /// 0 for top-level entries.
106    pub depth: usize,
107    /// The page it points to, if it points into this document.
108    pub page: Option<usize>,
109}
110
111/// Where a link in a page goes.
112#[derive(Clone, Debug, PartialEq)]
113pub enum LinkTarget {
114    /// A page of this document (0-based), and the display point on it to
115    /// show, when the link names one.
116    Page { page: usize, point: Option<(f64, f64)> },
117    /// A web address or other URL.
118    Url(String),
119}
120
121/// A link annotation: the area to click, in display points, and where it
122/// goes.
123#[derive(Clone, Debug, PartialEq)]
124pub struct Link {
125    pub rect: Rect,
126    pub target: LinkTarget,
127}
128
129/// A file embedded in the document, as pdf.js's attachments list shows
130/// them: from the catalog's EmbeddedFiles name tree, then the pages'
131/// file attachment annotations.
132#[derive(Clone, Debug, PartialEq)]
133pub struct Attachment {
134    /// The file name it was embedded under.
135    pub name: String,
136    /// The size its parameters state, in bytes, if any.
137    pub size: Option<u64>,
138    /// The description it carries, if any.
139    pub description: Option<String>,
140}
141
142/// An optional content group ("layer"), as pdf.js's layers list shows
143/// them, with whether the document's default configuration shows it. Use
144/// [`Document::set_layers`] to show others.
145#[derive(Clone, Debug, PartialEq)]
146pub struct Layer {
147    pub name: String,
148    pub visible: bool,
149}
150
151/// A document's metadata, for a Document Properties dialog. Dates are
152/// seconds since 1970.
153#[derive(Clone, Debug, Default, PartialEq)]
154pub struct Info {
155    pub title: Option<String>,
156    pub author: Option<String>,
157    pub subject: Option<String>,
158    pub keywords: Option<String>,
159    /// The application that made the original document.
160    pub creator: Option<String>,
161    /// The software that wrote the PDF.
162    pub producer: Option<String>,
163    pub created: Option<f64>,
164    pub modified: Option<f64>,
165    /// The PDF version, as (major, minor).
166    pub version: (i64, i64),
167    pub pages: usize,
168    pub encrypted: bool,
169}
170
171/// The message of the error [`Document::open`] returns for a PDF that needs
172/// a password, and of the one for a password that doesn't open it.
173pub const NEEDS_PASSWORD: &str = "the PDF is password-protected";
174pub const WRONG_PASSWORD: &str = "the password does not open this PDF";
175
176/// Why a document could not be opened or read.
177#[derive(Clone, Debug, PartialEq)]
178pub struct Error(pub String);
179
180impl Error {
181    /// Whether opening failed only for want of the right password.
182    pub fn needs_password(&self) -> bool {
183        self.0 == NEEDS_PASSWORD || self.0 == WRONG_PASSWORD
184    }
185}
186
187impl std::fmt::Display for Error {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.write_str(&self.0)
190    }
191}
192
193impl std::error::Error for Error {}
194
195pub type Result<T> = std::result::Result<T, Error>;
196
197/// The pixel size a page of `size` display points renders to at `scale`
198/// pixels per point, shrunk so neither side exceeds `max_px`; never 0.
199pub fn render_size(size: (f64, f64), scale: f64, max_px: usize) -> (usize, usize) {
200    let (w, h) = (size.0.max(0.0) * scale.max(0.0), size.1.max(0.0) * scale.max(0.0));
201    let longest = w.max(h);
202    let shrink = if longest > max_px as f64 && longest > 0.0 { max_px as f64 / longest } else { 1.0 };
203    (((w * shrink).round() as usize).max(1), ((h * shrink).round() as usize).max(1))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn render_sizes_scale_and_cap() {
212        assert_eq!(render_size((612.0, 792.0), 1.0, 4096), (612, 792));
213        assert_eq!(render_size((612.0, 792.0), 2.0, 4096), (1224, 1584));
214        assert_eq!(render_size((612.0, 792.0), 10.0, 1000), (773, 1000));
215        assert_eq!(render_size((792.0, 612.0), 10.0, 1000), (1000, 773));
216        // Degenerate pages still give a 1×1 picture rather than nothing.
217        assert_eq!(render_size((0.0, 0.0), 2.0, 100), (1, 1));
218        assert_eq!(render_size((10.0, 10.0), -1.0, 100), (1, 1));
219    }
220
221    #[test]
222    fn whole_words() {
223        let t: Vec<u16> = "cat concat cat_ x-cat".encode_utf16().collect();
224        assert!(is_whole_word(&t, 0, 3));
225        assert!(!is_whole_word(&t, 7, 3), "inside concat");
226        assert!(!is_whole_word(&t, 11, 3), "before an underscore");
227        assert!(is_whole_word(&t, 18, 3), "after a hyphen, at the end");
228        assert!(!is_whole_word(&t, 18, 9));
229        assert!(!is_whole_word(&t, 0, 0));
230        // A match that starts or ends with a non-word character stands alone.
231        assert!(is_whole_word(&t, 17, 4));
232    }
233
234    #[test]
235    fn pixels_read_back() {
236        let b = Bitmap { width: 2, height: 1, rgba: vec![1, 2, 3, 4, 5, 6, 7, 8] };
237        assert_eq!(b.pixel(1, 0), [5, 6, 7, 8]);
238    }
239}