//! An open document: its working file, view data and edit state. use std::sync::{Arc, OnceLock}; use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; use crate::history::Editor; use crate::{ CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, }; #[cfg(test)] use crate::{record_link_scan, record_marker_scan}; pub struct Document { pub id: DocId, pub name: String, pub path: Option, /// The working file: what Save writes. pub bytes: Arc>, /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. pub display: Arc>, pub info: DocInfo, pub renderer: RenderPool, /// The password the document was opened with (needed to read attachments, etc.). pub password: Option, /// `true` when there are edits that have not been saved. pub dirty: bool, /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. pub(crate) generation: u64, /// The generation last handed out by `autosave_snapshots`. pub(crate) snapshot_generation: u64, /// Why the document cannot be edited (e.g. encryption), if so. pub read_only_reason: Option, /// Interactive form fields of the current state (empty without a form). pub form: Arc>, pub(crate) marker_scan: OnceLock, pub(crate) links: OnceLock>, /// Signature fields, validated against the session's trust store (Signatures panel). pub signatures: Arc>, pub(crate) trust: Arc, pub(crate) sig_cache: Arc, pub(crate) editor: Option, pub(crate) config: RenderConfig, /// What field scripts printed or asked for (see [`Session::take_js_output`]). pub(crate) js_output: js::JsOutput, /// Dynamic XFA forms: what laying the template out produced (pages and fields are /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). pub xfa: Option, /// The parsed template of a laid-out XFA form, for its scripts. pub(crate) xfa_template: Option>, /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). pub(crate) xfa_scripts_off: bool, /// A content-only refresh keeps `info` from before the edit; an edit may have added or /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). pub(crate) fonts_stale: bool, /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. pub edit_notices: Vec, } /// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn /// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat /// keeps such text too, drawing it with fonts PdfCraft can't embed yet. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TextNotice { /// The form field's full name, or `None` for a comment. pub field: Option, /// The page (0-based) of the comment or of the field's widget. pub page: usize, pub ch: char, /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` /// set); otherwise the `?` shows in every viewer. pub redrawn_elsewhere: bool, } impl std::fmt::Display for TextNotice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); let page = self.page + 1; match (&self.field, self.redrawn_elsewhere) { (Some(name), true) => write!( f, "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" ), (Some(name), false) => write!( f, "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" ), (None, _) => write!( f, "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" ), } } } /// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying /// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); let mut names: Option> = None; let mut out: Vec = Vec::new(); for (page, p) in pages.iter().enumerate() { let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { let now = next.get(r); let Some(d) = now.as_dict() else { continue }; let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; // Only what this edit drew: a new annotation, or a new appearance. let was = prev.get(r); if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { continue; } let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { let names = names.get_or_insert_with(|| { pdfcraft_forms::fields(next) .into_iter() .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) .collect() }); names.get(&r).cloned().unwrap_or_default() }); if field.is_some() && out.iter().any(|n| n.field == field) { continue; } out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); } } out } impl Document { /// How the document opens (Document Properties ▸ Initial View). /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be /// read for editing). pub fn accessibility_check(&self, options: &a11y::Options) -> Option { self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) } /// A script of this XFA form ran past its time limit and was abandoned, so the form's /// scripts no longer run in this document (reopening it runs them again). The script's /// error is in the JavaScript output; this is the state, for a viewer to show. pub fn xfa_scripts_off(&self) -> bool { self.xfa_scripts_off } /// PDF Optimizer ▸ Audit space usage. pub fn audit_space(&self) -> Vec { self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() } /// Edit a PDF: the images `page` draws (0-based). pub fn page_images(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() } /// Mixed editable objects on one page. Source inventories are zero-based and added items /// appear once. Rectangles are user-space points, independent of the viewer's zoom. pub fn editable_objects(&self, page: usize) -> Result, String> { let editor = self.editor.as_ref().ok_or("the document can't be read")?; pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) } /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, /// so even a large crop-box origin cannot erase a small drag by cancellation. pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { let p = self.info.pages.get(page).ok_or("the page no longer exists")?; let [x0, y0, x1, y1] = p.crop.map(f64::from); let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); let (u, v) = match p.rotation { 90 => (b, -a), 180 => (-a, -b), 270 => (-b, a), _ => (a, b), }; let offset = [u * (x1 - x0), -v * (y1 - y0)]; if !offset.iter().all(|v| v.is_finite()) { return Err("the move must be finite and non-zero".into()); } Ok(offset) } /// Save image as: image `index` on `page` as a file (extension, bytes). pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { self.check_copying().map_err(|e| e.to_string())?; let editor = self.editor.as_ref().ok_or("the document can't be read")?; let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; if img.is_form { return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); } pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) } /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). pub fn text_blocks(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() } /// Saved measurement annotations, calculated from their geometry and PDF scales, plus /// the ones that couldn't be read (unsupported formats are skipped, not fatal). pub fn measurements(&self) -> Result { let e = self.editor.as_ref().ok_or("the document can't be read")?; Ok(measure::list(&e.cos)) } pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { let e = self.editor.as_ref().ok_or("the document can't be read")?; measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) } /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a /// point) where the page sets no scale. pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { let e = self.editor.as_ref().ok_or("the document can't be read")?; measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) } pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { let e = self.editor.as_ref().ok_or("the document can't be read")?; measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) } pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { let e = self.editor.as_ref().ok_or("the document can't be read")?; measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) } pub fn measurement_paths(&self, page: usize) -> Result { let e = self.editor.as_ref().ok_or("the document can't be read")?; measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) } /// A counter that changes with every edit (for caches of derived data). pub fn edit_generation(&self) -> u64 { self.generation } /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). pub fn text_lines(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() } /// The form's calculation order: its calculated fields in the order they calculate, what each /// formula reads, what is wrong with the order, and the order Fix order writes. pub fn calculation_order(&self) -> CalculationOrder { self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() } /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() } /// Reachable indirect structure tags, in document order. pub fn tags(&self) -> Vec { self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() } /// Marked-content items directly owned by one reachable indirect structure tag. pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) } /// The edit that fixes `rule`, for the rules with an automatic fix: the document language /// (`value` is the language), the title (`value` replaces it; else the current title or the /// file name) and the tab order. pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { let value = value.map(str::trim).filter(|v| !v.is_empty()); match rule { a11y::Rule::PrimaryLanguage => { let mut v = self.initial_view(); v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); Ok(Edit::SetInitialView(Box::new(v))) } a11y::Rule::Title => { let title = value .map(str::to_owned) .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); let mut v = self.initial_view(); v.display_title = true; Ok(Edit::Batch { label: "Set document title".into(), edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], }) } a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), other => Err(format!("\"{}\" has no automatic fix", other.name())), } } pub fn initial_view(&self) -> InitialView { self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() } /// Signed: at least one signature field holds a signature. pub fn is_signed(&self) -> bool { self.signatures.iter().any(|s| s.signed) } /// Whether comments are hidden (Comments ▸ Hide all comments). pub fn comments_hidden(&self) -> bool { self.config.hide_comments } pub fn can_undo(&self) -> Option<&str> { self.undo_label().map(Label::as_str) } pub fn can_redo(&self) -> Option<&str> { self.redo_label().map(Label::as_str) } /// What Undo would undo, in parts, for an interface that says it in another language. pub fn undo_label(&self) -> Option<&Label> { self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) } /// What Redo would redo, in parts. pub fn redo_label(&self) -> Option<&Label> { self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) } pub fn editable(&self) -> bool { self.editor.is_some() } /// What the opening password allows; `None` when the document is not encrypted. pub fn permissions(&self) -> Option { self.editor.as_ref().and_then(|e| e.cos.permissions()) } /// Printing is allowed (Table 22, bit 3). pub fn allows_printing(&self) -> bool { self.permissions().is_none_or(|p| p.print()) } /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. pub fn allows_copying(&self) -> bool { self.permissions().is_none_or(|p| p.copy()) } /// [`Self::allows_copying`], as the error to report when it isn't. pub fn check_copying(&self) -> Result<(), EditError> { if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } } /// Page changes (insert, delete, rotate, move, extract) are allowed. pub fn allows_assembly(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.assemble()) } /// Redaction marks waiting to be applied. pub fn redaction_marks(&self) -> usize { self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() } /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. pub fn marks(&self) -> &[MarkKind] { &self .marker_scan .get_or_init(|| { self.editor .as_ref() .map(|e| { #[cfg(test)] record_marker_scan(); pdfcraft_edit::scan_markers(&e.cos) }) .unwrap_or_default() }) .marks } /// Text and images added with Edit a PDF ▸ Add content (still editable). pub fn added(&self) -> &[pdfcraft_edit::Added] { &self .marker_scan .get_or_init(|| { self.editor .as_ref() .map(|e| { #[cfg(test)] record_marker_scan(); pdfcraft_edit::scan_markers(&e.cos) }) .unwrap_or_default() }) .added } /// Link annotations, for Edit a PDF ▸ Link. pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { self.links.get_or_init(|| { self.editor .as_ref() .map(|e| { #[cfg(test)] record_link_scan(); pdfcraft_annot::links::list(&e.cos) }) .unwrap_or_default() }) } /// Changes to content and document information are allowed. pub fn allows_modification(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.modify()) } /// How widget `widget` of text field `name` lays its value out, for asking whether a typed /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no /// more than fits. `None` without such a field or widget, or when the document can't be /// edited. pub fn field_text_box(&self, name: &str, widget: usize) -> Option { let cos = &self.editor.as_ref()?.cos; let f = self.form.iter().find(|f| f.name == name)?; let w = f.widgets.get(widget)?; Some(form_appearance::text_box(cos, f, w)) } /// Filling in form fields is allowed (Table 22, bits 6 and 9). pub fn allows_form_filling(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) } /// Adding and changing comments is allowed (Table 22, bit 6). pub fn allows_annotation(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.annotate()) } /// The document's security may be changed (Protect, Remove security): it is editable and, /// if encrypted, was opened with the owner password. pub fn allows_security_change(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) } /// A password restricts the document and it wasn't opened with the owner password: entering /// that password ([`Session::unlock`]) lifts the restrictions. pub fn can_unlock(&self) -> bool { self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) } /// A summary of the document's security for Document Properties ▸ Security, including /// protection applied in this session (written by the next save). pub fn security_summary(&self) -> Option { let editor = self.editor.as_ref()?; let h = editor.cos.output_handler()?; let d = h.dict(); let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); let method = match (d.v, stream) { (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", _ => "Attachments only", }; let pending = editor.cos.encryption_changed(); let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) } /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). pub fn comment_props(&self, page: usize, index: usize) -> Option { self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) } /// A form field's Appearance-tab look (borders, colours, font). pub fn field_look(&self, name: &str) -> Option { let e = self.editor.as_ref()?; let f = self.form.iter().find(|f| f.name == name)?; Some(pdfcraft_forms::look(&e.cos, f)) } /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. pub fn field_check_style(&self, name: &str) -> Option { let e = self.editor.as_ref()?; let f = self.form.iter().find(|f| f.name == name)?; matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) } /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): /// the page (0-based) of the first widget that cuts it, and how. pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { let e = self.editor.as_ref()?; let f = self.form.iter().find(|f| f.name == name)?; f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) } /// Remove Hidden Information: what each category would remove. pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() } /// Verification: faux redactions (opaque covers over extractable text) and redaction /// marks that were never applied. Fails closed: any read error is returned, never an /// empty list. pub fn audit_redactions(&self) -> Result, String> { let editor = self.editor.as_ref().ok_or("the document can't be read")?; pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) } /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() } /// Current value of a document-information entry (Title, Author, …). pub fn info_value(&self, key: &str) -> Option { self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) } /// Whether the document information has an entry `key`, of any type, listed or not. pub fn has_info(&self, key: &str) -> bool { self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) } /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. pub fn custom_info(&self) -> Vec<(String, String)> { self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() } /// The name shown on the tab and window: the document title when the document asks for it /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { self.editor .as_ref() .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) .map(|t| t.trim().to_owned()) .filter(|t| !t.is_empty() && !is_placeholder_title(t)) .unwrap_or_else(|| self.name.clone()) } /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that /// revision). Unsaved edits aren't a revision yet. pub fn revision_ends(&self) -> Vec { self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() } /// The file as last opened or saved, without unsaved edits: what an incremental save appends /// to. This stays that file when undo has restored an older snapshot. pub fn saved_bytes(&self) -> Arc> { self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) } /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). pub fn repair_log(&self) -> Vec { self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() } } /// A title that names no document: what a web browser writes when it saves a blank page or a /// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a /// title says less than the file name, so the file name is shown instead. An address is one /// word: "About: our company" is a real title. pub(crate) fn is_placeholder_title(title: &str) -> bool { let t = title.trim().to_lowercase(); let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); address || t == "untitled" } /// The working file plus appearances for comments that have none, and for form fields the file /// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the /// viewer and never enter these bytes, which image/text exports and Print as Image also consume. pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { let Some(e) = editor else { return bytes.clone() }; let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); let Some(display) = fields.or(comments) else { return bytes.clone(); }; match write_incremental(&display, &SaveOptions::default()) { Ok(written) => Arc::new(written), Err(err) => { e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); bytes.clone() } } } /// `comments` (the display copy with comment appearances, if it needed any; else the document /// itself) with its form fields drawn from their values where the file leaves that to the viewer. fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) } /// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) } /// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read /// the way the full inspection reads them: NULs and spaces trimmed, empty is none. pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); [ (&mut info.title, read("Title")), (&mut info.author, read("Author")), (&mut info.subject, read("Subject")), (&mut info.keywords, read("Keywords")), (&mut info.creator, read("Creator")), (&mut info.producer, read("Producer")), ] } /// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for /// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page /// images agree with the app and independent viewers. It never changes the source document, and an /// unparseable file, a write failure or a parser panic falls back to the original bytes. pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { guard(|| { pdfcraft_cos::Document::open_with_password(bytes.clone(), password) .ok() .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) .map(Arc::new) }) .ok() .flatten() .unwrap_or(bytes) } /// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with /// `password`. The renderer authenticates only the user password of R2–R4 files, so for their /// owner password this is the user password that owner authentication recovers; otherwise it is /// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner /// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps /// it, so the renderer reports the same error it always did. pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { let password = password?; let recovered = guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); Some(recovered.unwrap_or_else(|| password.to_owned())) } /// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) } /// Fails when the renderer still draws text under an applied redaction mark. /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and /// its verify pass reuses them. Where they differ from the renderer's (a standard font without /// /Widths, whose advances the interpreter approximates; a CMap /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would /// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its /// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps /// the document as it was. pub(crate) fn shown_under_redactions( after: &pdfcraft_cos::Document, marks: &[pdfcraft_redact::Mark], overlays: &[pdfcraft_cos::ObjRef], pages: &[pdfcraft_render::PageInfo], password: Option<&str>, ) -> Result<(), EditError> { use pdfcraft_cos::Object; use pdfcraft_redact::RedactError; let mut marked: Vec = marks.iter().map(|m| m.page).collect(); marked.sort_unstable(); marked.dedup(); if marked.is_empty() { return Ok(()); } let mut shown = after.clone(); let model = pdfcraft_model::pages(&shown); for &pi in &marked { let Some(page) = model.get(pi) else { continue }; let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; } let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); let mut residue = 0; for &pi in &marked { let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; // A page that cannot be read back cannot be confirmed clean. let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; let rects: Vec<[f64; 4]> = marks .iter() .filter(|m| m.page == pi) .flat_map(|m| m.rects.iter()) .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) .collect(); for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); let area = (x1 - x0) * (y1 - y0); if area.is_nan() || area <= 0.0 { continue; } // Redaction itself takes any glyph a fifth under a mark; one that is half under it // and still drawn was not where redaction thought it was. let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; if rects.iter().any(under) { residue += 1; } } } if residue > 0 { return Err(RedactError::Residue(residue).into()); } Ok(()) }