Skip to main content

falcon_pdf/
mac.rs

1//! The PDFKit side: every Objective-C and Core Graphics call in falcon's
2//! PDF support is in this file.
3//!
4//! Most framework methods are `unsafe` in objc2 only because their
5//! bindings cannot prove more than the types say. The invariants the calls
6//! here rely on are the same throughout, so each block names which apply:
7//!
8//! - **owned**: every receiver and argument is a live `Retained`/`CFRetained`
9//!   (or a reference borrowed from one) of the class the binding expects.
10//! - **plain**: arguments are plain values (numbers, points, rects, option
11//!   flags, a page index checked against `pageCount`).
12//! - **one thread**: the `PDFDocument` and everything taken from it are
13//!   only touched from the thread that opened it. `Document` holds
14//!   `Retained` Objective-C objects, so it is neither `Send` nor `Sync`,
15//!   and the compiler enforces this. PDFKit documents and pages may be
16//!   created, searched and drawn off the main thread as long as one thread
17//!   uses them at a time (the crate's tests exercise this).
18//!
19//! Every public method runs inside an autorelease pool, since the viewer
20//! calls them on a worker thread that has none of its own.
21
22use std::path::{Path, PathBuf};
23
24use objc2::AnyThread;
25use objc2::runtime::AnyObject;
26use objc2::rc::{Retained, autoreleasepool};
27use objc2_core_foundation::{CGPoint, CGRect, CGSize};
28use objc2_core_graphics::{CGBitmapContextCreate, CGColorSpace, CGContext, CGImageAlphaInfo, CGImageByteOrderInfo, CGInterpolationQuality, CGPDFDocument};
29use objc2_foundation::{NSArray, NSData, NSDate, NSPoint, NSString, NSStringCompareOptions, NSURL};
30use objc2_pdf_kit::{
31    PDFActionGoTo, PDFActionURL, PDFDestination, PDFDisplayBox, PDFDocument, PDFDocumentAttribute, PDFDocumentAuthorAttribute,
32    PDFDocumentCreationDateAttribute, PDFDocumentCreatorAttribute, PDFDocumentKeywordsAttribute, PDFDocumentModificationDateAttribute,
33    PDFDocumentProducerAttribute, PDFDocumentSubjectAttribute, PDFDocumentTitleAttribute, PDFOutline, PDFPage, PDFSelection,
34};
35
36use crate::geom::{self, PageGeom};
37use crate::ocg::Patcher;
38use crate::{Attachment, Bitmap, Error, Hit, Info, Layer, LayerNode, Link, LinkTarget, NEEDS_PASSWORD, OutlineEntry, Rect, Result, SearchOptions, Selection, WRONG_PASSWORD, render_size};
39
40/// Search stops collecting after this many matches.
41const MAX_HITS: usize = 10_000;
42
43fn err(msg: impl Into<String>) -> Error {
44    Error(msg.into())
45}
46
47/// Runs `f` on a document's catalog dictionary and its Core Graphics
48/// document, if it has them.
49fn catalog_of<T>(doc: &PDFDocument, f: impl FnOnce(catalog::Dict, &CGPDFDocument) -> T) -> Option<T> {
50    // SAFETY: owned; one thread. The CGPDFDocument is retained for the
51    // whole call, so the catalog and every object reached from it live
52    // at least as long.
53    let doc = unsafe { doc.documentRef() }?;
54    let cat = CGPDFDocument::catalog(Some(&doc));
55    (!cat.is_null()).then(|| f(cat, &doc))
56}
57
58/// An open PDF file. Usable only on the thread that opened it.
59pub struct Document {
60    doc: Retained<PDFDocument>,
61    count: usize,
62    path: PathBuf,
63    password: Option<String>,
64    /// The copy drawn while the layers differ from the default configuration.
65    switched: Option<Retained<PDFDocument>>,
66    /// Read on the first switch.
67    patcher: Option<Patcher>,
68}
69
70impl Document {
71    /// Opens a PDF. Fails cleanly for a missing file, a file that is not a
72    /// PDF, and a password-protected PDF (with [`NEEDS_PASSWORD`]). Reads
73    /// the file synchronously, so callers that must not block (the UI) call
74    /// it on a worker thread.
75    pub fn open(path: &Path) -> Result<Document> {
76        Self::open_with_password(path, None)
77    }
78
79    /// Opens a PDF, unlocking it with `password` if it has one. A locked
80    /// PDF fails with [`NEEDS_PASSWORD`] without a password and with
81    /// [`WRONG_PASSWORD`] when the password doesn't unlock it.
82    pub fn open_with_password(path: &Path, password: Option<&str>) -> Result<Document> {
83        if !path.is_file() {
84            return Err(err(format!("no file at {}", path.display())));
85        }
86        let path_text = path.to_str().ok_or_else(|| err("the path is not valid UTF-8"))?;
87        autoreleasepool(|_| {
88            let url = NSURL::fileURLWithPath(&NSString::from_str(path_text));
89            // SAFETY: owned; one thread (the result stays in this `Document`).
90            let doc = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }.ok_or_else(|| err("not a PDF this system can read"))?;
91            // SAFETY: owned; one thread.
92            if unsafe { doc.isLocked() } {
93                let Some(pw) = password else { return Err(err(NEEDS_PASSWORD)) };
94                // SAFETY: owned; one thread.
95                if !unsafe { doc.unlockWithPassword(&NSString::from_str(pw)) } {
96                    return Err(err(WRONG_PASSWORD));
97                }
98            }
99            // SAFETY: owned; one thread.
100            let count = unsafe { doc.pageCount() };
101            Ok(Document { doc, count, path: path.to_path_buf(), password: password.map(str::to_string), switched: None, patcher: None })
102        })
103    }
104
105    pub fn page_count(&self) -> usize {
106        self.count
107    }
108
109    /// The document's title from its metadata, if it has one.
110    pub fn title(&self) -> Option<String> {
111        autoreleasepool(|_| {
112            // SAFETY: owned; one thread; the attribute key is a framework
113            // constant set at load time.
114            let attrs = unsafe { self.doc.documentAttributes() }?;
115            let key = unsafe { PDFDocumentTitleAttribute };
116            let value = attrs.objectForKey(key)?;
117            let s = value.downcast::<NSString>().ok()?.to_string();
118            let s = s.trim().to_string();
119            (!s.is_empty()).then_some(s)
120        })
121    }
122
123    /// The document's metadata (title, author, dates, version and the like).
124    pub fn info(&self) -> Info {
125        autoreleasepool(|_| {
126            // SAFETY: owned; one thread.
127            let attrs = unsafe { self.doc.documentAttributes() };
128            let get = |key: &PDFDocumentAttribute| attrs.as_ref().and_then(|a| a.objectForKey(key));
129            // SAFETY: the attribute keys are framework constants set at load time.
130            let text = |key: &PDFDocumentAttribute| get(key).and_then(|v| text_of(&v));
131            let date = |key: &PDFDocumentAttribute| get(key).and_then(|v| v.downcast::<NSDate>().ok()).map(|d| d.timeIntervalSince1970());
132            unsafe {
133                Info {
134                    title: text(PDFDocumentTitleAttribute),
135                    author: text(PDFDocumentAuthorAttribute),
136                    subject: text(PDFDocumentSubjectAttribute),
137                    keywords: text(PDFDocumentKeywordsAttribute),
138                    creator: text(PDFDocumentCreatorAttribute),
139                    producer: text(PDFDocumentProducerAttribute),
140                    created: date(PDFDocumentCreationDateAttribute),
141                    modified: date(PDFDocumentModificationDateAttribute),
142                    version: (self.doc.majorVersion() as i64, self.doc.minorVersion() as i64),
143                    pages: self.count,
144                    encrypted: self.doc.isEncrypted(),
145                }
146            }
147        })
148    }
149
150    fn page(&self, i: usize) -> Result<Retained<PDFPage>> {
151        if i >= self.count {
152            return Err(err(format!("no page {}", i + 1)));
153        }
154        // SAFETY: owned, plain (index checked above); one thread.
155        unsafe { self.doc.pageAtIndex(i) }.ok_or_else(|| err(format!("page {} can't be read", i + 1)))
156    }
157
158    /// Page `i` of the copy showing the chosen layers, else of the document.
159    fn drawn_page(&self, i: usize) -> Result<Retained<PDFPage>> {
160        let Some(doc) = &self.switched else { return self.page(i) };
161        if i >= self.count {
162            return Err(err(format!("no page {}", i + 1)));
163        }
164        // SAFETY: owned, plain (index checked above; the copy was checked to
165        // have the same pages); one thread.
166        unsafe { doc.pageAtIndex(i) }.ok_or_else(|| err(format!("page {} can't be read", i + 1)))
167    }
168
169    fn geom(page: &PDFPage) -> PageGeom {
170        // SAFETY: owned, plain; one thread.
171        let (b, rot) = unsafe { (page.boundsForBox(PDFDisplayBox::CropBox), page.rotation()) };
172        PageGeom { x0: b.origin.x, y0: b.origin.y, w: b.size.width.abs(), h: b.size.height.abs(), turns: geom::turns(rot as i64) }
173    }
174
175    /// Page `i`'s size in points as shown (after its rotation).
176    pub fn page_size(&self, i: usize) -> Option<(f64, f64)> {
177        autoreleasepool(|_| self.page(i).ok().map(|p| Self::geom(&p).size()))
178    }
179
180    /// Every page's size, in order (one call for the viewer's layout).
181    pub fn page_sizes(&self) -> Vec<(f64, f64)> {
182        (0..self.count).map(|i| self.page_size(i).unwrap_or((612.0, 792.0))).collect()
183    }
184
185    /// Draws page `i` at `scale` pixels per point on white, turned as the
186    /// page asks, shrunk so neither side exceeds `max_px`.
187    pub fn render(&self, i: usize, scale: f64, max_px: usize) -> Result<Bitmap> {
188        let size = self.page_size(i).ok_or_else(|| err(format!("no page {}", i + 1)))?;
189        let (w, h) = render_size(size, scale, max_px.max(1));
190        self.render_part(i, Rect { x: 0.0, y: 0.0, w: size.0, h: size.1 }, w, h)
191    }
192
193    /// Draws the part `part` of page `i` (display points, so the crop box
194    /// and the page's turn are already applied) on white, filling a picture
195    /// of `w` x `h` pixels. Memory follows the picture, not the page, so
196    /// any zoom can be drawn for the part on screen.
197    pub fn render_part(&self, i: usize, part: Rect, w: usize, h: usize) -> Result<Bitmap> {
198        autoreleasepool(|_| {
199            let page = self.drawn_page(i)?;
200            let size = Self::geom(&page).size();
201            let (w, h) = (w.max(1), h.max(1));
202            let mut rgba = vec![0u8; w * h * 4];
203            let space = CGColorSpace::new_device_rgb().ok_or_else(|| err("no RGB colour space"))?;
204            let info = CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0;
205            // SAFETY: `rgba` is `w * h * 4` bytes and outlives the context,
206            // which is dropped before `rgba` is moved; 8 bits per component
207            // with `w * 4` bytes per row matches that buffer exactly.
208            let ctx = unsafe { CGBitmapContextCreate(rgba.as_mut_ptr().cast(), w, h, 8, w * 4, Some(&space), info) }.ok_or_else(|| err("no bitmap context"))?;
209            let c = Some(&*ctx);
210            CGContext::set_rgb_fill_color(c, 1.0, 1.0, 1.0, 1.0);
211            CGContext::fill_rect(c, CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(w as f64, h as f64)));
212            CGContext::set_interpolation_quality(c, CGInterpolationQuality::High);
213            if part.w > 0.0 && part.h > 0.0 {
214                CGContext::scale_ctm(c, w as f64 / part.w, h as f64 / part.h);
215                // The context's origin is at the bottom, display points' at
216                // the top: the part's bottom-left corner goes to the origin.
217                CGContext::translate_ctm(c, -part.x, part.y + part.h - size.1);
218                // drawWithBox:toContext: applies the box's origin and the
219                // page's rotation itself, drawing the page as shown into a
220                // context of its displayed size (checked by the rotated-page
221                // test).
222                // SAFETY: owned; one thread; `ctx` is a live bitmap context.
223                unsafe { page.drawWithBox_toContext(PDFDisplayBox::CropBox, &ctx) };
224            }
225            drop(ctx);
226            // The background is opaque white, so premultiplied is straight.
227            for px in rgba.as_chunks_mut::<4>().0 {
228                px[3] = 255;
229            }
230            Ok(Bitmap { width: w, height: h, rgba })
231        })
232    }
233
234    /// A small picture of page `i` whose longer side is `max_px`.
235    pub fn thumbnail(&self, i: usize, max_px: usize) -> Result<Bitmap> {
236        let size = self.page_size(i).ok_or_else(|| err(format!("no page {}", i + 1)))?;
237        let longest = size.0.max(size.1).max(1.0);
238        self.render(i, max_px as f64 / longest, max_px)
239    }
240
241    /// The text of page `i` (empty for a page without a text layer).
242    pub fn text(&self, i: usize) -> String {
243        autoreleasepool(|_| {
244            let Ok(page) = self.page(i) else { return String::new() };
245            // SAFETY: owned; one thread.
246            unsafe { page.string() }.map(|s| s.to_string()).unwrap_or_default()
247        })
248    }
249
250    /// Every match of `text` in the document, in reading order, with one
251    /// rectangle per line it spans. An empty query finds nothing.
252    pub fn search(&self, text: &str, options: SearchOptions) -> Vec<Hit> {
253        if text.is_empty() {
254            return Vec::new();
255        }
256        autoreleasepool(|_| {
257            let mut flags = NSStringCompareOptions::empty();
258            if !options.match_diacritics {
259                flags |= NSStringCompareOptions::DiacriticInsensitiveSearch;
260            }
261            if !options.match_case {
262                flags |= NSStringCompareOptions::CaseInsensitiveSearch;
263            }
264            // Each page's text as PDFKit counts it, for the whole-word test.
265            let mut texts: std::collections::HashMap<usize, Vec<u16>> = std::collections::HashMap::new();
266            // SAFETY: owned, plain; one thread.
267            let found = unsafe { self.doc.findString_withOptions(&NSString::from_str(text), flags) };
268            let mut hits = Vec::new();
269            for sel in found.iter().take(MAX_HITS) {
270                // SAFETY: owned; one thread.
271                let pages = unsafe { sel.pages() };
272                for page in pages.iter() {
273                    // SAFETY: owned; one thread; the page belongs to this document.
274                    let index = unsafe { self.doc.indexForPage(&page) };
275                    if index >= self.count {
276                        continue;
277                    }
278                    if options.whole_words {
279                        // SAFETY: owned, plain; one thread; the page is one of the selection's.
280                        let range = unsafe { sel.rangeAtIndex_onPage(0, &page) };
281                        let text = texts.entry(index).or_insert_with(|| unsafe { page.string() }.map(|s| s.to_string().encode_utf16().collect()).unwrap_or_default());
282                        if !crate::is_whole_word(text, range.location, range.length) {
283                            continue;
284                        }
285                    }
286                    hits.push(Hit { page: index, rects: line_rects(&sel, &page) });
287                }
288            }
289            hits
290        })
291    }
292
293    /// The text between two display points on page `page` (as a drag from
294    /// `from` to `to` selects it) and the rectangles to highlight.
295    pub fn selection(&self, page: usize, from: (f64, f64), to: (f64, f64)) -> Selection {
296        autoreleasepool(|_| {
297            let Ok(p) = self.page(page) else { return Selection::default() };
298            let g = Self::geom(&p);
299            let (a, b) = (g.to_page(from.0, from.1), g.to_page(to.0, to.1));
300            // SAFETY: owned, plain; one thread.
301            let Some(sel) = (unsafe { p.selectionFromPoint_toPoint(NSPoint::new(a.0, a.1), NSPoint::new(b.0, b.1)) }) else {
302                return Selection::default();
303            };
304            // SAFETY: owned; one thread.
305            let text = unsafe { sel.string() }.map(|s| s.to_string()).unwrap_or_default();
306            let rects = if text.is_empty() { Vec::new() } else { line_rects(&sel, &p) };
307            Selection { text, rects }
308        })
309    }
310
311    /// The table of contents, flattened depth-first; empty if there is none.
312    pub fn outline(&self) -> Vec<OutlineEntry> {
313        autoreleasepool(|_| {
314            let mut out = Vec::new();
315            // SAFETY: owned; one thread.
316            if let Some(root) = unsafe { self.doc.outlineRoot() } {
317                self.walk(&root, 0, &mut out);
318            }
319            out
320        })
321    }
322
323    /// The files embedded in the document: the catalog's EmbeddedFiles
324    /// name tree, then each page's file attachment annotations. PDFKit has
325    /// no API for them, so this reads the document's objects through Core
326    /// Graphics (`documentRef`).
327    pub fn attachments(&self) -> Vec<Attachment> {
328        autoreleasepool(|_| self.with_catalog(|cat, doc| catalog::embedded(cat, doc).into_iter().map(|(a, _)| a).collect()).unwrap_or_default())
329    }
330
331    /// The bytes of attachment `i` of [`Document::attachments`].
332    pub fn attachment_data(&self, i: usize) -> Result<Vec<u8>> {
333        autoreleasepool(|_| {
334            self.with_catalog(|cat, doc| catalog::embedded(cat, doc).into_iter().nth(i).and_then(|(_, stream)| catalog::stream_bytes(stream)))
335                .flatten()
336                .ok_or_else(|| err("the attachment can't be read"))
337        })
338    }
339
340    /// The optional content groups ("layers") and whether the default
341    /// configuration shows each.
342    pub fn layers(&self) -> Vec<Layer> {
343        autoreleasepool(|_| self.with_catalog(|cat, _| catalog::layers(cat)).unwrap_or_default())
344    }
345
346    /// The layers list as a tree, from the default configuration's
347    /// `/Order` (see [`crate::layer_tree`]); `None` when it has none, and
348    /// the list is then flat. Indices are those of [`Document::layers`].
349    pub fn layer_tree(&self) -> Option<Vec<LayerNode>> {
350        autoreleasepool(|_| self.with_catalog(|cat, _| catalog::layer_tree(cat)).flatten())
351    }
352
353    /// Chooses which layers [`Document::render`] and [`Document::thumbnail`]
354    /// show, one flag per entry of [`Document::layers`]. The default choice
355    /// draws the document itself; any other draws an in-memory copy with
356    /// that configuration (see the `ocg` module). Text, search and links
357    /// stay the document's. On failure what's drawn doesn't change.
358    pub fn set_layers(&mut self, visible: &[bool]) -> Result<()> {
359        let layers = self.layers();
360        if visible.len() != layers.len() {
361            return Err(err("the list of layers doesn't match the document"));
362        }
363        if layers.iter().map(|l| l.visible).eq(visible.iter().copied()) {
364            self.switched = None;
365            return Ok(());
366        }
367        let mut bytes = std::fs::read(&self.path).map_err(|e| err(e.to_string()))?;
368        if self.patcher.is_none() {
369            let patcher = Patcher::new(&bytes, self.password.as_deref())?;
370            if patcher.groups() != layers.len() {
371                return Err(err("the PDF's layers can't be switched"));
372            }
373            self.patcher = Some(patcher);
374        }
375        let tail = self.patcher.as_ref().map(|p| p.update(visible, bytes.len())).unwrap_or_default();
376        bytes.extend_from_slice(&tail);
377        autoreleasepool(|_| {
378            let data = NSData::with_bytes(&bytes);
379            // SAFETY: owned; one thread (the copy stays in this `Document`).
380            let copy = unsafe { PDFDocument::initWithData(PDFDocument::alloc(), &data) }.ok_or_else(|| err("the PDF's layers can't be switched"))?;
381            // SAFETY: owned; one thread.
382            if unsafe { copy.isLocked() } && !self.password.as_deref().is_some_and(|pw| unsafe { copy.unlockWithPassword(&NSString::from_str(pw)) }) {
383                return Err(err("the PDF's layers can't be switched"));
384            }
385            // SAFETY: owned; one thread.
386            let same_pages = unsafe { copy.pageCount() } == self.count;
387            // The engine must read back the configuration just written.
388            let took = catalog_of(&copy, |cat, _| catalog::layers(cat)).is_some_and(|l| l.iter().map(|l| l.visible).eq(visible.iter().copied()));
389            if !same_pages || !took {
390                return Err(err("the PDF's layers can't be switched"));
391            }
392            self.switched = Some(copy);
393            Ok(())
394        })
395    }
396
397    /// Runs `f` on the document's catalog dictionary and its Core Graphics
398    /// document, if it has them.
399    fn with_catalog<T>(&self, f: impl FnOnce(catalog::Dict, &CGPDFDocument) -> T) -> Option<T> {
400        catalog_of(&self.doc, f)
401    }
402
403    /// Page `i`'s link annotations: their areas and where they go (a page
404    /// of this document or a URL). Links to other files are left out.
405    pub fn links(&self, i: usize) -> Vec<Link> {
406        autoreleasepool(|_| {
407            let Ok(page) = self.page(i) else { return Vec::new() };
408            let g = Self::geom(&page);
409            let mut out = Vec::new();
410            // SAFETY: owned; one thread.
411            let annotations = unsafe { page.annotations() };
412            for a in annotations.iter() {
413                // SAFETY: owned; one thread.
414                let (kind, b) = unsafe { (a.r#type(), a.bounds()) };
415                if kind.is_none_or(|k| k.to_string() != "Link") || b.size.width <= 0.0 || b.size.height <= 0.0 {
416                    continue;
417                }
418                // The annotation's own URL or destination, else its action's.
419                // SAFETY: owned; one thread.
420                let (url, dest, action) = unsafe { (a.URL(), a.destination(), a.action()) };
421                let url = url.or_else(|| action.clone().and_then(|act| act.downcast::<PDFActionURL>().ok()).and_then(|u| unsafe { u.URL() }));
422                // SAFETY: owned; one thread.
423                let dest = dest.or_else(|| action.and_then(|act| act.downcast::<PDFActionGoTo>().ok()).map(|go| unsafe { go.destination() }));
424                let target = if let Some(text) = url.and_then(|u| u.absoluteString()).map(|s| s.to_string()) {
425                    Some(LinkTarget::Url(text))
426                } else {
427                    dest.and_then(|d| self.destination(&d))
428                };
429                if let Some(target) = target {
430                    out.push(Link { rect: g.rect_to_display(b.origin.x, b.origin.y, b.size.width, b.size.height), target });
431                }
432            }
433            out
434        })
435    }
436
437    /// A destination in this document as a page and display point.
438    fn destination(&self, d: &PDFDestination) -> Option<LinkTarget> {
439        // SAFETY: owned; one thread; the page belongs to this document.
440        let (page, point) = unsafe { (d.page()?, d.point()) };
441        let index = unsafe { self.doc.indexForPage(&page) };
442        if index >= self.count {
443            return None;
444        }
445        // PDFKit marks an unspecified coordinate with a huge value.
446        let known = |v: f64| v.is_finite() && v.abs() < 1e7;
447        let point = (known(point.x) && known(point.y)).then(|| Self::geom(&page).to_display(point.x, point.y));
448        Some(LinkTarget::Page { page: index, point })
449    }
450
451    fn walk(&self, node: &PDFOutline, depth: usize, out: &mut Vec<OutlineEntry>) {
452        // Deep or huge outlines are cut off rather than walked forever.
453        if depth > 16 || out.len() > 5000 {
454            return;
455        }
456        // SAFETY: owned, plain (child index below numberOfChildren); one thread.
457        unsafe {
458            for n in 0..node.numberOfChildren() {
459                let Some(child) = node.childAtIndex(n) else { continue };
460                let title = child.label().map(|s| s.to_string()).unwrap_or_default();
461                let page = child.destination().and_then(|d| d.page()).map(|p| self.doc.indexForPage(&p)).filter(|&i| i < self.count);
462                out.push(OutlineEntry { title, depth, page });
463                self.walk(&child, depth + 1, out);
464            }
465        }
466    }
467}
468
469/// Opens the PDF at `path` as [`print`] prints it: showing `layers` (one
470/// flag per entry of [`Document::layers`]) when given. The file on disk is
471/// not changed.
472pub fn open_for_print(path: &Path, password: Option<&str>, layers: Option<&[bool]>) -> Result<Document> {
473    let mut doc = Document::open_with_password(path, password)?;
474    if let Some(visible) = layers {
475        doc.set_layers(visible)?;
476    }
477    Ok(doc)
478}
479
480/// Prints the PDF at `path` (unlocked with `password` if it has one)
481/// through PDFKit's print operation: the system print panel, then the job
482/// named `title`. `layers` is the reader's choice of layers, if it differs
483/// from the document's default; the job prints what that shows. Must run
484/// on the main thread, which it blocks while the panel is up, and opens the
485/// file again there, since a [`Document`] stays on the thread that opened
486/// it. Returns whether it printed (false if cancelled). Fails off the main
487/// thread, for an unreadable file, when the layers can't be switched, or
488/// when the PDF forbids printing.
489pub fn print(path: &Path, password: Option<&str>, title: &str, layers: Option<&[bool]>) -> Result<bool> {
490    let mtm = objc2::MainThreadMarker::new().ok_or_else(|| err("printing needs the main thread"))?;
491    let doc = open_for_print(path, password, layers)?;
492    autoreleasepool(|_| {
493        // SAFETY: owned; this (main) thread opened the document just now.
494        if !unsafe { doc.doc.allowsPrinting() } {
495            return Err(err("this PDF does not allow printing"));
496        }
497        let info = objc2_app_kit::NSPrintInfo::sharedPrintInfo();
498        // SAFETY: owned, plain; main thread (`mtm`).
499        let op = unsafe { doc.switched.as_ref().unwrap_or(&doc.doc).printOperationForPrintInfo_scalingMode_autoRotate(Some(&info), objc2_pdf_kit::PDFPrintScalingMode::PageScaleDownToFit, true, mtm) }
500            .ok_or_else(|| err("PDFKit made no print job"))?;
501        op.setJobTitle(Some(&NSString::from_str(title)));
502        op.setShowsPrintPanel(true);
503        op.setShowsProgressPanel(true);
504        Ok(op.runOperation())
505    })
506}
507
508/// A metadata value as text: a string, or a list of strings (keywords)
509/// joined by commas; blank values count as none.
510fn text_of(v: &AnyObject) -> Option<String> {
511    let s = if let Some(s) = v.downcast_ref::<NSString>() {
512        s.to_string()
513    } else {
514        let list = v.downcast_ref::<NSArray>()?;
515        list.iter().filter_map(|x| x.downcast_ref::<NSString>().map(|s| s.to_string())).collect::<Vec<_>>().join(", ")
516    };
517    let s = s.trim().to_string();
518    (!s.is_empty()).then_some(s)
519}
520
521/// One display rectangle per line of `sel` on `page`.
522fn line_rects(sel: &PDFSelection, page: &PDFPage) -> Vec<Rect> {
523    let g = Document::geom(page);
524    // SAFETY: owned; one thread (called from `Document` methods).
525    let lines = unsafe { sel.selectionsByLine() };
526    let mut rects = Vec::new();
527    for line in lines.iter() {
528        // SAFETY: owned; one thread.
529        let b = unsafe { line.boundsForPage(page) };
530        if b.size.width > 0.0 && b.size.height > 0.0 {
531            rects.push(g.rect_to_display(b.origin.x, b.origin.y, b.size.width, b.size.height));
532        }
533    }
534    rects
535}
536
537#[cfg(test)]
538mod tests {
539    use objc2_foundation::NSDictionary;
540    use objc2_pdf_kit::{PDFDocumentOwnerPasswordOption, PDFDocumentUserPasswordOption};
541
542    use super::*;
543
544    /// A one-page PDF with a title, written by hand.
545    fn plain_pdf(path: &Path) {
546        let objs = [
547            "<< /Type /Catalog /Pages 2 0 R >>",
548            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
549            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>",
550            "<< /Title (Locked Book) /Author (Falcon) /Keywords (one two) >>",
551        ];
552        let mut out = b"%PDF-1.4\n".to_vec();
553        let mut offsets = Vec::new();
554        for (k, body) in objs.iter().enumerate() {
555            offsets.push(out.len());
556            out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", k + 1).as_bytes());
557        }
558        let xref = out.len();
559        out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
560        for o in offsets {
561            out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
562        }
563        out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
564        std::fs::write(path, out).unwrap();
565    }
566
567    #[test]
568    fn passwords_unlock_and_metadata_reads() {
569        let dir = tempfile::tempdir().unwrap();
570        let (plain, locked) = (dir.path().join("plain.pdf"), dir.path().join("locked.pdf"));
571        plain_pdf(&plain);
572        let info = Document::open(&plain).unwrap().info();
573        assert_eq!(info.title.as_deref(), Some("Locked Book"));
574        assert_eq!(info.author.as_deref(), Some("Falcon"));
575        assert_eq!((info.pages, info.encrypted), (1, false));
576        assert!(info.version.0 >= 1);
577        // PDFKit writes an encrypted copy with a user password.
578        autoreleasepool(|_| {
579            let url = NSURL::fileURLWithPath(&NSString::from_str(plain.to_str().unwrap()));
580            // SAFETY: owned, plain; this test's thread only; the option keys
581            // are framework constants.
582            unsafe {
583                let doc = PDFDocument::initWithURL(PDFDocument::alloc(), &url).unwrap();
584                let (user, owner) = (NSString::from_str("secret"), NSString::from_str("owner"));
585                let keys = [PDFDocumentUserPasswordOption, PDFDocumentOwnerPasswordOption];
586                let options = NSDictionary::from_slices(&keys, &[user.as_ref(), owner.as_ref()]);
587                let out = NSURL::fileURLWithPath(&NSString::from_str(locked.to_str().unwrap()));
588                assert!(doc.writeToURL_withOptions(&out, Some(&options)));
589            }
590        });
591        let e = Document::open(&locked).err().unwrap();
592        assert_eq!(e.0, NEEDS_PASSWORD);
593        assert!(e.needs_password());
594        let e = Document::open_with_password(&locked, Some("nope")).err().unwrap();
595        assert_eq!(e.0, WRONG_PASSWORD);
596        assert!(e.needs_password());
597        let doc = Document::open_with_password(&locked, Some("secret")).unwrap();
598        assert_eq!(doc.page_count(), 1);
599        assert!(doc.info().encrypted);
600        assert!(!Error("no file".into()).needs_password());
601    }
602
603    #[test]
604    fn printing_needs_the_main_thread() {
605        // The test harness runs tests on other threads.
606        let dir = tempfile::tempdir().unwrap();
607        let path = dir.path().join("plain.pdf");
608        plain_pdf(&path);
609        let e = print(&path, None, "x", None).err().unwrap();
610        assert!(e.0.contains("main thread"), "{e}");
611    }
612}
613
614/// Reading the document's own objects through Core Graphics' CGPDF
615/// functions: the embedded files and the optional content groups, which
616/// PDFKit doesn't expose.
617///
618/// Every pointer here is borrowed from a `CGPDFDocument` the caller keeps
619/// retained for the whole walk (`Document::with_catalog`), and is checked
620/// for null before use. Walks are capped in depth and count, so a
621/// malformed or cyclic tree ends.
622mod catalog {
623    use std::ffi::CStr;
624    use std::ptr::{self, NonNull};
625
626    use objc2_core_graphics::{
627        CGPDFArray, CGPDFArrayRef, CGPDFDataFormat, CGPDFDictionary, CGPDFDictionaryRef, CGPDFDocument, CGPDFInteger, CGPDFPage, CGPDFStream,
628        CGPDFStreamRef, CGPDFString, CGPDFStringRef,
629    };
630
631    use crate::{Attachment, Layer, LayerNode, OrderItem, layer_tree as build_tree};
632
633    pub type Dict = CGPDFDictionaryRef;
634
635    const MAX_DEPTH: usize = 32;
636    const MAX_ITEMS: usize = 10_000;
637
638    fn key(k: &CStr) -> NonNull<std::ffi::c_char> {
639        NonNull::new(k.as_ptr().cast_mut()).expect("a C string literal is never null")
640    }
641
642    fn dict(d: Dict, k: &CStr) -> Option<Dict> {
643        let mut out: Dict = ptr::null_mut();
644        // SAFETY: `d` is a live dictionary (see the module notes); `out` is
645        // a local the call writes.
646        (unsafe { CGPDFDictionary::dictionary(d, key(k), &mut out) } && !out.is_null()).then_some(out)
647    }
648
649    fn array(d: Dict, k: &CStr) -> Option<CGPDFArrayRef> {
650        let mut out: CGPDFArrayRef = ptr::null_mut();
651        // SAFETY: as `dict`.
652        (unsafe { CGPDFDictionary::array(d, key(k), &mut out) } && !out.is_null()).then_some(out)
653    }
654
655    fn stream(d: Dict, k: &CStr) -> Option<CGPDFStreamRef> {
656        let mut out: CGPDFStreamRef = ptr::null_mut();
657        // SAFETY: as `dict`.
658        (unsafe { CGPDFDictionary::stream(d, key(k), &mut out) } && !out.is_null()).then_some(out)
659    }
660
661    fn integer(d: Dict, k: &CStr) -> Option<i64> {
662        let mut out: CGPDFInteger = 0;
663        // SAFETY: as `dict`.
664        unsafe { CGPDFDictionary::integer(d, key(k), &mut out) }.then_some(out as i64)
665    }
666
667    fn name(d: Dict, k: &CStr) -> Option<String> {
668        let mut out: *const std::ffi::c_char = ptr::null();
669        // SAFETY: as `dict`; a name is a NUL-terminated string owned by the
670        // document.
671        (unsafe { CGPDFDictionary::name(d, key(k), &mut out) } && !out.is_null()).then(|| unsafe { CStr::from_ptr(out) }.to_string_lossy().into_owned())
672    }
673
674    fn text(s: CGPDFStringRef) -> Option<String> {
675        // SAFETY: `s` is a live string object of the document, or null.
676        (!s.is_null()).then(|| unsafe { CGPDFString::text_string(s) }).flatten().map(|cf| cf.to_string())
677    }
678
679    fn string(d: Dict, k: &CStr) -> Option<String> {
680        let mut out: CGPDFStringRef = ptr::null_mut();
681        // SAFETY: as `dict`.
682        if unsafe { CGPDFDictionary::string(d, key(k), &mut out) } { text(out) } else { None }
683    }
684
685    fn len(a: CGPDFArrayRef) -> usize {
686        // SAFETY: `a` is a live array of the document.
687        unsafe { CGPDFArray::count(a) }.min(MAX_ITEMS)
688    }
689
690    fn array_dict(a: CGPDFArrayRef, i: usize) -> Option<Dict> {
691        let mut out: Dict = ptr::null_mut();
692        // SAFETY: `a` is live and `i` is below its count.
693        (unsafe { CGPDFArray::dictionary(a, i, &mut out) } && !out.is_null()).then_some(out)
694    }
695
696    fn array_string(a: CGPDFArrayRef, i: usize) -> Option<String> {
697        let mut out: CGPDFStringRef = ptr::null_mut();
698        // SAFETY: as `array_dict`.
699        if unsafe { CGPDFArray::string(a, i, &mut out) } { text(out) } else { None }
700    }
701
702    /// A file specification's attachment: its name, size and description,
703    /// and the stream holding its bytes.
704    fn file_spec(spec: Dict, fallback: Option<String>) -> Option<(Attachment, CGPDFStreamRef)> {
705        let ef = dict(spec, c"EF")?;
706        let data = stream(ef, c"UF").or_else(|| stream(ef, c"F"))?;
707        let name = string(spec, c"UF").or_else(|| string(spec, c"F")).or(fallback).unwrap_or_else(|| "attachment".into());
708        // SAFETY: `data` is a live stream of the document.
709        let params = dict(unsafe { CGPDFStream::dictionary(data) }, c"Params");
710        let size = params.and_then(|p| integer(p, c"Size")).and_then(|n| u64::try_from(n).ok());
711        Some((Attachment { name, size, description: string(spec, c"Desc") }, data))
712    }
713
714    /// One node of the EmbeddedFiles name tree and its kids.
715    fn name_tree(node: Dict, depth: usize, out: &mut Vec<(Attachment, CGPDFStreamRef)>) {
716        if depth > MAX_DEPTH || out.len() >= MAX_ITEMS {
717            return;
718        }
719        if let Some(names) = array(node, c"Names") {
720            for i in (0..len(names)).step_by(2) {
721                if let Some(spec) = array_dict(names, i + 1)
722                    && let Some(a) = file_spec(spec, array_string(names, i))
723                {
724                    out.push(a);
725                }
726            }
727        }
728        if let Some(kids) = array(node, c"Kids") {
729            for i in 0..len(kids) {
730                if let Some(kid) = array_dict(kids, i) {
731                    name_tree(kid, depth + 1, out);
732                }
733            }
734        }
735    }
736
737    /// Every embedded file, in pdf.js's order: the name tree, then the
738    /// pages' file attachment annotations.
739    pub fn embedded(cat: Dict, doc: &CGPDFDocument) -> Vec<(Attachment, CGPDFStreamRef)> {
740        let mut out = Vec::new();
741        if let Some(tree) = dict(cat, c"Names").and_then(|n| dict(n, c"EmbeddedFiles")) {
742            name_tree(tree, 0, &mut out);
743        }
744        for n in 1..=CGPDFDocument::number_of_pages(Some(doc)) {
745            let Some(page) = CGPDFDocument::page(Some(doc), n) else { continue };
746            let pd = CGPDFPage::dictionary(Some(&page));
747            if pd.is_null() {
748                continue;
749            }
750            let Some(annots) = array(pd, c"Annots") else { continue };
751            for i in 0..len(annots) {
752                if out.len() >= MAX_ITEMS {
753                    return out;
754                }
755                if let Some(a) = array_dict(annots, i)
756                    && name(a, c"Subtype").as_deref() == Some("FileAttachment")
757                    && let Some(spec) = dict(a, c"FS")
758                    && let Some(f) = file_spec(spec, string(a, c"Contents"))
759                {
760                    out.push(f);
761                }
762            }
763        }
764        out
765    }
766
767    /// The bytes of an embedded file's stream, decoded.
768    pub fn stream_bytes(s: CGPDFStreamRef) -> Option<Vec<u8>> {
769        let mut format = CGPDFDataFormat::Raw;
770        // SAFETY: `s` is a live stream of the document; `format` is a local.
771        unsafe { CGPDFStream::data(s, &mut format) }.map(|d| d.to_vec())
772    }
773
774    fn array_array(a: CGPDFArrayRef, i: usize) -> Option<CGPDFArrayRef> {
775        let mut out: CGPDFArrayRef = ptr::null_mut();
776        // SAFETY: as `array_dict`.
777        (unsafe { CGPDFArray::array(a, i, &mut out) } && !out.is_null()).then_some(out)
778    }
779
780    /// `/Order` as items. The walk is capped in depth and in count, since
781    /// an array may reach itself through an indirect object.
782    fn order_items(a: CGPDFArrayRef, groups: &[Dict], depth: usize, budget: &mut usize) -> Vec<OrderItem> {
783        let mut out = Vec::new();
784        if depth > MAX_DEPTH {
785            return out;
786        }
787        for i in 0..len(a) {
788            if *budget == 0 {
789                break;
790            }
791            *budget -= 1;
792            out.push(if let Some(g) = array_dict(a, i) {
793                groups.iter().position(|&x| x == g).map_or(OrderItem::Other, OrderItem::Group)
794            } else if let Some(inner) = array_array(a, i) {
795                OrderItem::Array(order_items(inner, groups, depth + 1, budget))
796            } else if let Some(t) = array_string(a, i) {
797                OrderItem::Text(t)
798            } else {
799                OrderItem::Other
800            });
801        }
802        out
803    }
804
805    /// The layers tree from the default configuration's `/Order`, if it
806    /// has one. Labels are read as text strings, so PDFDocEncoding,
807    /// UTF-16 and an encrypted document's strings come out as they do for
808    /// the groups' names.
809    pub fn layer_tree(cat: Dict) -> Option<Vec<LayerNode>> {
810        let props = dict(cat, c"OCProperties")?;
811        let groups_array = array(props, c"OCGs")?;
812        let groups: Vec<Dict> = (0..len(groups_array)).filter_map(|i| array_dict(groups_array, i)).collect();
813        let order = array(dict(props, c"D")?, c"Order")?;
814        let mut budget = MAX_ITEMS;
815        Some(build_tree(&order_items(order, &groups, 0, &mut budget), groups.len()))
816    }
817
818    /// The optional content groups, in the document's order, each visible
819    /// unless the default configuration turns it off (its base state, then
820    /// its ON and OFF lists). Groups are matched by object identity: Core
821    /// Graphics hands out one dictionary per indirect object.
822    pub fn layers(cat: Dict) -> Vec<Layer> {
823        let Some(props) = dict(cat, c"OCProperties") else { return Vec::new() };
824        let Some(groups) = array(props, c"OCGs") else { return Vec::new() };
825        let config = dict(props, c"D");
826        let base_on = config.and_then(|d| name(d, c"BaseState")).is_none_or(|s| s != "OFF");
827        let listed = |k: &CStr| -> Vec<Dict> { config.and_then(|d| array(d, k)).map(|a| (0..len(a)).filter_map(|i| array_dict(a, i)).collect()).unwrap_or_default() };
828        let (on, off) = (listed(c"ON"), listed(c"OFF"));
829        (0..len(groups))
830            .filter_map(|i| array_dict(groups, i))
831            .map(|g| {
832                let visible = if base_on { !off.contains(&g) } else { on.contains(&g) };
833                Layer { name: string(g, c"Name").unwrap_or_default(), visible }
834            })
835            .collect()
836    }
837}
838