diff --git a/Cargo.lock b/Cargo.lock index d94bef8484..fafd037d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4681,8 +4681,10 @@ dependencies = [ "pdfcraft-testkit", "pdfcraft-xfa", "regex", + "roxmltree 0.21.2", "sha2 0.11.1", "thiserror 2.0.21", + "unicode-normalization", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index dfcb7606d5..e20839c69b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -456,6 +456,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. - **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged. - **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged. +- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. - **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged. @@ -900,6 +901,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`). - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. +- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. diff --git a/apps/pdfcraft-cli/build.rs b/apps/pdfcraft-cli/build.rs index ee77f13f6a..9a6500b7f3 100644 --- a/apps/pdfcraft-cli/build.rs +++ b/apps/pdfcraft-cli/build.rs @@ -6,12 +6,25 @@ //! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it //! into an error (for release builds). +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a +/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they +/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. +/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set("ProductName", "PdfCraft") .set("FileDescription", "PdfCraft command-line tool") diff --git a/apps/pdfcraft/build.rs b/apps/pdfcraft/build.rs index 35d519d99e..b6892e7b9d 100644 --- a/apps/pdfcraft/build.rs +++ b/apps/pdfcraft/build.rs @@ -9,6 +9,13 @@ #[path = "src/windows_manifest.rs"] mod windows_manifest; +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a +/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they +/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. +/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico"); @@ -18,6 +25,12 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set_icon("../../assets/app-icon/pdfcraft.ico") .set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2") diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs index df15b3420d..c93591c1d0 100644 --- a/crates/a11y/src/autotag_fields.rs +++ b/crates/a11y/src/autotag_fields.rs @@ -57,7 +57,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let mut widgets = Vec::new(); // A widget listed in more than one page's /Annots is tagged once (one key, one /Form). let mut seen = std::collections::HashSet::new(); + // A shared indirect /Annots array is scanned for the first page that lists it: its widgets + // are tagged once anyway, so scanning it again for every page only repeats the work. + let mut arrays = std::collections::HashSet::new(); for page in &pages { + if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref) + && !arrays.insert(array) + { + continue; + } let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default(); for annot in annots { let Some(reference) = annot.as_ref() else { continue }; diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 118b7af4e5..faf1a87bad 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -262,11 +262,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> { pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec { let mut out = Vec::new(); - let annots = |p: &Page| -> Vec<(ObjRef, Dict)> { + // The annotation objects are shared, never deep-copied: a reference repeated across a page's + // Annots array (or its pages) must not copy the dictionary each time. + let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc)> { let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); a.iter() - .filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d)))) - .filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup")) + .filter_map(|x| x.as_ref().map(|r| (r, doc.get(r)))) + .filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup"))) .collect() }; match rule { @@ -284,7 +286,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => { for &p in list { let Some(page) = pages.get(p) else { continue }; - for (_, a) in annots(page) { + for (_, obj) in annots(page) { + let Some(a) = obj.as_dict() else { continue }; let st = a.name(b"Subtype").unwrap_or(b""); let applies = match rule { Rule::TaggedFormFields => st == b"Widget", @@ -299,11 +302,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz let pattern = if st == b"Widget" { "Form field on page {p} isn't tagged" } else { - values.push(("{}", subtype_name(&a))); + values.push(("{}", subtype_name(a))); "{} annotation on page {p} isn't tagged" }; let mut finding = Finding::worded(Some(p), pattern, values); - finding.bbox = annotation_rect(doc, &a); + finding.bbox = annotation_rect(doc, a); out.push(finding); } } @@ -345,7 +348,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz let mut seen = HashSet::new(); for &p in list { let Some(page) = pages.get(p) else { continue }; - for (r, a) in annots(page) { + for (r, obj) in annots(page) { + let Some(a) = obj.as_dict() else { continue }; if a.name(b"Subtype") != Some(b"Widget") { continue; } @@ -362,7 +366,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz "Field \"{}\" on page {p} has no description (tooltip)", vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())], ); - finding.bbox = annotation_rect(doc, &a); + finding.bbox = annotation_rect(doc, a); out.push(finding); } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 4eac87358f..890eb33e9f 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1883,3 +1883,37 @@ fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowan let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") }; assert!(reason.contains("too much content"), "{reason}"); } + +#[test] +fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(), + "[5 0 R 5 0 R]".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); +} + +#[test] +fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() { + let doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(), + "<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(), + "<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(), + ], + "", + ); + let pages = pdfcraft_model::pages(&doc); + let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false); + assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported"); +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 34c49f298d..d3850d1d8f 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -700,7 +700,7 @@ impl Automation { summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?, "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; @@ -768,6 +768,49 @@ impl Automation { let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; self.apply(&a, edit)? } + "doc_convert_colours" => self.convert_colours(&a)?, + "attachment_edit" => { + let key = a.opt_str("key")?; + let name = a.opt_str("name")?; + if key.is_some() == name.is_some() { + return Err(ToolError::InvalidArgs("provide exactly one of key or name".into())); + } + let keys: Vec = self + .doc(&a)? + .info + .attachments + .iter() + .filter_map(|attachment| match &attachment.source { + pdfcraft_render::AttachmentSource::Document { key: attachment_key } + if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) => + { + Some(attachment_key.clone()) + } + pdfcraft_render::AttachmentSource::Document { .. } => None, + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect(); + let key = match keys.as_slice() { + [key] => key.clone(), + [] if key.is_some() => { + return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default()))); + } + [] => { + return Err(ToolError::InvalidArgs(format!( + "there is no document attachment named {:?}; use a name from doc_info", + name.unwrap_or_default() + ))); + } + _ => { + return Err(ToolError::InvalidArgs(format!( + "more than one document attachment is named {:?}; provide its key from doc_info", + name.unwrap_or_default() + ))); + } + }; + let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) }; + self.apply(&a, edit)? + } "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 823bc078c0..e1439aa89f 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -360,6 +360,7 @@ impl Automation { ("discard_thumbnails", &mut settings.discard_thumbnails), ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), + ("discard_search_index", &mut settings.discard_search_index), ("discard_print_settings", &mut settings.discard_print_settings), ("discard_form_actions", &mut settings.discard_form_actions), ("flate_unencoded", &mut settings.flate_unencoded), @@ -367,6 +368,7 @@ impl Automation { ("lzw_to_flate", &mut settings.lzw_to_flate), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ("discard_external_refs", &mut settings.discard_external_refs), ] { if let Some(b) = a.opt_bool(key)? { *flag = b; @@ -471,6 +473,7 @@ impl Automation { "image_bytes_after": o.image_bytes_after, "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, + "search_indexes": o.search_indexes, "tags_removed": o.tags_removed, "print_settings": o.print_settings, "form_actions": o.form_actions, @@ -479,6 +482,8 @@ impl Automation { "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, + "external_links": o.external_links, + "external_bookmarks": o.external_bookmarks, "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), @@ -626,11 +631,33 @@ impl Automation { replace, file: self.mark_file(a)?, }, + "doc_printer_marks" => { + use pdfcraft_engine::PrinterMarks; + let flag = |k: &str, default: bool| -> Result { Ok(a.opt_bool(k)?.unwrap_or(default)) }; + let (y, m, d) = self.session.today(); + let settings = PrinterMarks { + trim: flag("trim", true)?, + bleed: flag("bleed", true)?, + registration: flag("registration", true)?, + color_bars: flag("color_bars", true)?, + page_info: flag("page_info", true)?, + line_weight: a.opt_num("line_weight")?.unwrap_or(0.25), + make_room: match a.opt_num("make_room")? { + Some(r) if r < 0.0 => None, + Some(r) => Some(r), + None => Some(36.0), + }, + label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()), + date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")), + }; + Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) } + } _ => { let kind = match a.str("kind")? { "header_footer" => MarkKind::HeaderFooter, "watermark" => MarkKind::Watermark, "background" => MarkKind::Background, + "printer_marks" => MarkKind::PrinterMarks, other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), }; Edit::RemoveMarks { kind } diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 6cd02115a8..e6ca95e905 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -347,7 +347,12 @@ pub(crate) fn info(d: &Document) -> Value { "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, "display": t.display, })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::>(), + // A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its + // documents are `attachments`; `initial` names the one presented first, null for the cover. + "portfolio": i.portfolio.as_ref().map(|p| json!({ + "initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(), + })), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null // `substitute` with a non-null `substitution` means no face was available at all, so diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 2f1cf5e136..06f7005e27 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -60,8 +60,10 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { /// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on /// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default -/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells +/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the +/// caller chose in `options` stands. +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize, sheets: &[print::Sheet]) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -81,11 +83,54 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(print::media_option(paper)).collect(); + print::add_cups_orientation(&mut options, sheets); + options + }, }) } impl Automation { + /// Print production ▸ Convert colours: every colour on the pages in gray, rgb or cmyk. + pub(crate) fn convert_colours(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let target = match a.opt_str("to")?.unwrap_or("gray") { + "gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray, + "rgb" => pdfcraft_engine::ColourTarget::Rgb, + "cmyk" => pdfcraft_engine::ColourTarget::Cmyk, + other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))), + }; + let settings = pdfcraft_engine::ConvertColours { + target, + preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true), + images: a.opt_bool("images")?.unwrap_or(true), + annotations: a.opt_bool("annotations")?.unwrap_or(false), + }; + let id = self.doc(a)?.id; + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] })); + } + // PDF/X has no declaration of its own here: it always carries an output intent. + let declared = self.doc(a)?.standards(); + let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase())); + let loses: Vec = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect(); + let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?; + Ok(json!({ + "colours": found.colours, + "images": found.images, + "images_left": found.images_left, + "pages": found.pages, + "loses_conformance": loses, + "undo": r["undo"], + })) + } + /// `printers`: the system's printers, the default marked, and each one's two-sided support /// where the spooler reports it. pub(crate) fn printers(&self) -> Result { @@ -218,7 +263,8 @@ impl Automation { }; let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); + let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; + let sheets = laid_out.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); match (a.opt_str("path")?, a.opt_str("printer")?) { @@ -228,7 +274,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -267,13 +313,31 @@ mod tests { assert!(duplex("sideways", false).is_err()); } + /// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after + /// the paper, so a landscape sheet is turned onto the portrait paper rather than printed + /// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in + /// `options` stands and isn't sent twice. + #[test] + fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { + let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options; + let media = ("media".to_string(), "Letter".to_string()); + let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); + let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(job(json!({}), &landscape), turned); + assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets"); + assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them"); + let chosen = ("orientation-requested".to_string(), "5".to_string()); + assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once"); + } + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). #[test] fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { let (index, _) = paper_named(paper_name).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -289,7 +353,7 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, &[]).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, &[]).is_err()); } } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index a806d04283..1ed8fcbde0 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -200,6 +200,24 @@ impl Automation { let after = self.doc(a)?.redaction_marks(); out["applied"] = json!(before - after); out["marks_pending"] = json!(after); + // Where the removed text is still held that applying leaves as it is. + let left = self.doc(a)?.redaction_elsewhere().unwrap_or_default(); + out["still_holding"] = json!({ + "bookmarks": left.bookmarks, + "attachments": left.attachments, + "comments": left.comments, + "xmp": left.xmp, + "total": left.total(), + "unsearched": left.unsearched, + }); + // What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed. + let claim = |c: pdfcraft_engine::RedactionClaim| match c { + pdfcraft_engine::RedactionClaim::None => "none", + pdfcraft_engine::RedactionClaim::Kept => "kept", + pdfcraft_engine::RedactionClaim::Lost => "lost", + }; + let id = self.doc(a)?.redaction_identification().unwrap_or_default(); + out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) }); // The name to save the redacted copy under, as Save does in the app. out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name)); Ok(out) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4838fd0fdc..e1320972b8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -171,11 +171,23 @@ pub fn tools() -> Vec { t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") .destructive() .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), + t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.") + .cmd("prepress.convert_colors") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to convert (default all)"), + "to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] }, + "preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" }, + }), + &["doc"], + )), + t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.") + .with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security, repair notes and the objects that could not be read.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -612,7 +624,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise; the field keeps its rectangle and its content turns inside it. Undoable.", ) .with(schema( json!({ @@ -636,7 +648,7 @@ pub fn tools() -> Vec { "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "font_size": { "type": "number", "minimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, - "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, + "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, @@ -693,7 +705,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), @@ -1227,13 +1239,27 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.") + .cmd("prepress.marks") + .with(schema( + json!({ + "doc": doc(), "pages": pages("the pages to mark (default all)"), + "trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" }, + "line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." }, + "make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." }, + "label": { "type": "string", "description": "The page-information line's label (default: the file's name)." }, + "date": { "type": "string", "description": "The page-information line's date (default: today)." }, + "replace": { "type": "boolean" }, + }), + &["doc"], + )), t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") .ro() .cmd("edit.background") .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), - t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") + t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), + .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])), t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`_page_.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.") .cmd("export.image") .with(schema( @@ -1699,7 +1725,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1715,6 +1741,7 @@ pub fn tools() -> Vec { "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, + "discard_search_index": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "discard_form_actions": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, @@ -1722,6 +1749,7 @@ pub fn tools() -> Vec { "lzw_to_flate": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, + "discard_external_refs": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, }), &["doc", "path"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index b5aea91a5b..bc9d2e9ee5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5579,12 +5579,8 @@ fn rotating_a_field_through_tools() { ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 })); let (rot, turned) = rect_of(&mut a); assert_eq!(rot, 90); - let (bw, bh) = (before[2] - before[0], before[3] - before[1]); - let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]); - assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}"); - let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); - let (bc, tc) = (center(&before), center(&turned)); - assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}"); + // The field stays where it is; only its content turns (#4296). + assert!(turned.iter().zip(&before).all(|(t, b)| (t - b).abs() < 0.2), "the field moved: {before:?} -> {turned:?}"); assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties"); let (rot, back) = rect_of(&mut a); assert_eq!((rot, back), (0, before)); @@ -5703,6 +5699,73 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +/// #2539 review: `redact_apply` says where the removed text is still held that applying leaves as +/// it is (a bookmark title and a note's text here), and changes neither. +#[test] +fn redact_apply_reports_places_that_still_hold_the_removed_text() { + let dir = workdir("redact-still-holding"); + std::fs::write(dir.join("memo.txt"), "Codename Bluefin report\nPublic line").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Bluefin plans", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Annual figures", "page": 1 })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 400], "contents": "Ask about bluefin" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 500], "contents": "Looks good" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Bluefin" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["still_holding"], json!({ "bookmarks": 1, "attachments": 0, "comments": 1, "xmp": 0, "total": 2, "unsearched": false }), "{r}"); + // Said in the warnings too, with the other edit notices. + assert_eq!( + r["warnings"], + json!([ + "the redacted text also appears in 1 bookmark title and 1 comment, which redaction doesn't change (Remove hidden information removes them)" + ]), + "{r}" + ); + assert!(!page_text(&mut a, doc)[0].contains("Bluefin")); + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["Bluefin plans", "Annual figures"], "reported, not changed"); + assert_eq!(r["identification"], json!({ "pdfa": "none", "pdfua": "none" }), "{r}"); +} + +/// #2539 review: when `redact_apply` removes the document's XMP packet because it names the +/// redacted word, `identification` says the PDF/A-2b and PDF/UA-1 identification it held was +/// written back, and the warnings say it too. +#[test] +fn redact_apply_reports_the_identification_written_back() { + let dir = workdir("redact-identification"); + let xmp = r#"Bluefin report"#; + let body = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()); + let metadata = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &content, + &metadata, + ]; + std::fs::write(dir.join("pdfa.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "pdfa.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "BLUEFIN" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["identification"], json!({ "pdfa": "kept", "pdfua": "kept" }), "{r}"); + assert_eq!( + r["warnings"], + json!([ + "the document's XMP metadata held the redacted text and was removed; its PDF/A and PDF/UA identification was written back on its own" + ]), + "{r}" + ); +} + #[test] fn applying_redactions_proposes_the_name_with_redacted() { let dir = workdir("redact-save-as"); @@ -8319,6 +8382,36 @@ fn signing_with_translated_labels_through_tools() { assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}"); } +#[test] +fn optimizing_discards_an_embedded_search_index_through_tools() { + use pdfcraft_cos::{Dict, Object}; + let dir = workdir("optimize-search-index"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let mut info = Dict::new(); + for name in ["IBM-ODIndexes", "Illustrator"] { + let mut data = Dict::new(); + data.set(b"Private".to_vec(), Object::Int(1)); + info.set(name.as_bytes().to_vec(), Object::Dict(data)); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + std::fs::write(dir.join("indexed.pdf"), pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()).unwrap(); + let piece_info = |name: &str| { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); + let cat = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let info = d.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() + }; + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "indexed.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["search_indexes"], 0, "{kept}"); + assert_eq!(piece_info("kept.pdf"), ["IBM-ODIndexes", "Illustrator"]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_search_index": true })); + assert_eq!(r["search_indexes"], 1, "{r}"); + assert_eq!(piece_info("small.pdf"), ["Illustrator"]); +} + #[test] fn optimizing_through_tools() { let dir = workdir("optimize"); @@ -11328,6 +11421,22 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { assert_eq!(reopened["pages"], 1); } +#[test] +fn optimizing_discards_links_to_other_documents_through_tools() { + let dir = workdir("optimize-external-refs"); + std::fs::write(dir.join("remote.pdf"), remote_link_pdf("other.pdf", None)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "remote.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["external_links"], 0, "{kept}"); + let kept = ok(&mut a, "doc_open", json!({ "path": "kept.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": kept }))["count"], 1); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_external_refs": true })); + assert_eq!((r["external_links"].as_u64(), r["external_bookmarks"].as_u64()), (Some(1), Some(0)), "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": small }))["count"], 0); +} + /// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second /// page is turned with /Rotate 90. fn table_pdf() -> Vec { @@ -12002,6 +12111,60 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Printer marks through the tools: added around the trim box (the page widened to make room), +/// removable by kind. +#[test] +fn printer_marks_through_tools() { + let dir = workdir("printer-marks"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let width_before = ok(&mut a, "doc_info", json!({ "doc": doc }))["pages"][0]["width"].as_f64().unwrap_or(0.0); + let r = ok(&mut a, "doc_printer_marks", json!({ "doc": doc, "pages": [1], "color_bars": false, "line_weight": 0.5 })); + assert_eq!(r["undo"], "Add printer marks"); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let width_after = info["pages"][0]["width"].as_f64().unwrap_or(0.0); + assert!(width_after > width_before + 70.0 || width_before == 0.0, "widened to make room: {width_before} → {width_after}; {info}"); + let r = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "printer_marks" })); + assert_eq!(r["undo"], "Remove printer marks"); + for bad in [json!({ "line_weight": -1 }), json!({ "line_weight": 0 }), json!({ "line_weight": 10000 }), json!({ "make_room": 1000000 })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(matches!(a.call("doc_printer_marks", &args), Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))), "{bad}"); + } + assert!(matches!( + a.call( + "doc_printer_marks", + &json!({ "doc": doc, "trim": false, "bleed": false, "registration": false, "color_bars": false, "page_info": false }) + ), + Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) + )); +} + +/// Convert colours through the tools: counts, converts, reports nothing to do. +#[test] +fn convert_colours_through_tools() { + let dir = workdir("convert-colours"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The fixture sets no colour of its own: nothing to convert yet. + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray" })); + assert_eq!((r["colours"].as_u64(), r["undo"].is_null()), (Some(0), true), "{r}"); + // Red text added as page content converts. + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Red", "at": [20, 20], "color": "#FF0000" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray", "pages": [1] })); + assert!(r["colours"].as_u64().unwrap_or(0) >= 1 && r["pages"].as_u64() == Some(1), "{r}"); + assert_eq!(r["undo"], "Convert colours"); + assert_eq!(r["loses_conformance"], json!([]), "{r}"); + assert!(matches!(a.call("doc_convert_colours", &json!({ "doc": doc, "to": "lab" })), Err(ToolError::InvalidArgs(_)))); + // A file that declares a standard is told what it may have lost. + ok(&mut a, "pdfa_convert", json!({ "doc": doc, "level": "3b" })); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Blue", "at": [20, 40], "color": "#0000FF" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "cmyk", "pages": [1] })); + let loses = r["loses_conformance"].as_array().unwrap(); + assert_eq!(loses[0], "PDF/A-3b", "{r}"); + assert!(loses[1].as_str().unwrap().starts_with("output intent "), "{r}"); +} + /// Fix hairlines through the tools: counts what changed, and reports zero without an edit. #[test] fn fix_hairlines_through_tools() { @@ -12066,6 +12229,80 @@ fn attaching_and_deleting_files_through_the_tools() { assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); } +#[test] +fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() { + let dir = workdir("attachment-description"); + let bytes = b"original attachment bytes"; + std::fs::write(dir.join("notes.txt"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "attachment_add", json!({"doc": doc, "path": "notes.txt", "description": "Original"})); + + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": "Résumé 東京"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京"); + ok(&mut a, "edit_undo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Original"); + ok(&mut a, "edit_redo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京"); + + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": ""})); + assert!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"].is_null()); + ok(&mut a, "edit_undo", json!({"doc": doc})); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "edited.pdf", "full": true})); + let saved = std::fs::read(dir.join("edited.pdf")).unwrap(); + let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); + let attachment = info.attachments.iter().find(|item| item.name == "notes.txt").unwrap(); + assert_eq!(attachment.description.as_deref(), Some("Résumé 東京")); + assert_eq!(pdfcraft_render::attachment_data(&saved, None, attachment).unwrap(), bytes); + let reopened = ok(&mut a, "doc_open", json!({"path": "edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Résumé 東京"); +} + +#[test] +fn attachment_edit_resolves_the_listed_name_to_its_name_tree_key() { + let dir = workdir("attachment-name-tree-key"); + std::fs::write(dir.join("payload.txt"), b"payload").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "attachment_add", json!({"doc":doc,"path":"payload.txt","name":"storage-key.txt"})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"seed.pdf","full":true})); + let mut source = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("seed.pdf")).unwrap())).unwrap(); + let key = "storage-key.txt"; + let root = source.root().unwrap(); + let catalog = source.get(root).as_dict().unwrap().clone(); + let names = source.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone(); + let tree = source.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone(); + let pairs = source.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone(); + let spec_ref = pairs.get(1).and_then(pdfcraft_cos::Object::as_ref).unwrap(); + source + .update_dict(spec_ref, |spec| { + let name = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("visible-name.txt")); + spec.set(b"F".to_vec(), name.clone()); + spec.set(b"UF".to_vec(), name); + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap(); + std::fs::write(dir.join("named.pdf"), bytes).unwrap(); + + let doc = ok(&mut a, "doc_open", json!({"path":"named.pdf"}))["doc"].as_u64().unwrap(); + let attachment = &ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]; + assert_eq!(attachment["name"], "visible-name.txt"); + assert_eq!(attachment["key"], key); + ok(&mut a, "attachment_edit", json!({"doc":doc,"key":key,"description":"Updated"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["description"], "Updated"); + assert!(matches!( + a.call("attachment_edit", &json!({"doc":doc,"name":"missing.txt","description":"x"})), + Err(ToolError::InvalidArgs(message)) if message.contains("no document attachment named") + )); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf","full":true})); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("edited.pdf")).unwrap())).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().unwrap().clone(); + let names = saved.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone(); + let tree = saved.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone(); + let pairs = saved.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone(); + assert_eq!(pairs.first().and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some(key)); +} + #[test] fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 7875b139ee..cdb072a888 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -16,6 +16,7 @@ mod list_selection; mod model_3d; mod model_render; mod optimize_content_boundaries; +mod portfolio_info; mod redact_after_stray_q_form; mod redact_text_after_q; mod render_warnings; diff --git a/crates/automation/tests/it/portfolio_info.rs b/crates/automation/tests/it/portfolio_info.rs new file mode 100644 index 0000000000..750a3e8840 --- /dev/null +++ b/crates/automation/tests/it/portfolio_info.rs @@ -0,0 +1,54 @@ +//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it +//! presents first and how its file list first shows. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// Call tool `name` and return its JSON result. +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection` +/// entries, or none at all. +fn portfolio(collection: Option<&str>) -> Vec { + let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default(); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +4 0 obj << /Type /EmbeddedFile /Length 5 >> stream +hello +endstream endobj +5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj +6 0 obj << /Type /EmbeddedFile /Length 8 >> stream +%PDF-1.7 +endstream endobj +7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() +} + +/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file. +#[test] +fn doc_info_names_the_document_a_portfolio_presents_first() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap(); + std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap(); + std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let mut info = |path: &str| { + let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone() + }; + assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" })); + // Without `/D` the cover sheet is the initial document. + assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" })); + assert_eq!(info("plain.pdf"), Value::Null); +} diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index a5b53581fc..1ee4c9c4cd 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -119,6 +119,8 @@ pub fn scan_markers(doc: &Document) -> MarkerScan { Some(MarkKind::Watermark) } else if tag == Some(&b"Background"[..]) { Some(MarkKind::Background) + } else if tag == Some(&b"PrinterMarks"[..]) { + Some(MarkKind::PrinterMarks) } else { None }; diff --git a/crates/edit/src/colour.rs b/crates/edit/src/colour.rs new file mode 100644 index 0000000000..7eaab55384 --- /dev/null +++ b/crates/edit/src/colour.rs @@ -0,0 +1,230 @@ +//! Colours as sRGB through the colour space in force, for Convert colours: `g`/`rg`/`k`, and +//! `cs` + `sc`/`scn` through the space named. Device spaces, ICCBased by its component count (or +//! its Alternate), CalRGB and CalGray as their device kin, Lab through CIE XYZ with the +//! dictionary's white point, Indexed through its table, Separation and DeviceN through a Type 2 +//! (exponential) tint transform into the alternate space; a sampled or PostScript tint function, +//! or a pattern, falls back to a plain darkness so nothing reads as the wrong hue. + +use pdfcraft_cos::{Dict, Document, Object}; + +/// A colour space as far as a fill colour needs it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + Lab { + white: [f64; 3], + range: [f64; 4], + }, + Indexed { + base: Box, + hival: usize, + table: Vec, + }, + /// Separation and DeviceN: the number of inputs, the alternate space, and the tint + /// transform when it is a Type 2 function (C0, C1, N). + Tint { + inputs: usize, + alt: Box, + exp: Option<(Vec, Vec, f64)>, + }, + Pattern, +} + +impl Space { + /// How many numbers set a colour in this space. + fn components(&self) -> usize { + match self { + Space::Gray => 1, + Space::Rgb | Space::Lab { .. } => 3, + Space::Cmyk => 4, + Space::Indexed { .. } => 1, + Space::Tint { inputs, .. } => *inputs, + Space::Pattern => 0, + } + } +} + +fn nums(doc: &Document, o: &Object) -> Vec { + doc.resolve(o).as_array().map(|a| a.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()).unwrap_or_default() +} + +/// The colour space an operand of `cs` names: a device space, or a name in the resources' +/// `/ColorSpace` dictionary, or (inside arrays) a family. +pub(crate) fn space_of(doc: &Document, operand: &Object, cs_res: &Dict, depth: u8) -> Space { + if depth > 4 { + return Space::Gray; + } + let resolved = doc.resolve(operand); + match &*resolved { + Object::Name(n) => match n.as_slice() { + b"DeviceGray" | b"G" | b"CalGray" => Space::Gray, + b"DeviceRGB" | b"RGB" | b"CalRGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + b"Pattern" => Space::Pattern, + other => match cs_res.get(other) { + Some(o) => space_of(doc, o, &Dict::default(), depth + 1), + None => Space::Gray, + }, + }, + Object::Array(a) => { + let family = a.first().map(|f| doc.resolve(f)).and_then(|f| f.as_name().map(|n| n.to_vec())).unwrap_or_default(); + match family.as_slice() { + b"ICCBased" => { + let stream = a.get(1).map(|s| doc.resolve(s)); + let dict = stream.as_ref().and_then(|s| match &**s { + Object::Stream(st) => Some(st.dict.clone()), + _ => None, + }); + if let Some(alt) = dict.as_ref().and_then(|d| d.get(b"Alternate").cloned()) { + return space_of(doc, &alt, cs_res, depth + 1); + } + match dict.and_then(|d| d.int(b"N")) { + Some(1) => Space::Gray, + Some(4) => Space::Cmyk, + _ => Space::Rgb, + } + } + b"CalRGB" => Space::Rgb, + b"CalGray" => Space::Gray, + b"Lab" => { + let d = a.get(1).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default(); + let wp = d.get(b"WhitePoint").map(|w| nums(doc, w)).unwrap_or_default(); + let white = if wp.len() == 3 { [wp[0], wp[1], wp[2]] } else { [0.9505, 1.0, 1.089] }; + let r = d.get(b"Range").map(|w| nums(doc, w)).unwrap_or_default(); + let range = if r.len() == 4 { [r[0], r[1], r[2], r[3]] } else { [-100.0, 100.0, -100.0, 100.0] }; + Space::Lab { white, range } + } + b"Indexed" | b"I" => { + let base = a.get(1).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Rgb); + let hival = a.get(2).and_then(|h| doc.resolve(h).as_f64()).unwrap_or(0.0).clamp(0.0, 255.0) as usize; + let table = match a.get(3).map(|t| doc.resolve(t)) { + Some(t) => match &*t { + Object::String(s) => s.bytes.clone(), + Object::Stream(st) => st.decoded().unwrap_or_default(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + Space::Indexed { base: Box::new(base), hival, table } + } + b"Separation" | b"DeviceN" => { + // The `/None` colorant paints nothing (ISO 32000-2, 8.6.6.4): given a colour + // of its own it would appear, so it stays what it is, as a pattern does. + let is_none = |o: &Object| doc.resolve(o).as_name() == Some(b"None"); + let none = a.get(1).is_some_and(|names| { + is_none(names) || doc.resolve(names).as_array().is_some_and(|names| !names.is_empty() && names.iter().all(is_none)) + }); + if none { + return Space::Pattern; + } + let inputs = if family == b"Separation" { + 1 + } else { + a.get(1).map(|n| doc.resolve(n)).and_then(|n| n.as_array().map(Vec::len)).unwrap_or(1).max(1) + }; + let alt = a.get(2).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Gray); + let exp = a.get(3).map(|f| doc.resolve(f)).and_then(|f| { + let d = match &*f { + Object::Dict(d) => d.clone(), + Object::Stream(s) => s.dict.clone(), + _ => return None, + }; + if d.int(b"FunctionType") != Some(2) { + return None; + } + let c0 = d.get(b"C0").map(|v| nums(doc, v)).unwrap_or_else(|| vec![0.0]); + let c1 = d.get(b"C1").map(|v| nums(doc, v)).unwrap_or_else(|| vec![1.0]); + let n = d.get(b"N").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(1.0); + Some((c0, c1, n)) + }); + Space::Tint { inputs, alt: Box::new(alt), exp } + } + b"Pattern" => Space::Pattern, + b"DeviceGray" | b"G" => Space::Gray, + b"DeviceRGB" | b"RGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + _ => Space::Gray, + } + } + _ => Space::Gray, + } +} + +fn clamp01(v: f64) -> f64 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// CMYK to sRGB, the usual multiplicative approximation (no ICC profile). +pub(crate) fn cmyk(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] { + let (c, m, y, k) = (clamp01(c), clamp01(m), clamp01(y), clamp01(k)); + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// CIE L*a*b* (with the dictionary's white point) to sRGB, through XYZ and the D65 sRGB matrix. +fn lab(white: [f64; 3], l: f64, a: f64, b: f64) -> [f64; 3] { + let m = (l + 16.0) / 116.0; + let (ll, mm, nn) = (m + a / 500.0, m, m - b / 200.0); + let g = |x: f64| if x >= 6.0 / 29.0 { x * x * x } else { 108.0 / 841.0 * (x - 4.0 / 29.0) }; + let (xw, yw, zw) = (white[0].max(1e-6), white[1].max(1e-6), white[2].max(1e-6)); + let (x, y, z) = (xw * g(ll), yw * g(mm), zw * g(nn)); + // Scale the white point to D65 before the sRGB matrix (a plain von Kries over XYZ). + let (x, y, z) = (x * 0.9505 / xw, y / yw, z * 1.089 / zw); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z]; + lin.map(|c| { + let c = clamp01(c); + if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// The sRGB of `values` in `space` (missing values read as 0; a pattern reads as black). +pub(crate) fn rgb(space: &Space, values: &[f64]) -> [f64; 3] { + let v = |i: usize| values.get(i).copied().unwrap_or(0.0); + match space { + Space::Gray => { + let g = clamp01(v(0)); + [g, g, g] + } + Space::Rgb => [clamp01(v(0)), clamp01(v(1)), clamp01(v(2))], + Space::Cmyk => cmyk(v(0), v(1), v(2), v(3)), + Space::Lab { white, range } => { + let l = v(0).clamp(0.0, 100.0); + let a = v(1).clamp(range[0].min(range[1]), range[1].max(range[0])); + let b = v(2).clamp(range[2].min(range[3]), range[3].max(range[2])); + lab(*white, l, a, b) + } + Space::Indexed { base, hival, table } => { + let n = base.components().max(1); + let i = (v(0).max(0.0).round() as usize).min(*hival); + let vals: Vec = (0..n).map(|k| table.get(i * n + k).map(|b| *b as f64 / 255.0).unwrap_or(0.0)).collect(); + // Lab tables hold the L*, a*, b* range in bytes; the others are 0..1 already. + match &**base { + Space::Lab { range, .. } => { + let scaled = [ + vals[0] * 100.0, + range[0] + vals.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]), + range[2] + vals.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]), + ]; + rgb(base, &scaled) + } + _ => rgb(base, &vals), + } + } + Space::Tint { inputs, alt, exp } => match exp { + Some((c0, c1, n)) => { + let t = clamp01(v(0)); + let k = if *n == 1.0 { t } else { t.powf(*n) }; + let out: Vec = c0.iter().zip(c1.iter()).map(|(a, b)| a + k * (b - a)).collect(); + rgb(alt, &out) + } + // No usable transform: the darkness of the strongest tint, in the alternate space's ink. + None => { + let t = (0..*inputs).map(v).fold(0.0, f64::max); + let g = 1.0 - clamp01(t); + [g, g, g] + } + }, + Space::Pattern => [0.0, 0.0, 0.0], + } +} diff --git a/crates/edit/src/convert_colours.rs b/crates/edit/src/convert_colours.rs new file mode 100644 index 0000000000..7134e537d8 --- /dev/null +++ b/crates/edit/src/convert_colours.rs @@ -0,0 +1,492 @@ +//! Convert colours (Acrobat's Print production ▸ Convert Colors): every colour +//! operator in the page content and the artwork it draws is rewritten in a target space - +//! grayscale, RGB or CMYK - by the standard device formulas (no ICC profile is applied), black +//! preserved on request; pictures are re-encoded through a helper the caller supplies. +//! +//! This is not a prepress separation. No ICC profile, rendering intent or output intent is +//! used, so the numbers are not calibrated for any press, and the catalog's `/OutputIntents` +//! stay as they were. A PDF/A or PDF/X file may no longer conform afterwards, and still says it +//! does: nothing here changes what it declares (the `doc_convert_colours` tool lists what it +//! declared; `standards_remove_id` takes that off). +//! +//! Spot colours are converted, not kept: Separation and DeviceN through a Type 2 tint transform +//! into their alternate space, any other tint transform as a gray of the strongest tint's +//! darkness (the hue is lost). `/All` converts like any other; `/None` paints nothing and stays. +//! Patterns, shadings, inline images and transparency group spaces stay as they are. +//! +//! Soft masks are not touched: a picture keeps the one it has. The helper leaves a picture alone +//! when it cannot re-encode it (indexed, CMYK, not 8-bit, an explicit mask, a matted soft mask, +//! JPEG 2000 ...); those that are not in the target already are counted in +//! [`Converted::images_left`]. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use std::collections::{HashMap, HashSet}; + +use crate::colour::{Space, rgb, space_of}; +use crate::page_streams::PageStreams; +use crate::{EditError, check, page_list}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColourTarget { + Gray, + Rgb, + Cmyk, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColours { + pub target: ColourTarget, + /// Pure black stays 100% K (or gray 0) rather than a mix. + pub preserve_black: bool, + /// Re-encode pictures too (through the caller's helper). + pub images: bool, + /// The appearance streams of the pages' comments and form fields too. + pub annotations: bool, +} + +impl Default for ConvertColours { + fn default() -> Self { + Self { target: ColourTarget::Gray, preserve_black: true, images: true, annotations: false } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Converted { + /// Colour operators rewritten. + pub colours: usize, + /// Pictures re-encoded. + pub images: usize, + /// Pictures not in the target space that could not be re-encoded and are left as they were. + pub images_left: usize, + /// Pages on which something changed. + pub pages: usize, +} + +/// Re-encode one image stream in the target space, if it can be: the caller's (the optimizer +/// knows how to decode and encode pictures; the edit crate does not). +pub type ImageRecolour<'a> = dyn FnMut(&Document, &Stream, ColourTarget) -> Option + 'a; + +fn luma(c: [f64; 3]) -> f64 { + (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]).clamp(0.0, 1.0) +} + +/// sRGB → the target's components. +pub fn components(target: ColourTarget, c: [f64; 3], preserve_black: bool) -> Vec { + let black = c.iter().all(|v| *v < 0.02); + match target { + ColourTarget::Gray => vec![if black && preserve_black { 0.0 } else { luma(c) }], + ColourTarget::Rgb => c.to_vec(), + ColourTarget::Cmyk => { + if black && preserve_black { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let k = 1.0 - c.iter().cloned().fold(0.0, f64::max); + if k >= 0.999 { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let f = |v: f64| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0); + vec![f(c[0]), f(c[1]), f(c[2]), k] + } + } +} + +fn op_for(target: ColourTarget, stroke: bool) -> &'static str { + match (target, stroke) { + (ColourTarget::Gray, false) => "g", + (ColourTarget::Gray, true) => "G", + (ColourTarget::Rgb, false) => "rg", + (ColourTarget::Rgb, true) => "RG", + (ColourTarget::Cmyk, false) => "k", + (ColourTarget::Cmyk, true) => "K", + } +} + +/// Whether a `cs` operand names DeviceCMYK itself (directly or through a `/ColorSpace` +/// resource), whose initial colour is black, unlike an ICC CMYK space's all-zero white. +fn device_cmyk(doc: &Document, operand: &Object, cs_res: &Dict) -> bool { + let is_device = |o: &Object| match &*doc.resolve(o) { + Object::Name(n) => matches!(n.as_slice(), b"DeviceCMYK" | b"CMYK"), + Object::Array(a) => a.len() == 1 && a.first().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceCMYK" | b"CMYK")), + _ => false, + }; + match operand.as_name() { + Some(n) => is_device(operand) || cs_res.get(n).is_some_and(is_device), + None => is_device(operand), + } +} + +fn r3(v: f64) -> Object { + Object::Real((v * 1000.0).round() / 1000.0) +} + +/// The current fill and stroke spaces (None = a device space set by g/rg/k, which the +/// converted operator sets again), with the q/Q stack: what one content stream leaves for the +/// next on the same page. +#[derive(Clone, Debug, Default)] +struct State { + fill: Option, + stroke: Option, + stack: Vec<(Option, Option)>, +} + +/// Rewrite one content stream's colour operators, starting from `state` and leaving in it the +/// state at the stream's end. `cs_res` is its `/ColorSpace` resource dictionary. Returns the +/// new bytes when anything changed, and how many operators. +fn rewrite(data: &[u8], state: &mut State, doc: &Document, cs_res: &Dict, settings: &ConvertColours) -> (Option>, usize) { + let mut ops: Vec = parse(data).ops; + let mut count = 0usize; + let mut out: Vec = Vec::with_capacity(ops.len()); + let State { fill, stroke, stack } = state; + let numbers = |op: &Op| -> Vec { op.operands.iter().filter_map(Object::as_f64).collect() }; + let convert = |c: [f64; 3], is_stroke: bool| -> Op { + Op::new(op_for(settings.target, is_stroke), components(settings.target, c, settings.preserve_black).into_iter().map(r3).collect()) + }; + for op in ops.drain(..) { + match op.op.as_slice() { + b"q" => { + stack.push((fill.clone(), stroke.clone())); + if stack.len() > 512 { + stack.remove(0); + } + out.push(op); + } + b"Q" => { + if let Some((f, s)) = stack.pop() { + *fill = f; + *stroke = s; + } + out.push(op); + } + b"cs" | b"CS" => { + let is_stroke = op.op.as_slice() == b"CS"; + let space = op.operands.first().map(|o| space_of(doc, o, cs_res, 0)).unwrap_or(Space::Gray); + let pattern = space == Space::Pattern; + if is_stroke { + *stroke = Some(space); + } else { + *fill = Some(space); + } + if pattern { + // Patterns stay what they are (their own content is not converted here). + out.push(op); + } else { + // The space's initial colour, now in the target: black (a tint at full, + // DeviceCMYK's 0 0 0 1, ISO 32000-1 8.6.5.5). + let space = if is_stroke { stroke.clone() } else { fill.clone() }.unwrap_or(Space::Gray); + let initial = match space { + Space::Tint { .. } => rgb(&space, &[1.0; 8]), + Space::Cmyk if op.operands.first().is_some_and(|o| device_cmyk(doc, o, cs_res)) => rgb(&space, &[0.0, 0.0, 0.0, 1.0]), + _ => rgb(&space, &[]), + }; + out.push(convert(initial, is_stroke)); + count += 1; + } + } + b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = match op.op.to_ascii_lowercase().as_slice() { + b"g" => Space::Gray, + b"rg" => Space::Rgb, + _ => Space::Cmyk, + }; + let already = matches!( + (settings.target, &space), + (ColourTarget::Gray, Space::Gray) | (ColourTarget::Rgb, Space::Rgb) | (ColourTarget::Cmyk, Space::Cmyk) + ); + if is_stroke { + *stroke = None; + } else { + *fill = None; + } + if already { + out.push(op); + } else { + let c = rgb(&space, &numbers(&op)); + out.push(convert(c, is_stroke)); + count += 1; + } + } + b"sc" | b"scn" | b"SC" | b"SCN" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = if is_stroke { stroke.clone() } else { fill.clone() }; + match space { + Some(Space::Pattern) | None if op.operands.iter().any(|o| matches!(o, Object::Name(_))) => out.push(op), + None => { + // sc after g/rg/k: the device space those set; read the numbers by count. + let n = numbers(&op); + let space = match n.len() { + 1 => Space::Gray, + 4 => Space::Cmyk, + _ => Space::Rgb, + }; + out.push(convert(rgb(&space, &n), is_stroke)); + count += 1; + } + Some(Space::Pattern) => out.push(op), + Some(space) => { + out.push(convert(rgb(&space, &numbers(&op)), is_stroke)); + count += 1; + } + } + } + _ => out.push(op), + } + } + ((count > 0).then(|| serialize_ops(&out)), count) +} + +/// Whether `space`'s colours are `target`'s (an indexed space's are its base's). +fn in_target(space: &Space, target: ColourTarget) -> bool { + match (space, target) { + (Space::Gray, ColourTarget::Gray) | (Space::Rgb, ColourTarget::Rgb) | (Space::Cmyk, ColourTarget::Cmyk) => true, + (Space::Indexed { base, .. }, _) => in_target(base, target), + _ => false, + } +} + +/// Whether the picture `s` is outside `target`: a stencil mask has no colours of its own (it +/// paints with the fill colour, which is converted). +fn needs_converting(doc: &Document, s: &Stream, target: ColourTarget) -> bool { + let stencil = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); + !stencil && !s.dict.get(b"ColorSpace").is_some_and(|cs| in_target(&space_of(doc, cs, &Dict::default(), 0), target)) +} + +fn stream_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +fn cs_resources(doc: &Document, resources: Option<&Dict>) -> Dict { + resources.and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default() +} + +/// What a run keeps across pages: each XObject already handled, and the object that now +/// stands for it (itself, or the converted copy made because a page not being converted draws +/// it too); and the XObjects those other pages draw. +struct Run { + done: HashMap, + shared: HashSet, +} + +/// The XObjects (forms and images, and those the forms draw), and appearance streams, that +/// the pages not in `pages` draw: converting one of those in place would change those pages. +fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut todo: Vec = Vec::new(); + let xobjects_of = |res: Option<&Object>, todo: &mut Vec| { + if let Some(xo) = res + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x))) + .and_then(|x| x.as_dict().cloned()) + { + todo.extend(xo.iter().map(|(_, o)| o.clone())); + } + }; + for (i, page) in page_list(doc).into_iter().enumerate() { + if pages.contains(&i) { + continue; + } + xobjects_of(page.dict.get(b"Resources"), &mut todo); + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots { + let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue }; + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())), + _ => todo.push(v.clone()), + } + } + } + } + while let Some(o) = todo.pop() { + let Some(r) = o.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let res = match &*doc.get(r) { + Object::Stream(s) => s.dict.get(b"Resources").cloned(), + _ => None, + }; + xobjects_of(res.as_ref(), &mut todo); + } + seen +} + +/// Store a converted XObject: in place, or, when another page draws `r` too, as a new object +/// the caller points to instead. Returns the object that now stands for `r`. +fn put(doc: &mut Document, run: &mut Run, r: ObjRef, obj: Object) -> ObjRef { + let to = if run.shared.contains(&r) { + doc.add(obj) + } else { + doc.set(r, obj); + r + }; + run.done.insert(r, to); + to +} + +/// Convert the XObjects of `resources`: forms rewritten (and their own XObjects after them), +/// images through `recolour`. Each object is converted once; one that a page not being +/// converted draws is converted as a copy, and the returned `/XObject` dictionary, when there +/// is one, points at the copies (the caller puts it in `resources`' place). When `may_copy` is +/// false the caller cannot do that, so such objects are left alone. +fn convert_xobjects( + doc: &mut Document, + resources: Option<&Dict>, + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, + run: &mut Run, + result: &mut Converted, + may_copy: bool, +) -> Option { + let xo = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + let mut repointed = xo.clone(); + let mut changed = false; + for (name, o) in xo.iter() { + let Some(r) = o.as_ref() else { continue }; + if let Some(&to) = run.done.get(&r) { + if to != r { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + continue; + } + if !may_copy && run.shared.contains(&r) { + continue; + } + // Marked before converting, so a form that draws itself ends. + run.done.insert(r, r); + let Some(s) = stream_of(doc, r) else { continue }; + let stored = match s.dict.name(b"Subtype") { + Some(b"Form") => { + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let cs = cs_resources(doc, res.as_ref()); + let (new, n) = match s.decoded() { + Ok(data) => rewrite(&data, &mut State::default(), doc, &cs, settings), + Err(_) => (None, 0), + }; + let inner = convert_xobjects(doc, res.as_ref(), settings, recolour, run, result, true); + if new.is_none() && inner.is_none() { + None + } else { + let mut form = s.clone(); + if let Some(xo) = inner { + let mut res = res.unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + if let Some(bytes) = new { + let mut dict = form.dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms"] { + dict.remove(k); + } + form = Stream::flate(dict, &bytes); + result.colours += n; + } + Some(put(doc, run, r, Object::Stream(form))) + } + } + Some(b"Image") if settings.images => match recolour(doc, &s, settings.target) { + Some(new) => { + result.images += 1; + Some(put(doc, run, r, Object::Stream(new))) + } + None => { + result.images_left += usize::from(needs_converting(doc, &s, settings.target)); + None + } + }, + _ => None, + }; + if let Some(to) = stored + && to != r + { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + } + changed.then_some(repointed) +} + +/// Convert the colours on `pages` (0-based). An object that pages not in `pages` also draw is +/// converted as a copy for these pages, so the others keep their colours. +pub fn convert_colours( + doc: &mut Document, + pages: &[usize], + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, +) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + let mut result = Converted::default(); + let mut run = Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages) }; + for &i in pages { + let Some(page) = page_list(doc).get(i).cloned() else { continue }; + let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let cs = cs_resources(doc, resources.as_ref()); + let before = (result.colours, result.images); + // Each content stream on its own (state carries across them); a page with a stream + // that cannot be read keeps its content as it is. + if let Some(streams) = PageStreams::read(doc, &page.dict) { + let mut state = State::default(); + let mut new: Vec>> = Vec::with_capacity(streams.data.len()); + for data in &streams.data { + let (bytes, n) = rewrite(data, &mut state, doc, &cs, settings); + result.colours += n; + new.push(bytes); + } + streams.write(doc, page.obj, new)?; + } + if let Some(xo) = convert_xobjects(doc, resources.as_ref(), settings, recolour, &mut run, &mut result, true) { + // This page's own resources point at the copies; a resource dictionary other pages + // share is not changed. + let mut res = resources.clone().unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + if settings.annotations { + let annots: Vec = page + .dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|a| a.as_ref()) + .collect(); + for a in annots { + let Some(ap) = + doc.get(a).as_dict().and_then(|d| d.get(b"AP").cloned()).map(|ap| doc.resolve(&ap)).and_then(|ap| ap.as_dict().cloned()) + else { + continue; + }; + // Every appearance stream (and the states of a /N dictionary) as a form. + let mut streams: Vec = Vec::new(); + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Stream(_) => streams.extend(v.as_ref()), + Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())), + _ => {} + } + } + let mut fake = Dict::new(); + let mut xo = Dict::new(); + for (k, r) in streams.iter().enumerate() { + xo.set(format!("A{k}").into_bytes(), Object::Ref(*r)); + } + fake.set(b"XObject".to_vec(), Object::Dict(xo)); + // An appearance stream other pages draw is left alone (there is no resource + // dictionary here to point at a copy). + convert_xobjects(doc, Some(&fake), settings, recolour, &mut run, &mut result, false); + } + } + if (result.colours, result.images) != before { + result.pages += 1; + } + } + Ok(result) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2d508f6878..d0f6d2c863 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -64,8 +64,13 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +pub mod printer_marks; +pub use printer_marks::{PrinterMarks, add_printer_marks}; +mod colour; +pub mod convert_colours; pub mod hairlines; mod page_streams; +pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours}; pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines}; pub mod added; pub use added::{ diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs index 2623da88ba..22530c8836 100644 --- a/crates/edit/src/mark_transaction_tests.rs +++ b/crates/edit/src/mark_transaction_tests.rs @@ -121,6 +121,7 @@ fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, rep MarkKind::Background => { add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) } + MarkKind::PrinterMarks => Ok(()), } } @@ -172,6 +173,7 @@ fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_re MarkKind::HeaderFooter => "(New header)".to_string(), MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + MarkKind::PrinterMarks => String::new(), }; apply_replacement(&mut doc, kind, source, true).unwrap(); let doc = reopen(&doc); diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 6c88fb2bf0..d772c48f4b 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -77,6 +77,8 @@ pub enum MarkKind { HeaderFooter, Watermark, Background, + /// Printer marks: trim, bleed, registration, colour bars, page information. + PrinterMarks, } impl MarkKind { @@ -85,6 +87,7 @@ impl MarkKind { MarkKind::HeaderFooter => "HeaderFooter", MarkKind::Watermark => "Watermark", MarkKind::Background => "Background", + MarkKind::PrinterMarks => "PrinterMarks", } } } diff --git a/crates/edit/src/printer_marks.rs b/crates/edit/src/printer_marks.rs new file mode 100644 index 0000000000..a247f65d30 --- /dev/null +++ b/crates/edit/src/printer_marks.rs @@ -0,0 +1,340 @@ +//! Printer marks (Acrobat's Print production ▸ Add Printer Marks): trim and +//! bleed marks, registration targets, colour bars and page information drawn outside the trim +//! box, as a page mark like a header, so they can be removed or replaced. The page is widened +//! when asked and the marks would otherwise fall outside it; the trim box stays where it is. +//! +//! Lines, targets and the information line are in registration colour, the `/All` Separation +//! (ISO 32000-2, 8.6.6.4), so they print on every plate, spot plates too; viewers show its +//! four-ink alternate. The colour bars are process inks and stay DeviceCMYK. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_fonts::{literal, win_ansi}; + +use crate::marks::{MarkKind, n}; +use crate::place::{END, add_resources, begin, check, page_list, place}; +use crate::{EditError, remove_marks}; + +/// Which marks, and how heavy. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarks { + pub trim: bool, + pub bleed: bool, + pub registration: bool, + pub color_bars: bool, + pub page_info: bool, + /// Line weight in points (Acrobat: 0.25, 0.5, 1). + pub line_weight: f64, + /// Widen the page by this many points on each side (at least what the marks need) when + /// they would fall outside it; `None` leaves the page and clips the marks. + pub make_room: Option, + /// What the page information line says before the page number (the file's name). + pub label: String, + /// The date on the page information line. + pub date: String, +} + +impl Default for PrinterMarks { + fn default() -> Self { + Self { + trim: true, + bleed: true, + registration: true, + color_bars: true, + page_info: true, + line_weight: 0.25, + make_room: Some(36.0), + label: String::new(), + date: String::new(), + } + } +} + +/// How far a mark reaches beyond the trim box: a gap, then the mark itself. +const GAP: f64 = 6.0; +const LENGTH: f64 = 18.0; +/// Registration targets sit this far beyond the trim box, centred on each side. +const TARGET_OFFSET: f64 = 16.0; +const TARGET_RADIUS: f64 = 4.0; +const BAR: f64 = 10.0; +const INFO_SIZE: f64 = 7.0; +/// The colour space resource holding registration colour. +const REGISTRATION: &str = "PCReg"; +/// The largest page side PDF viewers take (Annex C of ISO 32000-1 lists it as Acrobat's limit). +const MAX_PAGE_SIDE: f64 = 14_400.0; + +/// `[/Separation /All /DeviceCMYK ]`: full tint is all four process inks. +fn registration() -> Object { + let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), reals(&[0.0, 1.0])); + tint.set(b"C0".to_vec(), reals(&[0.0; 4])); + tint.set(b"C1".to_vec(), reals(&[1.0; 4])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK"), Object::Dict(tint)]) +} + +impl PrinterMarks { + /// The line weights a run accepts, in points. + pub const WEIGHTS: std::ops::RangeInclusive = 0.1..=4.0; + /// The most a run widens a page by on each side at the caller's asking, in points. + pub const MAX_ROOM: f64 = 288.0; + + fn any(&self) -> bool { + self.trim || self.bleed || self.registration || self.color_bars || self.page_info + } + + /// How far beyond the trim box the chosen marks reach. + pub fn reach(&self) -> f64 { + let mut r: f64 = 0.0; + if self.trim || self.bleed { + r = r.max(GAP + LENGTH); + } + if self.registration { + r = r.max(TARGET_OFFSET + TARGET_RADIUS + 2.0); + } + if self.color_bars || self.page_info { + r = r.max(GAP + BAR + 6.0); + } + r + } + + /// The marks' content for a page whose trim box is `trim` and bleed box `bleed` (user + /// space), `page` of `count` (1-based). + pub fn content(&self, trim: [f64; 4], bleed: [f64; 4], page: usize, count: usize) -> String { + let w = self.line_weight.clamp(*Self::WEIGHTS.start(), *Self::WEIGHTS.end()); + // Registration colour: every plate. + let mut c = format!("/{REGISTRATION} CS 1 SCN /{REGISTRATION} cs 1 scn {} w 0 J\n", n(w)); + let [x0, y0, x1, y1] = trim; + let line = |c: &mut String, ax: f64, ay: f64, bx: f64, by: f64| { + c.push_str(&format!("{} {} m {} {} l S\n", n(ax), n(ay), n(bx), n(by))); + }; + // Crop (trim) marks: at each corner, outside the box, along both edges. + let corner_marks = |c: &mut String, b: [f64; 4]| { + let [bx0, by0, bx1, by1] = b; + for (x, dir) in [(bx0, -1.0), (bx1, 1.0)] { + for y in [by0, by1] { + // Vertical mark beyond the top or bottom edge. + let (ya, yb) = if y == by0 { (y - GAP, y - GAP - LENGTH) } else { (y + GAP, y + GAP + LENGTH) }; + line(c, x, ya, x, yb); + // Horizontal mark beyond the left or right edge. + line(c, x + dir * GAP, y, x + dir * (GAP + LENGTH), y); + } + } + }; + if self.trim { + corner_marks(&mut c, trim); + } + if self.bleed && bleed != trim { + c.push_str(&format!("[{} {}] 0 d\n", n(w * 4.0), n(w * 4.0))); + corner_marks(&mut c, bleed); + c.push_str("[] 0 d\n"); + } + if self.registration { + let r = TARGET_RADIUS; + let k = 0.5523 * r; + let target = |c: &mut String, cx: f64, cy: f64| { + c.push_str(&format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S\n", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + )); + line(c, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(c, cx, cy - r * 1.6, cx, cy + r * 1.6); + }; + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + target(&mut c, mx, y1 + TARGET_OFFSET); + target(&mut c, mx, y0 - TARGET_OFFSET); + target(&mut c, x0 - TARGET_OFFSET, my); + target(&mut c, x1 + TARGET_OFFSET, my); + } + if self.color_bars { + // Above the top edge, from the left: C, M, Y, K at 100%, then 75, 50, 25% K. + let y = y1 + GAP + 2.0; + let x = x0 + LENGTH + GAP + 12.0; + let swatches: [(f64, f64, f64, f64); 7] = [ + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + (0.0, 0.0, 0.0, 0.75), + (0.0, 0.0, 0.0, 0.5), + (0.0, 0.0, 0.0, 0.25), + ]; + for (i, (cy, m, yl, k)) in swatches.iter().enumerate() { + c.push_str(&format!("{} {} {} {} k {} {} {} {} re f\n", n(*cy), n(*m), n(*yl), n(*k), n(x + i as f64 * BAR), n(y), n(BAR), n(BAR))); + } + c.push_str(&format!("/{REGISTRATION} cs 1 scn\n")); + } + if self.page_info { + let mut info = String::new(); + if !self.label.trim().is_empty() { + info.push_str(self.label.trim()); + info.push_str(" "); + } + info.push_str(&format!("{page} of {count}")); + if !self.date.trim().is_empty() { + info.push_str(" "); + info.push_str(self.date.trim()); + } + let info: String = info.chars().filter(|ch| pdfcraft_fonts::win_ansi_encodable(*ch)).collect(); + c.push_str(&format!("BT /PCHelv {} Tf 1 0 0 1 {} {} Tm ", n(INFO_SIZE), n(x0 + LENGTH + GAP + 12.0), n(y0 - GAP - BAR))); + c.push_str(&ascii_literal(&literal(&win_ansi(&info)))); + c.push_str(" Tj ET\n"); + } + c + } +} + +/// A PDF literal string made of WinAnsi bytes, as ASCII text: bytes from 0x80 up are written as +/// octal escapes, so they reach the content stream unchanged instead of being replaced when the +/// bytes are turned into a `String`. +fn ascii_literal(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + if b < 0x80 { + out.push(char::from(b)); + } else { + out.push_str(&format!("\\{b:03o}")); + } + } + out +} + +/// How far the chosen marks reach beyond the trim box on each side (left, bottom, right, top): +/// `reach`, or more where the bleed marks, which start at the bleed box, go further out. +fn side_reach(marks: &PrinterMarks, trim: [f64; 4], bleed: [f64; 4]) -> [f64; 4] { + let base = marks.reach(); + if !(marks.bleed && bleed != trim) { + return [base; 4]; + } + let beyond = |bleed_edge: f64, trim_edge: f64| base.max((trim_edge - bleed_edge).abs() + GAP + LENGTH); + [beyond(bleed[0], trim[0]), beyond(bleed[1], trim[1]), beyond(bleed[2], trim[2]), beyond(bleed[3], trim[3])] +} + +/// Add printer marks to `pages` (0-based). With `replace`, marks already there are removed +/// first. Returns the pages that were widened (media or crop box) to make room. +pub fn add_printer_marks(doc: &mut Document, pages: &[usize], marks: &PrinterMarks, replace: bool) -> Result, EditError> { + let all = page_list(doc); + check(pages, all.len())?; + if !marks.any() { + return Err(EditError::Invalid("choose at least one kind of mark".into())); + } + if !PrinterMarks::WEIGHTS.contains(&marks.line_weight) { + let (low, high) = (PrinterMarks::WEIGHTS.start(), PrinterMarks::WEIGHTS.end()); + return Err(EditError::Invalid(format!("the line weight must be from {low} to {high} points"))); + } + if marks.make_room.is_some_and(|room| !(0.0..=PrinterMarks::MAX_ROOM).contains(&room)) { + return Err(EditError::Invalid(format!("the room to make must be from 0 to {} points", PrinterMarks::MAX_ROOM))); + } + if replace { + remove_marks(doc, pages, MarkKind::PrinterMarks)?; + } + let count = all.len(); + let mut widened = Vec::new(); + for &i in pages { + let [media, crop, bleed, trim, art] = pdfcraft_organize_boxes(doc, i)?; + let side = side_reach(marks, trim, bleed); + let needed = [trim[0] - side[0], trim[1] - side[1], trim[2] + side[2], trim[3] + side[3]]; + let within = |b: [f64; 4]| b[0] <= needed[0] && b[1] <= needed[1] && b[2] >= needed[2] && b[3] >= needed[3]; + // The crop box is what shows, so the marks need room inside it, not only inside the media box. + if !within(crop) + && let Some(room) = marks.make_room + { + let new_media = if within(media) { + None + } else { + let room = |s: f64| room.max(s); + Some([ + media[0].min(trim[0] - room(side[0])), + media[1].min(trim[1] - room(side[1])), + media[2].max(trim[2] + room(side[2])), + media[3].max(trim[3] + room(side[3])), + ]) + }; + if new_media.is_some_and(|m| m[2] - m[0] > MAX_PAGE_SIDE || m[3] - m[1] > MAX_PAGE_SIDE) { + return Err(EditError::Invalid(format!("page {} would be wider than the {MAX_PAGE_SIDE} points a PDF page can be", i + 1))); + } + // Without a wider media box, only the crop box grows, to the marks and no further than the media box. + let new_crop = match new_media { + Some(m) => m, + None => [ + crop[0].min(needed[0]).max(media[0]), + crop[1].min(needed[1]).max(media[1]), + crop[2].max(needed[2]).min(media[2]), + crop[3].max(needed[3]).min(media[3]), + ], + }; + let rect = |r: [f64; 4]| Object::Array(r.iter().map(|v| Object::Real(*v)).collect()); + let page = page_list(doc)[i].clone(); + doc.update_dict(page.obj, |d| { + if let Some(m) = new_media { + d.set(b"MediaBox".to_vec(), rect(m)); + } + // The crop box shows the marks too. The trim, bleed and art boxes default to the + // crop box and are clipped to the media box, so each is written out as it was in + // effect, or it would move with the wider boxes and a second run would widen the + // page again. + d.set(b"CropBox".to_vec(), rect(new_crop)); + for (key, value) in [(&b"BleedBox"[..], bleed), (&b"TrimBox"[..], trim), (&b"ArtBox"[..], art)] { + d.set(key.to_vec(), rect(value)); + } + })?; + widened.push(i); + } + let page = page_list(doc)[i].clone(); + let content = marks.content(trim, bleed, i + 1, count); + let body = format!("{}{}{}", begin(MarkKind::PrinterMarks, "PrinterMarks", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), content, END); + add_resources(doc, &page, None, Some(body.as_bytes()))?; + let page = page_list(doc)[i].clone(); + // add_resources gave the page resources of its own. + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut spaces = res.get(b"ColorSpace").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default(); + spaces.set(REGISTRATION.as_bytes().to_vec(), registration()); + res.set(b"ColorSpace".to_vec(), Object::Dict(spaces)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + let page = page_list(doc)[i].clone(); + place(doc, &page, MarkKind::PrinterMarks, body.into_bytes(), false)?; + } + Ok(widened) +} + +/// A page's boxes: media, crop, bleed, trim, art (each inherited or falling back as the +/// standard says), without depending on the organize crate. +fn pdfcraft_organize_boxes(doc: &Document, page: usize) -> Result<[[f64; 4]; 5], EditError> { + let p = page_list(doc).get(page).cloned().ok_or_else(|| EditError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = |k: &[u8]| -> Option<[f64; 4]> { + let a = p.dict.get(k).map(|o| doc.resolve(o))?; + let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + }; + let media = rect(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); + let clip = |r: [f64; 4]| [r[0].max(media[0]), r[1].max(media[1]), r[2].min(media[2]), r[3].min(media[3])]; + let crop = rect(b"CropBox").map(clip).unwrap_or(media); + let (bleed, trim, art) = (rect(b"BleedBox").map(clip), rect(b"TrimBox").map(clip), rect(b"ArtBox").map(clip)); + Ok([media, crop, bleed.unwrap_or(crop), trim.unwrap_or(crop), art.unwrap_or(crop)]) +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5d220d5592..d56240ee87 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9399,6 +9399,113 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Printer marks: drawn outside the trim box as a removable page mark; the page +/// is widened to make room when asked and the trim box stays; running again replaces them. +#[test] +fn printer_marks_are_drawn_outside_the_trim_box_and_removable() { + use crate::printer_marks::PrinterMarks; + let mut doc = fixture(); + let page_content_text = |doc: &Document, page: usize| String::from_utf8_lossy(&page_content_bytes(doc, page)).into_owned(); + let page = page_list(&doc)[0].clone(); + // A trim box inside a letter-size page, 36 pt in: room for the marks, bleed marks included + // (the bleed box is 9 pt out, so they reach 33 pt beyond the trim box), without widening. + doc.update_dict(page.obj, |d| { + d.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 612.0, 792.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"TrimBox".to_vec(), Object::Array([36.0, 36.0, 576.0, 756.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"BleedBox".to_vec(), Object::Array([27.0, 27.0, 585.0, 765.0].iter().map(|v| Object::Real(*v)).collect())); + }) + .unwrap(); + let marks = PrinterMarks { label: "Quote 1042.pdf".into(), date: "2026-10-13".into(), ..PrinterMarks::default() }; + let widened = add_printer_marks(&mut doc, &[0, 1], &marks, false).unwrap(); + assert_eq!(widened, vec![1], "page 2 has no trim box inside its media box, so it was widened"); + assert_eq!(marks_present(&doc), vec![MarkKind::PrinterMarks]); + let text = page_content_text(&doc, 0); + assert!(text.contains("/PCMark /PrinterMarks"), "{text}"); + // A trim mark at the lower-left corner: vertical below the box, horizontal to its left. + assert!(text.contains("36 30 m 36 12 l S") && text.contains("30 36 m 12 36 l S"), "{text}"); + assert!(text.contains("/PCReg CS 1 SCN /PCReg cs 1 scn"), "registration colour: {text}"); + assert!(!text.contains("1 1 1 1 K"), "not four process inks alone: {text}"); + assert!(text.contains("[1 1] 0 d") && text.contains("27 21 m 27 3 l S"), "dashed bleed marks: {text}"); + assert!(text.contains("1 0 0 0 k") && text.contains("0 0 0 0.25 k"), "colour bars: {text}"); + assert!(text.contains("(Quote 1042.pdf 1 of 3 2026-10-13) Tj"), "page information: {text}"); + // Page 2: the media box grew by 36 pt a side around its (whole-page) trim box. + let p2 = page_list(&doc)[1].clone(); + let media: Vec = p2.dict.get(b"MediaBox").and_then(|m| m.as_array()).unwrap().iter().filter_map(|v| v.as_f64()).collect(); + assert_eq!(media, vec![-36.0, -36.0, 636.0, 836.0]); + // Again with replace: still one set of marks per page; without replace, two. + add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 1); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 2); + assert_eq!(remove_marks(&mut doc, &[0, 1], MarkKind::PrinterMarks).unwrap(), 3); + assert!(marks_present(&doc).is_empty()); + // Nothing chosen, or a bad weight, is refused. + let none = PrinterMarks { trim: false, bleed: false, registration: false, color_bars: false, page_info: false, ..PrinterMarks::default() }; + assert!(add_printer_marks(&mut doc, &[0], &none, false).is_err()); + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err()); +} + +/// Convert colours: device, ICC-based and separation colours in page content +/// and forms become the target's operators; black is preserved; patterns stay; a second run +/// finds nothing. +#[test] +fn colours_are_converted_to_gray_rgb_and_cmyk() { + use crate::convert_colours::{ColourTarget, ConvertColours, convert_colours}; + let mut doc = fixture(); + let page = page_list(&doc)[0].clone(); + // An ICCBased RGB space and a spot colour (Separation to CMYK, Type 2 tint). + let mut icc = Dict::new(); + icc.set(b"N".to_vec(), Object::Int(3)); + let icc_ref = doc.add(Object::Stream(Stream::flate(icc, b"not a real profile"))); + let mut fun = Dict::new(); + fun.set(b"FunctionType".to_vec(), Object::Int(2)); + fun.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])); + fun.set(b"C0".to_vec(), Object::Array(vec![Object::Int(0); 4])); + fun.set(b"C1".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(1), Object::Int(0)])); + fun.set(b"N".to_vec(), Object::Int(1)); + let mut cs = Dict::new(); + cs.set(b"CS0".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(icc_ref)])); + cs.set(b"Spot".to_vec(), Object::Array(vec![Object::name("Separation"), Object::name("Red"), Object::name("DeviceCMYK"), Object::Dict(fun)])); + let mut res = Dict::new(); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + let content = b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S /CS0 cs 0 1 0 scn 0 0 20 20 re f /Spot cs 1 scn 30 30 20 20 re f 0 g 60 60 10 10 re f 0.5 g 70 70 5 5 re f"; + let c = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(c)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + let mut no_images = |_: &Document, _: &Stream, _: ColourTarget| None; + let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::>().join(" "); + // Grayscale. + let mut gray = doc.clone(); + let done = convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!((done.colours, done.pages), (6, 1), "{done:?}"); + let t = flat(&page_content_bytes(&gray, 0)); + assert!(t.contains("0.299 g 10 10 50 50 re f"), "red → its luma: {t}"); + assert!(t.contains("0.114 G 0 0 m 100 100 l S"), "blue stroke: {t}"); + assert!(t.contains("0 g 0 1 0 scn") || t.contains("0.587 g 0 0 20 20 re f"), "ICC green through its space: {t}"); + assert!(t.contains("0.587 g 0 0 20 20 re f"), "{t}"); + assert!(t.contains("0.299 g 30 30 20 20 re f"), "the spot's tint through its CMYK alternate (0 1 1 0 = red): {t}"); + assert!(t.contains("0 g 60 60 10 10 re f") && t.contains("0.5 g 70 70 5 5 re f"), "gray stays gray: {t}"); + assert!(!t.contains("/CS0 cs") && !t.contains("/Spot cs"), "named spaces are gone: {t}"); + assert_eq!(convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0, "nothing left"); + // CMYK with black preserved; without, black is still 100% K by the formula. + let mut cmyk = doc.clone(); + let s = ConvertColours { target: ColourTarget::Cmyk, ..ConvertColours::default() }; + convert_colours(&mut cmyk, &[0], &s, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&cmyk, 0)); + assert!(t.contains("0 1 1 0 k 10 10 50 50 re f"), "red: {t}"); + assert!(t.contains("0 0 0 1 k 60 60 10 10 re f"), "black preserved: {t}"); + assert!(t.contains("0 0 0 0.5 k 70 70 5 5 re f"), "gray 0.5 → 50% K: {t}"); + // RGB: the spot becomes its red. + let mut rgbd = doc.clone(); + convert_colours(&mut rgbd, &[0], &ConvertColours { target: ColourTarget::Rgb, ..ConvertColours::default() }, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&rgbd, 0)); + assert!(t.contains("1 0 0 rg 30 30 20 20 re f"), "{t}"); + assert!(t.contains("1 0 0 rg 10 10 50 50 re f"), "already RGB, kept: {t}"); +} + /// Fix hairlines: zero-width strokes and strokes thinner than the threshold on /// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider /// lines, and glyphs and patterns unless asked, are left alone. diff --git a/crates/edit/tests/it/convert_colours_cmyk_initial.rs b/crates/edit/tests/it/convert_colours_cmyk_initial.rs new file mode 100644 index 0000000000..277aaaaf20 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_cmyk_initial.rs @@ -0,0 +1,71 @@ +//! Convert colours: `/DeviceCMYK cs` starts at black (0 0 0 1, ISO 32000-1 8.6.5.5), so the +//! converted operator is black too, not the white that four zeros would be. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +fn page_with(content: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + &format!("<< >>stream:{content}"), + ]) +} + +#[test] +fn device_cmyk_starts_at_black() { + let mut doc = page_with("/DeviceCMYK cs 0 0 10 10 re f /DeviceCMYK CS 0 0 m 10 10 l S"); + let s = ConvertColours { preserve_black: false, ..ConvertColours::default() }; + let done = convert_colours(&mut doc, &[0], &s, &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + let t = stream_text(&doc, contents(&doc, 0)[0]); + assert!(t.contains("0 g 0 0 10 10 re f") && t.contains("0 G 0 0 m 10 10 l S"), "black, not white: {t}"); +} diff --git a/crates/edit/tests/it/convert_colours_shared.rs b/crates/edit/tests/it/convert_colours_shared.rs new file mode 100644 index 0000000000..bd373011d6 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_shared.rs @@ -0,0 +1,144 @@ +//! Convert colours on some pages leaves the others as they were: a form or picture they share +//! is converted as a copy for the pages being converted, and the resource dictionary they +//! share is not changed. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// Two pages sharing one resource dictionary (inherited from the page tree) whose form draws +/// red, and a form inside it that draws blue. +fn two_pages() -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] /Resources 5 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /XObject << /Fm1 7 0 R >> >>", + "<< >>stream:/Fm1 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm2 8 0 R >> >> >>stream:1 0 0 rg 0 0 10 10 re f /Fm2 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>stream:0 0 1 rg 0 0 5 5 re f", + ]) +} + +fn form_of(doc: &Document, page: usize, name: &[u8]) -> ObjRef { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap() +} + +fn inner_form(doc: &Document, outer: ObjRef) -> ObjRef { + match &*doc.get(outer) { + Object::Stream(s) => { + let res = doc.resolve(s.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(b"Fm2").and_then(Object::as_ref).unwrap() + } + _ => panic!("not a stream"), + } +} + +#[test] +fn a_shared_form_is_converted_as_a_copy() { + let mut doc = two_pages(); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + // The other page still draws the original forms, in their colours. + let kept = form_of(&doc, 1, b"Fm1"); + assert!(stream_text(&doc, kept).contains("1 0 0 rg 0 0 10 10 re f"), "{}", stream_text(&doc, kept)); + assert!(stream_text(&doc, inner_form(&doc, kept)).contains("0 0 1 rg 0 0 5 5 re f")); + // The converted page draws copies, the inner one too. + let copy = form_of(&doc, 0, b"Fm1"); + assert_ne!(copy, kept); + assert!(stream_text(&doc, copy).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, copy)); + assert!(stream_text(&doc, inner_form(&doc, copy)).contains("0.114 g 0 0 5 5 re f")); +} + +#[test] +fn converting_every_page_changes_the_forms_in_place() { + let mut doc = two_pages(); + let before = form_of(&doc, 0, b"Fm1"); + convert_colours(&mut doc, &[0, 1], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(form_of(&doc, 0, b"Fm1"), before); + assert_eq!(form_of(&doc, 1, b"Fm1"), before); + assert!(stream_text(&doc, before).contains("0.299 g 0 0 10 10 re f")); +} + +/// One page of `content` with `res` as its resources, followed by `more` objects from 5 up. +fn page_of(res: &str, content: &str, more: &[&str]) -> Document { + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources {res} >>"); + let content = format!("<< >>stream:{content}"); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", page.as_str(), content.as_str()]; + objs.extend_from_slice(more); + pdf(&objs) +} + +/// The `/None` colorant paints nothing; a colour of its own would make it appear. +#[test] +fn the_none_colorant_stays() { + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>"; + let res = format!( + "<< /ColorSpace << /S [/Separation /None /DeviceCMYK {tint}] /D [/DeviceN [/None /None] /DeviceCMYK {tint}] /Real [/Separation /Red /DeviceCMYK {tint}] >> >>" + ); + let content = "/S cs 1 scn 0 0 9 9 re f /D cs 1 1 scn 0 0 9 9 re f"; + let mut doc = page_of(&res, content, &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0); + assert_eq!(stream_text(&doc, ObjRef { num: 4, generation: 0 }), content); + let mut doc = page_of(&res, "/Real cs 1 scn 0 0 9 9 re f", &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 2, "a real spot is converted"); +} + +/// Pictures the helper cannot re-encode are counted, unless they are in the target already or +/// have no colours of their own. +#[test] +fn pictures_left_alone_are_counted() { + const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1"; + let indexed = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 0 ] >>stream:x"); + let gray_table = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceGray 0 <80>] >>stream:x"); + let gray = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace /DeviceGray >>stream:x"); + let stencil = format!("<< {IMAGE} /BitsPerComponent 1 /ImageMask true >>stream:x"); + let res = "<< /XObject << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >>"; + let mut doc = page_of(res, "/A Do /B Do /C Do /D Do", &[&indexed, &gray_table, &gray, &stencil]); + let mut none = doc.clone(); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().images_left, 1); + let s = ConvertColours { images: false, ..ConvertColours::default() }; + assert_eq!(convert_colours(&mut none, &[0], &s, &mut no_images).unwrap().images_left, 0, "pictures were not asked for"); +} diff --git a/crates/edit/tests/it/convert_colours_streams.rs b/crates/edit/tests/it/convert_colours_streams.rs new file mode 100644 index 0000000000..edad0a32e8 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_streams.rs @@ -0,0 +1,100 @@ +//! Convert colours rewrites each of a page's content streams on its own: a stream keeps its +//! dictionary (its `/PCMark` among it), unchanged streams stay as they are, the colour space +//! set in one stream holds in the next, and a page with an unreadable stream is left alone. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green). +fn page_with(streams: &[&str]) -> Document { + let refs: Vec = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << /CS0 [/Indexed /DeviceRGB 1 ] >> >> /Contents [{}] >>", + refs.join(" ") + ); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", &page]; + objs.extend_from_slice(streams); + pdf(&objs) +} + +#[test] +fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() { + let mut doc = page_with(&["<< >>stream:0.5 g 0 0 10 10 re f", "<< /PCMark /Redaction >>stream:1 0 0 rg 0 0 10 10 re f"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 1); + let after = contents(&doc, 0); + assert_eq!(after.len(), 2, "still two streams"); + assert_eq!(after[0], before[0], "the unchanged stream is the same object"); + let mark = match &*doc.get(after[1]) { + Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec), + _ => None, + }; + assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept"); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f")); +} + +#[test] +fn the_colour_space_carries_from_one_stream_to_the_next() { + // Index 0 of the space set in the first stream is red; read as a gray, it would be black. + let mut doc = page_with(&["<< >>stream:/CS0 cs", "<< >>stream:0 sc 0 0 10 10 re f"]); + convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + let after = contents(&doc, 0); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, after[1])); +} + +#[test] +fn a_page_with_an_unreadable_stream_is_left_alone() { + let mut doc = page_with(&["<< >>stream:1 0 0 rg 0 0 10 10 re f", "<< /Filter /DCTDecode >>stream:not a picture"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 0); + assert_eq!(contents(&doc, 0), before, "no stream is dropped"); +} diff --git a/crates/edit/tests/it/main.rs b/crates/edit/tests/it/main.rs new file mode 100644 index 0000000000..b64db49da1 --- /dev/null +++ b/crates/edit/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod convert_colours_cmyk_initial; +mod convert_colours_shared; +mod convert_colours_streams; diff --git a/crates/edit/tests/printer_marks_accented_label.rs b/crates/edit/tests/printer_marks_accented_label.rs new file mode 100644 index 0000000000..ccb8eb5861 --- /dev/null +++ b/crates/edit/tests/printer_marks_accented_label.rs @@ -0,0 +1,12 @@ +//! The page information line keeps accented letters: WinAnsi bytes from 0x80 up must reach the +//! content stream as escapes, not be replaced with U+FFFD. + +use pdfcraft_edit::PrinterMarks; + +#[test] +fn accented_label_is_written_as_winansi_escapes() { + let marks = PrinterMarks { label: "Café.pdf".into(), ..PrinterMarks::default() }; + let content = marks.content([0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 612.0, 792.0], 1, 1); + assert!(!content.contains('\u{FFFD}'), "no replacement character in the stream: {content:?}"); + assert!(content.contains("(Caf\\351.pdf 1 of 1)"), "é is the WinAnsi byte 0xE9 written as \\351: {content:?}"); +} diff --git a/crates/edit/tests/printer_marks_bleed_reach.rs b/crates/edit/tests/printer_marks_bleed_reach.rs new file mode 100644 index 0000000000..cb7590768c --- /dev/null +++ b/crates/edit/tests/printer_marks_bleed_reach.rs @@ -0,0 +1,50 @@ +//! Bleed marks start at the bleed box, so the room made for the marks counts the bleed too. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A 9 pt bleed: the bleed marks run out to 9 + 6 + 18 = 33 pt from the trim box, further than the +/// usual 24, so the crop box grows by that much. +#[test] +fn crop_box_grows_to_hold_the_bleed_marks() { + let mut doc = page_with(&[ + (b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), + (b"CropBox", [200.0, 200.0, 800.0, 800.0]), + (b"TrimBox", [200.0, 200.0, 800.0, 800.0]), + (b"BleedBox", [191.0, 191.0, 809.0, 809.0]), + ]); + let marks = PrinterMarks::default(); + assert_eq!(marks.reach(), 24.0); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"CropBox"), Some(vec![167.0, 167.0, 833.0, 833.0])); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} diff --git a/crates/edit/tests/printer_marks_boxes.rs b/crates/edit/tests/printer_marks_boxes.rs new file mode 100644 index 0000000000..d236c48197 --- /dev/null +++ b/crates/edit/tests/printer_marks_boxes.rs @@ -0,0 +1,108 @@ +//! Printer marks and the page boxes: the marks must show inside the crop box, and running +//! again must not move the trim box or grow the page. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +/// One empty page with these boxes set on it. +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A crop box cut to the trim inside a large media box: the marks fit the media box but +/// not the crop box, so the crop box grows to show them and the trim box stays. +#[test] +fn printer_marks_widen_crop_inside_media() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), (b"CropBox", [200.0, 200.0, 800.0, 800.0])]); + let marks = PrinterMarks::default(); + let reach = marks.reach(); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"MediaBox"), Some(vec![0.0, 0.0, 1000.0, 1000.0]), "the media box already had room"); + let crop = page_box(&doc, b"CropBox").unwrap(); + assert_eq!(crop, vec![200.0 - reach, 200.0 - reach, 800.0 + reach, 800.0 + reach]); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} + +/// No trim box given: it defaults to the crop box, which widening moves. Running again with +/// replace must keep the trim box and the page size from the first run. +#[test] +fn printer_marks_rerun_keeps_trim() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + let marks = PrinterMarks::default(); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + let media = page_box(&doc, b"MediaBox"); + let crop = page_box(&doc, b"CropBox"); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + assert_eq!(page_box(&doc, b"BleedBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + let widened = add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert!(widened.is_empty(), "the page already has room"); + assert_eq!(page_box(&doc, b"MediaBox"), media, "the page did not grow again"); + assert_eq!(page_box(&doc, b"CropBox"), crop); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); +} + +/// A trim box past the media box is clipped to it. Widening the media box must not let it +/// grow to what was written. +#[test] +fn printer_marks_keep_the_trim_box_in_effect() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 100.0, 100.0]), (b"TrimBox", [-20.0, -20.0, 120.0, 120.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 100.0, 100.0])); +} + +/// Line weights and room out of range are refused, not clamped, and nothing past the largest +/// page a PDF can have is written. +#[test] +fn printer_marks_refuse_numbers_out_of_range() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + for line_weight in [-1.0, 0.0, 0.05, 5.0, 10_000.0, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight, ..PrinterMarks::default() }, false).is_err(), "{line_weight}"); + } + for room in [-1.0, 289.0, 1e9, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { make_room: Some(room), ..PrinterMarks::default() }, false).is_err(), "{room}"); + } + let mut big = page_with(&[(b"MediaBox", [0.0, 0.0, 14_400.0, 1000.0])]); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks::default(), false).is_err()); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks { make_room: None, ..PrinterMarks::default() }, false).is_ok(), "left as it is"); +} + +/// Registration colour is the /All Separation, in the page's resources beside what it had. +#[test] +fn printer_marks_use_the_all_separation() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let res = res.as_dict().unwrap(); + assert!(res.get(b"Font").is_some()); + let spaces = doc.resolve(res.get(b"ColorSpace").unwrap()); + let cs = doc.resolve(spaces.as_dict().unwrap().get(b"PCReg").unwrap()); + let cs = cs.as_array().unwrap(); + assert_eq!(cs[..3], [Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK")]); + assert!(cs[3].as_dict().unwrap().int(b"FunctionType") == Some(2)); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e78786bf8b..1f757f98e4 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -409,9 +409,9 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("Output preview", "layers", "prepress.output_preview", Planned("M11")), item("Preflight", "file-check", "preflight.run", Planned("M11")), - item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), + item("Convert colours", "palette", "prepress.convert_colors", Ready), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), - item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), + item("Add printer marks", "ruler", "prepress.marks", Ready), item("Fix hairlines", "minus", "prepress.hairlines", Ready), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 01004182cf..1996bcd793 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -231,6 +231,16 @@ pub const COMMANDS: &[CommandSpec] = &[ // Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one; // a tab's right-click menu also splits right or down with any tab. c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"), + // A document can hide the toolbars, menu and panels (ISO 32000-2 Table 147); this brings them + // back for its tab, and with nothing hidden hides them. + c( + "view.toggle_interface", + "Show or hide toolbars and panels", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F8" }), + Document, + "panel-left", + ), // Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS, // where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and // also run from Combine files, which has no document open. @@ -406,6 +416,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"), + c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"), + c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"), c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index a5f7db979b..530f8787af 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -70,8 +70,9 @@ pub struct Document { /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see - /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The - /// interface shows it; tools return it. + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off, places + /// applied redactions left that still hold the removed text. The interface shows it; tools + /// return it. pub edit_notices: Vec, } @@ -86,6 +87,21 @@ pub enum EditNotice { /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look /// the same on white paper. BackdropsLeftOff { pages: Vec }, + /// Applying redactions left these places as they were, and they still hold the removed text + /// (bookmark titles, attachments, the comments that remain, XMP on other objects; see + /// [`RedactionElsewhere`](crate::RedactionElsewhere)). Only when one is counted, or the text + /// couldn't be searched for. + RedactionElsewhere(pdfcraft_redact::Elsewhere), + /// Applying redactions removed the document's XMP packet, which held the removed text and + /// said the file conforms to PDF/A or PDF/UA: each claim was written back in a packet of its + /// own, or lost (see [`RedactionIdentification`](crate::RedactionIdentification)). + RedactionIdentification(pdfcraft_redact::Identification), +} + +/// The standards whose identification `id` has as `claim`: "PDF/A", "PDF/UA" or both, joined. +pub(crate) fn standards_with(id: &pdfcraft_redact::Identification, claim: pdfcraft_redact::Claim) -> Option { + let names: Vec<&str> = [("PDF/A", id.pdfa), ("PDF/UA", id.pdfua)].into_iter().filter(|(_, c)| *c == claim).map(|(n, _)| n).collect(); + (!names.is_empty()).then(|| names.join(" and ")) } impl std::fmt::Display for EditNotice { @@ -100,10 +116,50 @@ impl std::fmt::Display for EditNotice { page_runs(pages) ) } + EditNotice::RedactionElsewhere(e) => elsewhere_text(e, f), + EditNotice::RedactionIdentification(id) => { + use pdfcraft_redact::Claim; + write!(f, "the document's XMP metadata held the redacted text and was removed")?; + if let Some(kept) = standards_with(id, Claim::Kept) { + write!(f, "; its {kept} identification was written back on its own")?; + } + match standards_with(id, Claim::Lost) { + Some(lost) => write!(f, "; its {lost} identification couldn't be kept, so the file no longer says it conforms to {lost}"), + None => Ok(()), + } + } } } } +/// [`EditNotice::RedactionElsewhere`] in words (English; the interface has its own wording). +fn elsewhere_text(e: &pdfcraft_redact::Elsewhere, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(format!("1 {one}")), + n => Some(format!("{n} {many}")), + }; + let places: Vec = [ + count(e.bookmarks, "bookmark title", "bookmark titles"), + count(e.attachments, "attachment", "attachments"), + count(e.comments, "comment", "comments"), + count(e.xmp, "other metadata stream", "other metadata streams"), + ] + .into_iter() + .flatten() + .collect(); + let list = match places.split_last() { + None => "no place counted".to_string(), + Some((last, [])) => last.clone(), + Some((last, rest)) => format!("{} and {last}", rest.join(", ")), + }; + if e.unsearched { + write!(f, "the redacted text couldn't be searched for, so {list} may still hold it (Remove hidden information removes them)") + } else { + write!(f, "the redacted text also appears in {list}, which redaction doesn't change (Remove hidden information removes them)") + } +} + /// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". pub(crate) fn page_runs(pages: &[usize]) -> String { let mut runs: Vec<(usize, usize)> = Vec::new(); @@ -190,6 +246,26 @@ pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos } impl Document { + /// The places the last edit's applied redactions left that still hold the removed text + /// ([`EditNotice::RedactionElsewhere`]), or `None` when there are none or the last edit + /// applied none. + pub fn redaction_elsewhere(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionElsewhere(e) => Some(*e), + _ => None, + }) + } + + /// What became of the PDF/A and PDF/UA identification when the last edit's applied + /// redactions removed the document's XMP packet ([`EditNotice::RedactionIdentification`]), + /// or `None` when no packet that made such a claim was removed. + pub fn redaction_identification(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionIdentification(id) => Some(*id), + _ => None, + }) + } + /// 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). diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 94b93c860f..1d5181bf65 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use crate::{ AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, - PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, - embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, - unrestricted, xfa, xfa_after_data_change, + PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, + decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, + open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -359,6 +359,14 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Print production ▸ Add printer marks: trim and bleed marks, registration targets, colour + /// bars and page information outside the trim box, as a page mark; the page may be widened + /// to make room (`settings.make_room`), so the page geometry is read again. + AddPrinterMarks { + pages: Vec, + settings: PrinterMarks, + replace: bool, + }, /// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on /// the page (after every transformation; zero-width lines always) get `settings.width`. FixHairlines { @@ -377,6 +385,16 @@ pub enum Edit { DeleteAttachment { key: String, }, + /// Print production ▸ Convert colours: every colour on the pages in grayscale, RGB or CMYK. + ConvertColours { + pages: Vec, + settings: pdfcraft_edit::ConvertColours, + }, + /// Change or clear only `/Desc` on a document-level attachment's file specification. + EditAttachmentDescription { + key: String, + description: Option, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 4262769278..be1cb1a0a4 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -31,6 +31,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Insert pages from {name} — fields renamed", "Attach {name}", "Delete attachment {key}", + "Edit attachment description", "Add bookmark", "Rename bookmark", "Delete bookmark", @@ -259,6 +260,8 @@ impl Edit { Edit::FixHairlines { .. } => Label::of("Fix hairlines"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::ConvertColours { .. } => Label::of("Convert colours"), + Edit::EditAttachmentDescription { .. } => Label::of("Edit attachment description"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::AutoTagFormFields => Label::of("Tag form fields"), @@ -307,6 +310,8 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::RemoveMarks { kind: MarkKind::PrinterMarks } => Label::of("Remove printer marks"), + Edit::AddPrinterMarks { .. } => Label::of("Add printer marks"), Edit::AddText { .. } => Label::of("Add text"), Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 455326753c..eb213f3f64 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -92,6 +92,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Err(EditError::NotPermitted("changing security")) } } + // Converting the appearances of comments and form fields changes them too, which + // content changes alone do not allow. + Edit::ConvertColours { settings, .. } if settings.annotations && !(p.modify() && p.annotate()) => { + Err(EditError::NotPermitted(if p.modify() { "comments" } else { "changes to the document" })) + } // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted // document it is a change of security (#3921). Edit::ConvertPdfA { .. } => { @@ -103,6 +108,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } + | Edit::AddPrinterMarks { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } @@ -138,6 +144,8 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::FixHairlines { .. } | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } + | Edit::ConvertColours { .. } + | Edit::EditAttachmentDescription { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::AutoTagFormFields @@ -244,6 +252,11 @@ pub(crate) struct EditCtx { /// The overlay streams the edit's applied redactions added, which alone the renderer's check /// leaves out. pub(crate) redaction_overlays: Vec, + /// What the edit's applied redactions left as it was that still holds the removed text. + pub(crate) redaction_elsewhere: Option, + /// What became of the PDF/A and PDF/UA identification when the edit's applied redactions + /// removed the catalog's XMP packet. + pub(crate) redaction_identification: pdfcraft_redact::Identification, /// What [`Edit::PrepareText`] prepared, repaired and left grouped, for /// [`Session::prepare_text`](crate::Session::prepare_text). pub(crate) prepared: Option, @@ -295,6 +308,8 @@ impl EditCtx { rng: getrandom::fill, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), + redaction_elsewhere: None, + redaction_identification: pdfcraft_redact::Identification::default(), prepared: None, } } @@ -637,6 +652,23 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ConvertColours { pages, settings } => { + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + let done = pdfcraft_edit::convert_colours(doc, pages, settings, &mut recolour)?; + if done.colours == 0 && done.images == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("nothing on those pages needed converting".into()))); + } + } + Edit::EditAttachmentDescription { key, description } => { + pdfcraft_organize::set_attachment_description(doc, key, description.as_deref())?; + } Edit::ReplaceText { page, quads, text, author, strike, caret } => { // One `/NM` each: the strikeout is grouped with the caret, and XFDF names that link by // the caret's `/NM` (#2666). @@ -845,6 +877,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::LayoutTableToBlocks { object } => { a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::AddPrinterMarks { pages, settings, replace } => { + pdfcraft_edit::add_printer_marks(doc, pages, settings, *replace)?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; @@ -919,7 +954,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_edit::add_content(doc, *page, &content)?; } Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + let report = pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = report.overlays; + cx.redaction_elsewhere = Some(report.still_holding); + cx.redaction_identification = report.identification; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 1d2e22a19b..4259e6c721 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -47,8 +47,9 @@ pub(crate) fn uses_scripts(edit: &Edit) -> bool { pub(crate) fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, + Edit::AddPrinterMarks { .. } => Scope::Full, // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::EditAttachmentDescription { .. } => Scope::Full, // A file attachment also changes the Attachments list. Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) @@ -97,6 +98,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } + | Edit::ConvertColours { .. } | Edit::FixHairlines { .. } // Recognized text is an invisible text layer stamped on its page, with its font in the // page's resources (#6310): a full re-inspection made applying one word cost seconds on diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5f03d810a2..17f196cf6a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -96,8 +96,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed, - HeaderFooter, Ink as TextInk, MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, ColourTarget, ColoursConverted, Content as AddedContent, ConvertColours, + Family as FontFamily, Hairlines, HairlinesFixed, HeaderFooter, Ink as TextInk, MarkKind, PrinterMarks, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, @@ -129,6 +129,7 @@ pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; pub use pdfcraft_print as print; +pub use pdfcraft_redact::Elsewhere as RedactionElsewhere; pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, @@ -136,6 +137,7 @@ pub use pdfcraft_redact::patterns::{ pub use pdfcraft_redact::redacted_file_name; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator}; +pub use pdfcraft_redact::{Claim as RedactionClaim, Identification as RedactionIdentification}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, StatusCaptions, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 89421919e6..1584fb5209 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -218,7 +218,13 @@ impl Session { if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { return Err(EditError::SignedRewrite(edit.label().to_string())); } - let notices = edit_notices(&edit, &editor.cos, &next); + let mut notices = edit_notices(&edit, &editor.cos, &next); + // Places applied redactions left that still hold the removed text, and what became of + // the PDF/A or PDF/UA identification in the XMP they removed: said, not silent. + notices.extend(cx.redaction_elsewhere.filter(|e| e.total() > 0 || e.unsearched).map(EditNotice::RedactionElsewhere)); + if cx.redaction_identification != pdfcraft_redact::Identification::default() { + notices.push(EditNotice::RedactionIdentification(cx.redaction_identification)); + } let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); @@ -466,7 +472,14 @@ impl Session { ) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + matches!( + e, + Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::AddPrinterMarks { .. } + | Edit::RemoveMarks { .. } + ) }); (touches_added, touches_marks) } diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 532aa6ed78..4177d1705f 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -116,6 +116,24 @@ impl Session { .map_err(EditError::Write) } + /// What Convert colours would change on `pages` of `id`, without changing the document. + pub fn count_colours(&self, id: DocId, pages: &[usize], settings: &pdfcraft_edit::ConvertColours) -> pdfcraft_edit::ColoursConverted { + let Some(doc) = self.get(id) else { return Default::default() }; + let Some(editor) = doc.editor.as_ref() else { return Default::default() }; + let mut cos = editor.cos.clone(); + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + // The preview runs the image decoders over the document's own bytes; a panic in one is + // a count of nothing, not a crash. + guard(|| pdfcraft_edit::convert_colours(&mut cos, pages, settings, &mut recolour).unwrap_or_default()).unwrap_or_default() + } + /// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many /// pages, without changing the document (the edit itself refuses when there is nothing to fix). pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index da6b662669..3912bfd5ec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2368,6 +2368,29 @@ fn highlight_multiplies_over_text() { assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}"); } +/// Converting the appearances of comments and form fields needs the permission to comment; +/// converting the page alone needs only the permission to modify. +#[test] +fn converting_colours_of_comments_needs_the_comment_permission() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let params = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "own", + permissions: !(1 << 5), + encrypt_metadata: true, + seed: [7; 32], + }; + cos.set_encryption(¶ms).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap(); + let with = ConvertColours { annotations: true, ..ConvertColours::default() }; + assert_eq!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: with }), Err(EditError::NotPermitted("comments"))); + let without = s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }); + assert!(!matches!(without, Err(EditError::NotPermitted(_))), "{without:?}"); +} + #[test] fn comment_permission_is_enforced() { let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); @@ -3284,19 +3307,7 @@ fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec< "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + pdf_of(&objs) } /// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does. @@ -3363,6 +3374,55 @@ fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { ); } +/// #2539 review: Apply redactions tells the caller where the removed text is still held that +/// it leaves as it is (here a bookmark title), and the next edit, or an undo, forgets it. +#[test] +fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page("/Font << /F1 4 0 R >>", "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"); + let id = s.open("bookmark.pdf", None, Arc::new(page), None).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Bluefin plans".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Annual figures".into(), page: 0, left: None, top: None }).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "only applying redactions reports it"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let found = s.get(id).unwrap().redaction_elsewhere().expect("applying reports what it left"); + assert_eq!(found, crate::RedactionElsewhere { bookmarks: 1, ..Default::default() }); + // One of the edit notices, so tools and the interface say it with the others. + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the redacted text also appears in 1 bookmark title, which redaction doesn't change (Remove hidden information removes them)"]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Bluefin plans→1", "Annual figures→1"], "the bookmark is reported, not changed"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "an undo forgets it"); +} + +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A-2b, writes the identification back and +/// says so; tools list it with the other edit notices, and an undo forgets it. +#[test] +fn applied_redactions_report_the_pdfa_identification_written_back() { + let xmp = r#"Bluefin report"#; + let body = "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("pdfa.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let kept = crate::RedactionIdentification { pdfa: crate::RedactionClaim::Kept, pdfua: crate::RedactionClaim::None }; + assert_eq!(s.get(id).unwrap().redaction_identification(), Some(kept)); + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the document's XMP metadata held the redacted text and was removed; its PDF/A identification was written back on its own"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_identification(), None, "an undo forgets it"); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] @@ -10698,6 +10758,41 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } +/// Convert colours re-encodes pictures too (RGB → gray, the JPEG staying JPEG) and is one undo +/// step; a document with nothing to convert refuses rather than leaving an empty step. +#[test] +fn converting_colours_recolours_pictures() { + let mut s = Session::new(); + let mut px = image::RgbImage::new(8, 8); + for p in px.pixels_mut() { + *p = image::Rgb([200, 40, 40]); + } + let mut jpg = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(px).write_to(&mut jpg, image::ImageFormat::Jpeg).unwrap(); + let pic = s.create_from_images(&[("p.jpg".into(), jpg.into_inner())]).unwrap(); + let id = s.open("p.pdf", None, pic, None).unwrap(); + // The picture's colour space and filter, read from the document's current bytes. + let picture = |s: &Session| -> (String, String) { + let doc = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + doc.scan_objects() + .find_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => Some(( + String::from_utf8_lossy(st.dict.name(b"ColorSpace").unwrap_or_default()).into_owned(), + String::from_utf8_lossy(st.dict.name(b"Filter").unwrap_or_default()).into_owned(), + )), + _ => None, + }) + .expect("the picture") + }; + assert_eq!(picture(&s).0, "DeviceRGB"); + s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Convert colours")); + assert_eq!(picture(&s), ("DeviceGray".to_string(), "DCTDecode".to_string())); + assert!(matches!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }), Err(EditError::Edit(_)))); + s.undo(id).unwrap(); + assert_eq!(picture(&s).0, "DeviceRGB"); +} + /// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). fn phone_jpeg() -> Vec { use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; diff --git a/crates/engine/tests/it/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs index 67d560bb4b..8d7574ebec 100644 --- a/crates/engine/tests/it/form_rotation.rs +++ b/crates/engine/tests/it/form_rotation.rs @@ -23,7 +23,8 @@ fn one_page() -> Document { doc } -/// Width and height of the dark pixels on page 1. +/// Width and height of the dark pixels on page 1: the black text, not the field's grey (0.55) +/// border, which would give the field's own outline. fn ink_span(doc: &Document) -> (u32, u32) { let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); @@ -34,7 +35,7 @@ fn ink_span(doc: &Document) -> (u32, u32) { for y in 0..p.height { for x in 0..p.width { let i = ((y * p.width + x) * 4) as usize; - if p.rgba[i] < 200 { + if p.rgba[i] < 100 { ink = true; (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); } @@ -44,15 +45,18 @@ fn ink_span(doc: &Document) -> (u32, u32) { (x1 - x0, y1 - y0) } +/// A tall, narrow field turned 90 degrees stays where it is and its text runs up its long side +/// (#4296), rather than the field turning into a short, wide one that cuts the text off. #[test] fn a_rotated_field_draws_along_the_vertical_edge() { let mut doc = one_page(); - add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap(); - set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap(); - set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); + add_field(&mut doc, 0, [10.0, 40.0, 30.0, 120.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("MMMMMM".into())).unwrap(); + set_props(&mut doc, "tall", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); let (wide, tall) = ink_span(&doc); assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}"); - set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); + set_props(&mut doc, "tall", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); let (wide, tall) = ink_span(&doc); - assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}"); + // Six 12 pt M's are about 60 pt, 120 px at scale 2; the field is 20 pt (40 px) wide. + assert!(tall >= 100 && wide < 40, "the text does not run up the field: {wide}x{tall} px"); } diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 1c3724ef4a..66738246c2 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -612,6 +612,9 @@ pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option (i == toks.len() && v.is_finite()).then_some(v) } +/// Most field values one `AFSimple_Calculate` list may expand to (a form has some thousands). +const MAX_EXPANDED_VALUES: usize = 1 << 20; + /// The Calculate event: the new value, given the current values of the other fields /// (`values(name)` returns every field named `name` or below it, for groups). pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { @@ -623,20 +626,21 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()), Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + // The values are folded as they are read, never collected: a group name listed many + // times would otherwise be held once per listing. A list that expands past + // MAX_EXPANDED_VALUES is not calculated. + let mut n = 0usize; + let xs = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).inspect(|_| n += 1).take(MAX_EXPANDED_VALUES + 1); let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + CalcOp::Sum | CalcOp::Average => xs.sum(), + CalcOp::Product => xs.product(), + CalcOp::Minimum => xs.fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.fold(f64::NEG_INFINITY, f64::max), }; + if n > MAX_EXPANDED_VALUES { + return None; + } + let v = if *op == CalcOp::Average { if n == 0 { 0.0 } else { v / n as f64 } } else { v }; Some(if v.is_finite() { js_number(v) } else { "0".into() }) } Calculate::Notation(expr) => { diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 3c6dab1bef..b4b6452a3c 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -486,3 +486,23 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() { } } } + +#[test] +fn simple_calculations_fold_a_repeated_group_without_collecting_it() { + let group = |_: &str| vec!["2".to_string(); 3]; + let list = |op, n: usize| Calculate::Simple { op, fields: vec!["g".to_string(); n] }; + assert_eq!(calculate(&list(CalcOp::Sum, 4), &group).as_deref(), Some("24")); + assert_eq!(calculate(&list(CalcOp::Average, 4), &group).as_deref(), Some("2")); + assert_eq!(calculate(&list(CalcOp::Product, 2), &group).as_deref(), Some("64")); + assert_eq!(calculate(&list(CalcOp::Maximum, 2), &group).as_deref(), Some("2")); + assert_eq!(calculate(&list(CalcOp::Sum, 0), &group).as_deref(), Some("0")); +} + +#[test] +fn a_simple_calculation_that_expands_past_the_allowance_is_not_calculated() { + let thousand = |_: &str| vec!["1".to_string(); 1000]; + let within = Calculate::Simple { op: CalcOp::Sum, fields: vec!["g".to_string(); 1000] }; + let past = Calculate::Simple { op: CalcOp::Sum, fields: vec!["g".to_string(); 2000] }; + assert_eq!(calculate(&within, &thousand).as_deref(), Some("1000000")); + assert_eq!(calculate(&past, &thousand), None); +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 2c26d265a5..715db946e4 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -118,8 +118,9 @@ pub struct FieldProps { /// Options tab: the mark of a check box or radio button (every widget). pub check_style: Option, /// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise. - /// Changing between horizontal and vertical swaps that widget's width and height around its - /// center, unless [`Self::rect`] is also set. + /// The widget keeps its rectangle: `/R` turns the appearance inside it (ISO 32000-2 Table 192 + /// turns the annotation relative to the page, and its `/Rect` stays in default user space), so + /// a tall field turned 90 degrees holds text running up its long side. pub rotation: Option<(usize, i64)>, } diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 14659c4f52..2c727779eb 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -3,18 +3,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_wi use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) -} - /// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was /// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` /// are the old `/I` positions carried into the new list, which tell apart options sharing an @@ -139,21 +127,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } + if let Some(rotation) = props.rotation { + check_rotation(&f, name, rotation)?; } // A locked field only takes unlocking (Acrobat greys its properties out). if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { @@ -366,7 +341,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result>, -) -> Result, FormError> { +/// Whether `set_props` can turn widget `wi` of `f` to `rot`: 0, 90, 180 or 270 degrees, of a +/// widget the field has. +fn check_rotation(f: &Field, name: &str, (wi, rot): (usize, i64)) -> Result<(), FormError> { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + if f.widgets.get(wi).is_none() { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + } + Ok(()) +} + +fn apply_late_widget_props(doc: &mut Document, f: &Field, props: &FieldProps, new_da: &str) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -551,12 +532,11 @@ fn apply_late_widget_props( } } } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) + // Only `/MK /R` changes: the widget stays where it is on the page, and the redraw below lays + // its content out along the turned direction inside the same rectangle. + if let Some((wi, rot)) = props.rotation && let Some(w) = f.widgets.get(wi) { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); if rot == 0 { mk.remove(b"R"); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 03e491958f..b69e24d8dc 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3280,14 +3280,14 @@ fn rotated_fields_draw_in_their_quadrant() { let turned = field(&fields(&doc), "name").widgets[0].clone(); assert_eq!(turned.rotation, 90); let r = turned.rect; - // [50 700 250 720] around its center becomes 20 wide and 200 tall. - assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}"); + // The field stays where it was: only its content turns. + assert!(r.iter().zip(before).all(|(a, b)| close(*a, b)), "{r:?} moved from {before:?}"); let widget = doc.get(turned.obj); let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap(); assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90)); let (_, matrix) = appearance_box(&doc, &turned); assert!(matrix.is_some()); - // 270 stays on the vertical axis, so the rectangle is not swapped again. + // 270 keeps the rectangle too. set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap(); let r270 = field(&fields(&doc), "name").widgets[0].rect; assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}"); @@ -3310,18 +3310,26 @@ fn rotated_fields_draw_in_their_quadrant() { assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}"); } +/// #4296: a tall field turned a quarter keeps its place, and its text is laid out along the long +/// side (the appearance box is as wide as the field is tall) and turned onto the field. #[test] -fn a_rotation_refused_as_too_small_changes_nothing_else() { +fn a_tall_field_turned_a_quarter_keeps_its_place_and_runs_its_text_up_it() { let mut doc = one_page(); - add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap(); - // 200 x 3: turned a quarter it would be 3 wide, too small to use. - let obj = field(&fields(&doc), "thin").widgets[0].obj; - doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap(); - let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() }; - assert!(set_props(&mut doc, "thin", &props).is_err()); - let f = field(&fields(&doc), "thin").clone(); - assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused"); - assert_eq!(f.widgets[0].rotation, 0); + let rect = [50.0, 600.0, 70.0, 760.0]; + add_field(&mut doc, 0, rect, &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("Ada".into())).unwrap(); + let props = FieldProps { tooltip: Some("Name".into()), rotation: Some((0, 90)), ..FieldProps::default() }; + set_props(&mut doc, "tall", &props).unwrap(); + let f = field(&fields(&doc), "tall").clone(); + let w = &f.widgets[0]; + assert_eq!((w.rotation, f.tooltip.as_deref()), (90, Some("Name"))); + assert!(w.rect.iter().zip(rect).all(|(a, b)| close(*a, b)), "the field moved: {:?} -> {:?}", rect, w.rect); + let (bbox, matrix) = appearance_box(&doc, w); + assert!(close(bbox[2], 160.0) && close(bbox[3], 20.0), "the text is laid out across the field, not up it: {bbox:?}"); + let m = matrix.unwrap(); + // (x, y) -> (20 - y, x): the box's 160 pt run goes up the field. + assert!(close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 20.0), "{m:?}"); + assert!(ap(&doc, w).contains("(Ada) Tj")); } #[test] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 1a571452ec..3207b69cab 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -139,6 +139,13 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going stay LZW: a predictor carries over to Flate without `EarlyChange`, filters applied before the LZW one go with it and those after it keep their bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images. +- **Discard external cross-references** (off by default): `GoToR`, `GoToE`, `Launch` and + `ImportData` actions, and `Thread` actions with an `/F`, are taken out of link annotations and + bookmarks, and the actions chained after one through `/Next` take its place. Other annotations, + such as form fields, keep their actions. A link left with no action and no `/Dest` is removed + from its page, and its object, when something else refers to it, keeps the link without the + action; a bookmark keeps its title and children. Links within the document and web links + (`URI`) stay. An outline of more than 100,000 bookmarks stops the optimization with an error. The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save @@ -146,6 +153,13 @@ then this, then merges identical objects and writes a full, compressed save progress bar). JPEG decoding comes from `zune-jpeg` (the decoder behind `image` and the renderer), JPEG encoding and resampling from the `image` crate. +Discard embedded search index (`discard_search_index`, off by default) removes the entries of +the catalog's `/PieceInfo` whose application name contains "index" in any letter case, such as +`IBM-ODIndexes`, and removes `/PieceInfo` when no other entry is left. An indirect `/PieceInfo` +is copied into the catalog without those entries, and the original object is not changed. The +full save leaves out the objects that only the removed entries refer to. Pages keep their own +`/PieceInfo`. + ## Target PDF version: partial compatibility The target selects the header version, clears the catalog `/Version`, controls object diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs new file mode 100644 index 0000000000..3981b81b81 --- /dev/null +++ b/crates/optimize/src/external_refs.rs @@ -0,0 +1,439 @@ +//! Discard User Data ▸ Discard external cross-references: links and bookmarks that open another +//! document. +//! +//! An action opens another document when its `/S` is `GoToR` (another PDF file), `GoToE` (a +//! document embedded in this one, or the one this is embedded in), `Launch` (a file or an +//! application), `ImportData` (form data read from another file) or `Thread` with an `/F` (an +//! article in another file). Such an action is taken out of the `/A` of a link annotation +//! (`/Subtype /Link`) or a bookmark, and the actions chained after it through `/Next` take its +//! place in order. Other annotations, such as form fields, keep their actions. A link left with no +//! action and no `/Dest` is removed from its page; when the link is stored as its own object, that +//! object keeps the link without its action for anything else that refers to it (the structure +//! tree, for example). A bookmark stays, with its title and children. Links that jump within the +//! document (`/Dest`, `GoTo`) and web links (`URI`) are kept. An `/A` that takes more than +//! [`MAX_STEPS`] values to check, or whose actions open another document and take more than that +//! to rewrite, stops the optimization with an error, and so does an outline of more than +//! [`MAX_OUTLINE`] items or with items stored inside each other more than [`MAX_DEPTH`] levels +//! deep. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::OptimizeError; + +/// Action types that open or read another document (`Thread` does when it has an `/F`). +const EXTERNAL: [&[u8]; 4] = [b"GoToR", b"GoToE", b"Launch", b"ImportData"]; + +/// How many levels (actions, `/Next` arrays and references) a chain is rewritten. Past it, the rest +/// of the chain is kept when it opens no other document, and removed when it does. +const MAX_DEPTH: usize = 512; + +/// How many values each pass over one `/A` looks at: the check for actions to other documents, +/// then the rewrite with its check past [`MAX_DEPTH`]. A chain that needs more in either pass +/// stops the optimization with [`OptimizeError::ExternalRefLimit`]. +const MAX_STEPS: usize = 100_000; + +/// How many bookmarks the outline walk looks at before it stops the optimization with +/// [`OptimizeError::ExternalRefLimit`]. +const MAX_OUTLINE: usize = 100_000; + +/// What is left of an action or a list of actions. +enum Kept { + /// Nothing changed: keep the object as it is stored. + Same, + /// These actions, in order, replace it. + Now(Vec), +} + +#[derive(Default)] +struct Walk { + /// References on the current chain, to end a chain that comes back to itself. + path: Vec, + /// Indirect objects from which an action to another document can be reached. + dirty: HashSet, + /// Actions to another document taken out. + removed: usize, + /// A chain that opens another document past [`MAX_DEPTH`] was cut there. + cut: bool, + /// Values looked at so far, against [`MAX_STEPS`]. + steps: usize, + /// The walk ran out of steps; its result is not used. + exhausted: bool, +} + +fn is_external(doc: &Document, d: &Dict) -> bool { + let Some(s) = d.get(b"S").map(|s| doc.resolve(s)) else { return false }; + match s.as_name() { + Some(b"Thread") => d.get(b"F").is_some_and(|f| !matches!(doc.resolve(f).as_ref(), Object::Null)), + Some(n) => EXTERNAL.contains(&n), + None => false, + } +} + +/// The actions an unchanged `/Next` value stands for, in order. +fn listed(doc: &Document, next: &Object) -> Vec { + match doc.resolve(next).as_ref() { + Object::Array(a) => a.clone(), + Object::Dict(_) => vec![next.clone()], + _ => Vec::new(), + } +} + +impl Walk { + /// Count one value; false once the walk has run out of steps. + fn step(&mut self) -> bool { + self.steps += 1; + if self.steps > MAX_STEPS { + self.exhausted = true; + } + !self.exhausted + } + + /// Whether the actions stored directly in `o` include one to another document; the references + /// they hold are added to `refs`. + fn scan(&mut self, doc: &Document, o: &Object, refs: &mut Vec) -> bool { + let mut stack = vec![o]; + let mut external = false; + while let Some(o) = stack.pop() { + if !self.step() { + return false; + } + match o { + Object::Ref(r) => refs.push(*r), + Object::Array(items) => stack.extend(items), + Object::Dict(d) => { + external |= is_external(doc, d); + if let Some(n) = d.get(b"Next") { + stack.push(n); + } + } + _ => {} + } + } + external + } + + /// Mark the indirect objects reachable from `a` that lead to an action to another document. + /// Each indirect object is looked at once. + fn reach(&mut self, doc: &Document, a: &Object) { + let mut todo = Vec::new(); + self.scan(doc, a, &mut todo); + let mut seen = HashSet::new(); + let mut parents: HashMap> = HashMap::new(); + let mut found = Vec::new(); + while let Some(r) = todo.pop() { + if self.exhausted { + return; + } + if !seen.insert(r) { + continue; + } + let target = doc.get(r); + let mut refs = Vec::new(); + if self.scan(doc, &target, &mut refs) { + found.push(r); + } + for c in refs { + parents.entry(c).or_default().push(r); + todo.push(c); + } + } + while let Some(r) = found.pop() { + if self.dirty.insert(r) + && let Some(p) = parents.get(&r) + { + found.extend(p); + } + } + } + + /// Whether `o` or an action chained after it opens another document. Needs [`Walk::reach`] + /// to have run on an `/A` that holds `o`. + fn holds_external(&mut self, doc: &Document, o: &Object) -> bool { + let mut refs = Vec::new(); + let external = self.scan(doc, o, &mut refs); + external || refs.iter().any(|r| self.dirty.contains(r)) + } +} + +impl Walk { + /// Indirect actions that change are written as new indirect objects (the old ones stay for + /// whatever else uses them), so a rewritten chain nests no deeper than the original. + fn strip(&mut self, doc: &mut Document, o: &Object, depth: usize) -> Kept { + if !self.step() { + return Kept::Same; + } + if depth > MAX_DEPTH { + if !self.holds_external(doc, o) { + return Kept::Same; + } + self.cut = true; + return Kept::Now(Vec::new()); + } + match o { + Object::Ref(r) => { + if !self.dirty.contains(r) { + return Kept::Same; + } + if self.path.contains(r) { + return Kept::Now(Vec::new()); + } + self.path.push(*r); + let target = doc.get(*r); + let kept = self.strip(doc, &target, depth + 1); + self.path.pop(); + match kept { + Kept::Now(mut v) if v.len() == 1 && matches!(v.first(), Some(Object::Dict(_))) => { + let d = v.remove(0); + Kept::Now(vec![Object::Ref(doc.add(d))]) + } + other => other, + } + } + Object::Array(items) => { + let mut out = Vec::new(); + let mut changed = false; + for item in items { + match self.strip(doc, item, depth + 1) { + Kept::Same => out.push(item.clone()), + Kept::Now(v) => { + changed = true; + out.extend(v); + } + } + } + if changed { Kept::Now(out) } else { Kept::Same } + } + Object::Dict(d) => { + let next = d.get(b"Next"); + let rest = next.map(|n| self.strip(doc, n, depth + 1)); + if is_external(doc, d) { + self.removed += 1; + return Kept::Now(match (rest, next) { + (Some(Kept::Now(v)), _) => v, + (Some(Kept::Same), Some(n)) => listed(doc, n), + _ => Vec::new(), + }); + } + match rest { + Some(Kept::Now(v)) => { + let mut d = d.clone(); + set_next(doc, &mut d, v); + Kept::Now(vec![Object::Dict(d)]) + } + _ => Kept::Same, + } + } + _ => Kept::Same, + } + } +} + +fn set_next(doc: &mut Document, d: &mut Dict, mut v: Vec) { + match v.len() { + 0 => { + d.remove(b"Next"); + } + 1 => d.set(b"Next".to_vec(), v.remove(0)), + _ => { + let next = v.into_iter().map(|a| if matches!(a, Object::Dict(_) | Object::Array(_)) { Object::Ref(doc.add(a)) } else { a }).collect(); + d.set(b"Next".to_vec(), Object::Array(next)); + } + } +} + +/// `/A` of `owner` with its actions to other documents taken out: `None` when nothing changed, +/// `Some(None)` when no action is left, `Some(Some(action))` otherwise (one action, the rest after +/// it). +fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result>, OptimizeError> { + let mut walk = Walk::default(); + // A quick check first, which looks at each indirect object once: a chain with no action to + // another document is left alone however its actions are shared, unless it holds more than + // `MAX_STEPS` values. + walk.reach(doc, a); + let external = walk.holds_external(doc, a); + if walk.exhausted { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + if !external { + return Ok(None); + } + walk.steps = 0; + let kept = walk.strip(doc, a, 0); + if walk.exhausted { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + let Kept::Now(mut v) = kept else { return Ok(None) }; + if walk.removed == 0 && !walk.cut { + return Ok(None); + } + // A null or a reference to something other than an action cannot lead the chain. + let head = v.iter().position(|o| doc.resolve(o).as_dict().is_some()).unwrap_or(v.len()); + v.drain(..head); + if v.is_empty() { + return Ok(Some(None)); + } + let first = v.remove(0); + if v.is_empty() { + return Ok(Some(Some(first))); + } + let Some(mut d) = doc.resolve(&first).as_dict().cloned() else { return Ok(Some(None)) }; + let mut next = d.get(b"Next").map(|n| listed(doc, n)).unwrap_or_default(); + next.extend(v); + set_next(doc, &mut d, next); + Ok(Some(Some(Object::Ref(doc.add(Object::Dict(d)))))) +} + +/// The outline walk: bookmarks stored as their own objects wait on `stack`; those stored inside +/// another bookmark are edited where they are. +#[derive(Default)] +struct Outline { + stack: Vec, + items: usize, + changed: usize, +} + +impl Outline { + /// Take actions to other documents out of the bookmark `d` (or, when `item` is false, the + /// outline dictionary) and of the bookmarks stored inside it. `owner` is the object that holds + /// `d`. Returns whether `d` changed. + fn visit(&mut self, doc: &mut Document, d: &mut Dict, owner: ObjRef, depth: usize, item: bool) -> Result { + let mut changed = false; + if item { + self.items += 1; + if self.items > MAX_OUTLINE { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + let stripped = match d.get(b"A") { + Some(a) => strip_action(doc, a, owner)?, + None => None, + }; + if let Some(action) = stripped { + match action { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + } + self.changed += 1; + changed = true; + } + } + let keys: &[&[u8]] = if item { &[b"Next", b"First"] } else { &[b"First"] }; + for k in keys { + match d.get_mut(k) { + Some(Object::Ref(r)) => self.stack.push(*r), + Some(Object::Dict(c)) => { + if depth >= MAX_DEPTH { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + changed |= self.visit(doc, c, owner, depth + 1, true)?; + } + _ => {} + } + } + Ok(changed) + } +} + +/// Take actions to other documents out of bookmarks, depth-first. Returns the bookmarks changed. +fn discard_bookmarks(doc: &mut Document) -> Result { + let Some(root) = doc.root() else { return Ok(0) }; + let catalog = doc.get(root); + let mut walk = Outline::default(); + let mut seen = HashSet::new(); + match catalog.as_dict().and_then(|c| c.get(b"Outlines")) { + Some(Object::Ref(o)) => { + seen.insert(*o); + if let Some(mut d) = doc.get(*o).as_dict().cloned() + && walk.visit(doc, &mut d, *o, 0, false)? + { + doc.set(*o, Object::Dict(d)); + } + } + Some(Object::Dict(o)) => { + let mut d = o.clone(); + if walk.visit(doc, &mut d, root, 0, false)? { + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(d)))?; + } + } + _ => {} + } + while let Some(r) = walk.stack.pop() { + if !seen.insert(r) { + continue; + } + let Some(mut d) = doc.get(r).as_dict().cloned() else { continue }; + if walk.visit(doc, &mut d, r, 0, true)? { + doc.set(r, Object::Dict(d)); + } + } + Ok(walk.changed) +} + +/// Take actions to other documents out of links and bookmarks. Returns (links, bookmarks) +/// changed; a link counts whether it was removed or kept for its other actions. +pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), OptimizeError> { + let mut links = 0; + let mut removed_links = HashSet::new(); + for p in pages { + let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; + let Some(annots) = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; + let mut keep = Vec::with_capacity(annots.len()); + let mut changed = false; + for x in &annots { + let Some(mut d) = doc.resolve(x).as_dict().cloned() else { + keep.push(x.clone()); + continue; + }; + let owner = match x { + Object::Ref(r) => *r, + _ => *p, + }; + let link = d.get(b"Subtype").map(|s| doc.resolve(s)).is_some_and(|s| s.as_name().is_some_and(|n| n == b"Link")); + let stripped = match d.get(b"A") { + Some(a) if link => strip_action(doc, a, owner)?, + _ => None, + }; + let Some(action) = stripped else { + if !matches!(x, Object::Ref(r) if removed_links.contains(r)) { + keep.push(x.clone()); + } else { + changed = true; + } + continue; + }; + links += 1; + match action { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + } + let removed = d.get(b"A").is_none() && d.get(b"Dest").is_none(); + match x { + Object::Ref(r) => { + doc.set(*r, Object::Dict(d)); + if removed { + removed_links.insert(*r); + changed = true; + } else { + keep.push(x.clone()); + } + } + _ => { + changed = true; + if !removed { + keep.push(Object::Dict(d)); + } + } + } + } + if changed { + doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(keep)))?; + } + } + let bookmarks = discard_bookmarks(doc)?; + Ok((links, bookmarks)) +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index f72579778a..7bfa44dafd 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1615,6 +1615,77 @@ fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { Ok(()) } +/// The space a picture is re-encoded in by [`recolour_image`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageTarget { + Gray, + Rgb, + Cmyk, +} + +/// Re-encode an 8-bit gray or RGB picture (Flate or JPEG, no mask, no decode array) in +/// `target`: gray and RGB pictures keep their compression (JPEG stays JPEG, compressed a second +/// time at quality 80, so with some loss), CMYK is written lossless. A soft mask stays as it is, +/// and so does a picture whose soft mask has a `/Matte`, which is a colour in the picture's own +/// space. `None` when the picture is not one this handles, or already in the target space. +pub fn recolour_image(doc: &Document, s: &Stream, target: ImageTarget) -> Option { + let d = &s.dict; + if d.contains(b"Mask") || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || d.int(b"BitsPerComponent") != Some(8) { + return None; + } + if d.get(b"SMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte"))) { + return None; + } + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, 3)) && d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, 1)) { + return None; + } + let n = components(doc, d.get(b"ColorSpace")?)?; + let want = match target { + ImageTarget::Gray => 1, + ImageTarget::Rgb => 3, + ImageTarget::Cmyk => 4, + }; + if n == want { + return None; + } + let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + // One picture a call: `pixels` itself refuses one past MAX_PIXELS, so no shared allowance is kept here. + let px = pixels(doc, s, n, &mut ImageBudget::with_work(u64::MAX))?; + let was_dct = is_dct(s)?; + let out: Vec = match (n, target) { + (3, ImageTarget::Gray) => { + px.as_chunks::<3>().0.iter().map(|p| ((299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000) as u8).collect() + } + (1, ImageTarget::Rgb) => px.iter().flat_map(|g| [*g, *g, *g]).collect(), + (1, ImageTarget::Cmyk) => px.iter().flat_map(|g| [0, 0, 0, 255 - *g]).collect(), + (3, ImageTarget::Cmyk) => px + .as_chunks::<3>() + .0 + .iter() + .flat_map(|p| { + let k = 255 - p.iter().copied().max().unwrap_or(0) as u32; + if k >= 255 { + return [0u8, 0, 0, 255]; + } + let f = |v: u8| (((255 - v as u32 - k) * 255) / (255 - k)).min(255) as u8; + [f(p[0]), f(p[1]), f(p[2]), k as u8] + }) + .collect(), + _ => return None, + }; + let mut dict = d.clone(); + dict.set( + b"ColorSpace".to_vec(), + Object::name(match target { + ImageTarget::Gray => "DeviceGray", + ImageTarget::Rgb => "DeviceRGB", + ImageTarget::Cmyk => "DeviceCMYK", + }), + ); + let compression = if want == 4 { Compression::Flate } else { Compression::Retain }; + image_stream(&dict, &out, want, w, h, compression, was_dct) +} + #[cfg(test)] mod dimension_tests { use super::*; diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index fc29a45d4e..97296844a2 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -21,14 +21,19 @@ //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or //! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. +//! - **Discard embedded search index** (off by default): the search indexes stored in the +//! catalog's `/PieceInfo` (see [`Settings::discard_search_index`]). //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine -//! runs alongside. +//! runs alongside. Links that open another document, and bookmark actions that do, are discarded +//! here (Discard User Data ▸ Discard external cross-references, see +//! [`Settings::discard_external_refs`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +mod external_refs; mod form_actions; pub mod fragments; mod images; @@ -42,7 +47,7 @@ pub use audit::{SpaceCategory, SpaceUse, audit_space}; use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; use pdfcraft_filters::{Filter, Params}; -pub use images::effective_resolutions; +pub use images::{ImageTarget, effective_resolutions, recolour_image}; pub use pool::give_way_to_other_programs; pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; @@ -56,6 +61,8 @@ pub enum OptimizeError { IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), + #[error("object {0} holds too many actions or bookmarks to take out its links to other documents")] + ExternalRefLimit(ObjRef), #[error("the optimization was cancelled")] Cancelled, } @@ -199,6 +206,11 @@ pub struct Settings { pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, + /// Remove the entries of the catalog's `/PieceInfo` (data an application keeps for the whole + /// document, ISO 32000-2 §14.5) whose application name contains "index" in any letter case, + /// and `/PieceInfo` itself when nothing else is left in it. Objects that only those entries + /// refer to are left out of the saved file. + pub discard_search_index: bool, pub discard_print_settings: bool, /// Remove form submission, import and reset actions; the fields and links that ran them stay. pub discard_form_actions: bool, @@ -214,6 +226,10 @@ pub struct Settings { pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, + /// Discard User Data: take `GoToR`, `GoToE`, `Launch` and `ImportData` actions, and `Thread` + /// actions with an `/F`, out of links and bookmarks; a link left with nothing to do is + /// removed. Links within the document and web links stay. + pub discard_external_refs: bool, } impl Settings { @@ -237,6 +253,7 @@ impl Default for Settings { discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, + discard_search_index: false, discard_print_settings: false, discard_form_actions: false, flate_unencoded: true, @@ -244,6 +261,7 @@ impl Default for Settings { lzw_to_flate: true, remove_invalid_links: true, remove_unreferenced_dests: true, + discard_external_refs: false, } } } @@ -302,6 +320,8 @@ pub struct Report { pub thumbnails: usize, pub alternate_images: usize, pub tags_removed: bool, + /// Search index entries removed from the catalog's `/PieceInfo`. + pub search_indexes: usize, pub print_settings: usize, /// Form submission, import and reset actions removed. pub form_actions: usize, @@ -312,6 +332,10 @@ pub struct Report { pub invalid_links: usize, pub invalid_bookmarks: usize, pub unreferenced_dests: usize, + /// Links removed (or kept for their other actions) and bookmarks whose action was changed or + /// cleared, because they opened another document (Discard external cross-references). + pub external_links: usize, + pub external_bookmarks: usize, /// Images and forms the pages listed but never drew, dropped (Clean Up). pub unused_xobjects: usize, } @@ -410,29 +434,15 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } report.tags_removed = true; } + if settings.discard_search_index + && let Some(root) = root + { + report.search_indexes = discard_search_index(doc, root)?; + } if settings.discard_print_settings && let Some(root) = root { - const PRINT: [&[u8]; 7] = [b"PrintScaling", b"Duplex", b"PickTrayByPDFSize", b"PrintPageRange", b"NumCopies", b"PrintArea", b"PrintClip"]; - let vp = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences").cloned()); - let edit = |d: &mut pdfcraft_cos::Dict| PRINT.iter().filter(|k| d.remove(k).is_some()).count(); - match vp { - Some(Object::Ref(r)) => { - let mut n = 0; - doc.update_dict(r, |d| n = edit(d))?; - report.print_settings += n; - } - Some(Object::Dict(_)) => { - let mut n = 0; - doc.update_dict(root, |c| { - if let Some(Object::Dict(d)) = c.get_mut(b"ViewerPreferences") { - n = edit(d); - } - })?; - report.print_settings += n; - } - _ => {} - } + report.print_settings += discard_print_settings(doc, root)?; } if settings.discard_form_actions { report.form_actions = form_actions::discard(doc)?; @@ -442,6 +452,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: report.invalid_links = l; report.invalid_bookmarks = b; } + if settings.discard_external_refs { + let (l, b) = external_refs::discard(doc, &pages)?; + report.external_links = l; + report.external_bookmarks = b; + } if settings.remove_unreferenced_dests { report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; } @@ -620,6 +635,66 @@ pub(crate) fn stored_len(s: &Stream) -> Option { out.len().checked_add(s.raw.len()) } +/// Removes the print entries from the catalog's /ViewerPreferences, returning how many went. +fn discard_print_settings(doc: &mut Document, root: ObjRef) -> Result { + const PRINT: [&[u8]; 7] = [b"PrintScaling", b"Duplex", b"PickTrayByPDFSize", b"PrintPageRange", b"NumCopies", b"PrintArea", b"PrintClip"]; + let vp = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences").cloned()); + let edit = |d: &mut pdfcraft_cos::Dict| PRINT.iter().filter(|k| d.remove(k).is_some()).count(); + let mut n = 0; + match vp { + Some(Object::Ref(r)) => doc.update_dict(r, |d| n = edit(d))?, + Some(Object::Dict(_)) => doc.update_dict(root, |c| { + if let Some(Object::Dict(d)) = c.get_mut(b"ViewerPreferences") { + n = edit(d); + } + })?, + _ => {} + } + Ok(n) +} + +/// Remove the search index entries of the catalog's `/PieceInfo` ([`Settings::discard_search_index`]) +/// and return how many there were. An indirect `/PieceInfo` is not edited: the catalog gets an +/// indirect copy without those entries, so other objects that refer to it keep theirs. The objects +/// the removed entries held are dropped by the full save, which writes only what the trailer +/// reaches. +fn discard_search_index(doc: &mut Document, root: ObjRef) -> Result { + let Some(info) = doc.get(root).as_dict().and_then(|c| c.get(b"PieceInfo").cloned()) else { + return Ok(0); + }; + let indirect = matches!(info, Object::Ref(_)); + let (info, removed) = match &*doc.resolve(&info) { + Object::Dict(d) => { + let kept: pdfcraft_cos::Dict = d.iter().filter(|(k, _)| !is_search_index(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + let removed = d.len().saturating_sub(kept.len()); + (kept, removed) + } + _ => return Ok(0), + }; + if removed == 0 { + return Ok(0); + } + let replacement = if info.is_empty() { + None + } else { + let copy = Object::Dict(info); + Some(if indirect { Object::Ref(doc.add(copy)) } else { copy }) + }; + doc.update_dict(root, |c| match replacement { + Some(info) => c.set(b"PieceInfo".to_vec(), info), + None => { + c.remove(b"PieceInfo"); + } + })?; + Ok(removed) +} + +/// Whether a `/PieceInfo` application name names a search index: it contains "index" in any +/// letter case (such as `IBM-ODIndexes`). +fn is_search_index(name: &[u8]) -> bool { + name.windows(5).any(|w| w.eq_ignore_ascii_case(b"index")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 92203852c7..7ab25262f8 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2975,6 +2975,510 @@ fn invalid_links_and_unreferenced_destinations_go() { assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0); } +mod external_refs { + use super::*; + + /// An action dictionary of type `s`, followed by `next` when given. + fn action(s: &str, next: Option) -> Object { + let mut d = Dict::new(); + d.set(b"S".to_vec(), Object::name(s)); + match s { + "GoToR" | "Launch" => d.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"other.pdf".to_vec()))), + "URI" => d.set(b"URI".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"https://example.com".to_vec()))), + _ => {} + } + if let Some(n) = next { + d.set(b"Next".to_vec(), n); + } + Object::Dict(d) + } + + fn link_with(a: Option, dest: Option) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + if let Some(a) = a { + d.set(b"A".to_vec(), a); + } + if let Some(dest) = dest { + d.set(b"Dest".to_vec(), dest); + } + Object::Dict(d) + } + + /// The action types an `/A` value runs, in order (depth-first through `/Next`), at most 1,000. + fn chain(doc: &Document, a: &Object) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![a.clone()]; + while let Some(o) = stack.pop() { + if out.len() >= 1_000 { + break; + } + match doc.resolve(&o).as_ref() { + Object::Array(items) => stack.extend(items.iter().rev().cloned()), + Object::Dict(d) => { + out.push( + d.get(b"S") + .map(|s| doc.resolve(s)) + .and_then(|s| s.as_name().map(|n| String::from_utf8_lossy(n).into_owned())) + .unwrap_or_default(), + ); + if let Some(n) = d.get(b"Next") { + stack.push(n.clone()); + } + } + _ => {} + } + } + out + } + + /// A one-page document whose page holds `annots`, with one bookmark per action in `bookmarks`. + fn linked_doc(annots: impl FnOnce(&mut Document, ObjRef) -> Vec, bookmarks: &[Object]) -> (Document, ObjRef, Vec) { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let annots = annots(&mut doc, p); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + let outlines = doc.add(Object::Null); + let items: Vec = bookmarks.iter().map(|_| doc.add(Object::Null)).collect(); + for (i, (r, a)) in items.iter().zip(bookmarks).enumerate() { + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(format!("Item {i}").into_bytes()))); + item.set(b"Parent".to_vec(), Object::Ref(outlines)); + item.set(b"A".to_vec(), a.clone()); + if let Some(n) = items.get(i + 1) { + item.set(b"Next".to_vec(), Object::Ref(*n)); + } + doc.set(*r, Object::Dict(item)); + } + let mut o = Dict::new(); + if let (Some(first), Some(last)) = (items.first(), items.last()) { + o.set(b"First".to_vec(), Object::Ref(*first)); + o.set(b"Last".to_vec(), Object::Ref(*last)); + } + doc.set(outlines, Object::Dict(o)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Ref(outlines))).unwrap(); + (doc, p, items) + } + + fn annots_of(doc: &Document, p: ObjRef) -> Vec { + let annots = doc.get(p).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().map(|a| doc.resolve(a).as_dict().unwrap().clone()).collect() + } + + #[test] + fn links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay() { + let (doc, p, items) = linked_doc( + |doc, p| { + let here = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut goto = Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), here.clone()); + let remote = doc.add(link_with(Some(action("GoToR", None)), None)); + vec![ + Object::Ref(remote), + link_with(Some(action("Launch", None)), None), + link_with(Some(action("GoToE", None)), None), + link_with(Some(action("URI", None)), None), + link_with(Some(Object::Dict(goto)), None), + link_with(None, Some(here)), + link_with(Some(action("GoToR", Some(action("URI", None)))), None), + ] + }, + &[action("GoToR", None), action("Named", None)], + ); + let on = Settings { discard_external_refs: true, ..Settings::default() }; + // Off by default: nothing changes. + let mut off = doc.clone(); + let report = optimize(&mut off, &Settings::default()).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks), (0, 0)); + assert_eq!(annots_of(&off, p).len(), 7); + let mut doc = doc; + let report = optimize(&mut doc, &on).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks, report.invalid_links), (4, 1, 0), "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| d.get(b"A").map(|a| chain(&doc, a)).unwrap_or_default()).collect(); + assert_eq!( + left, + [vec!["URI"], vec!["GoTo"], vec![], vec!["URI"]], + "the web link, the jump within and the /Dest link stay; the chained web link keeps its URI" + ); + assert!(annots_of(&doc, p)[2].get(b"Dest").is_some()); + let bookmark = |doc: &Document, r: ObjRef| doc.get(r).as_dict().unwrap().clone(); + assert!(bookmark(&doc, items[0]).get(b"A").is_none(), "the bookmark stays, without its action"); + assert_eq!(bookmark(&doc, items[0]).get(b"Next"), Some(&Object::Ref(items[1])), "and its place in the outline"); + assert_eq!(chain(&doc, bookmark(&doc, items[1]).get(b"A").unwrap()), ["Named"]); + } + + #[test] + fn discarding_a_remote_action_keeps_the_links_local_destination() { + let (mut doc, p, _) = + linked_doc(|_, p| vec![link_with(Some(action("GoToR", None)), Some(Object::Array(vec![Object::Ref(p), Object::name("Fit")])))], &[]); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let links = annots_of(&reopened, p); + assert_eq!(links.len(), 1); + assert!(links[0].get(b"A").is_none()); + assert_eq!(links[0].get(b"Dest"), Some(&Object::Array(vec![Object::Ref(p), Object::name("Fit")]))); + } + + #[test] + fn repeated_indirect_remote_link_is_removed_from_one_page() { + let (mut doc, p, _) = linked_doc( + |doc, _| { + let link = doc.add(link_with(Some(action("GoToR", None)), None)); + vec![Object::Ref(link), Object::Ref(link)] + }, + &[], + ); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert!(annots_of(&doc, p).is_empty(), "both references leave the page"); + } + + #[test] + fn shared_indirect_remote_link_with_empty_next_is_removed_from_every_page() { + let (mut doc, first, items) = linked_doc(|_, _| Vec::new(), &[action("Named", None), action("GoToR", None)]); + let second = page(&mut doc, &[], "BT ET"); + let link = doc.add(link_with(Some(action("GoToR", Some(Object::Array(vec![Object::Null])))), None)); + for p in [first, second] { + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + } + doc.update_dict(items[0], |d| d.set(b"First".to_vec(), Object::Ref(items[1]))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert_eq!(report.external_bookmarks, 1, "{report:?}"); + assert!(doc.get(items[1]).as_dict().unwrap().get(b"A").is_none(), "the shared bookmark has no action"); + assert!(annots_of(&doc, first).is_empty(), "the first page loses the link"); + assert!(annots_of(&doc, second).is_empty(), "the second page loses the link"); + assert!(doc.get(link).as_dict().unwrap().get(b"A").is_none(), "the shared link has no action"); + } + + #[test] + fn an_action_to_another_document_hands_its_place_to_the_actions_after_it() { + let (doc, p, _) = linked_doc( + |doc, p| { + let here = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + // A shared Launch, stored on its own with an indirect /S, followed by a web link. + let launch_name = doc.add(Object::name("Launch")); + let Object::Dict(mut launch) = action("Launch", Some(action("URI", None))) else { panic!("action() builds a dictionary") }; + launch.set(b"S".to_vec(), Object::Ref(launch_name)); + let launch = doc.add(Object::Dict(launch)); + let mut goto = Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), here); + goto.set( + b"Next".to_vec(), + Object::Array(vec![action("GoToR", Some(action("JavaScript", None))), Object::Ref(launch), action("Named", None)]), + ); + // A remote go-to whose web link comes back to it. + let (a1, a2) = (doc.add(Object::Null), doc.add(Object::Null)); + doc.set(a1, action("GoToR", Some(Object::Ref(a2)))); + doc.set(a2, action("URI", Some(Object::Ref(a1)))); + // A remote go-to that leads a list: the rest follow the first action left. + let list = action("GoToR", Some(Object::Array(vec![action("SubmitForm", Some(action("Named", None))), action("URI", None)]))); + vec![ + link_with(Some(Object::Dict(goto)), None), + link_with(Some(Object::Ref(a1)), None), + link_with(Some(list), None), + link_with(Some(Object::Ref(launch)), None), + ] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 4, "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| chain(&doc, d.get(b"A").unwrap())).collect(); + assert_eq!( + left, + [vec!["GoTo", "JavaScript", "URI", "Named"], vec!["URI"], vec!["SubmitForm", "Named", "URI"], vec!["URI"]], + "the actions after a removed one run in their order, and a chain that comes back ends there" + ); + } + + #[test] + fn entries_that_are_not_actions_do_not_take_the_place_of_a_removed_action() { + let (doc, p, items) = linked_doc( + |doc, _| { + // A null, then a reference to an empty object, before the web link. + let empty = doc.add(Object::Null); + let next = Object::Array(vec![Object::Null, Object::Ref(empty), action("URI", None), Object::Null]); + vec![link_with(Some(action("GoToR", Some(next))), None)] + }, + &[ + action("Launch", Some(Object::Array(vec![Object::Null, action("URI", None)]))), + action("GoToR", Some(Object::Array(vec![Object::Null, Object::Null]))), + ], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks), (1, 2), "{report:?}"); + let links = annots_of(&doc, p); + assert_eq!(links.len(), 1, "the link keeps its web link, so it stays on the page"); + assert_eq!(chain(&doc, links[0].get(b"A").unwrap()), ["URI"]); + let bookmark = |r: ObjRef| doc.get(r).as_dict().unwrap().clone(); + assert_eq!(chain(&doc, bookmark(items[0]).get(b"A").unwrap()), ["URI"]); + assert!(bookmark(items[1]).get(b"A").is_none(), "a chain of nulls alone leaves no action"); + } + + #[test] + fn a_chain_deeper_than_the_limit_loses_its_tail_and_one_without_a_remote_action_stays() { + let deep = |n: usize, last: &str| (0..n).fold(action(last, None), |next, _| action("URI", Some(next))); + let (doc, p, _) = linked_doc(|_, _| vec![link_with(Some(deep(600, "GoToR")), None), link_with(Some(deep(600, "Named")), None)], &[]); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| chain(&doc, d.get(b"A").unwrap())).collect(); + assert!(left[0].len() < 600 && left[0].iter().all(|s| s == "URI"), "cut before the remote go-to: {} actions", left[0].len()); + assert_eq!(left[1].len(), 601, "a deep chain with nothing to remove is kept whole"); + } + + #[test] + fn a_long_chain_of_indirect_actions_stays_indirect_and_reads_back_after_saving() { + // 130 indirect web links, then a remote go-to: the parser reads at most 128 nested levels. + let mut tail = None; + let (doc, p, _) = linked_doc( + |doc, _| { + let mut r = doc.add(action("GoToR", None)); + for _ in 0..130 { + r = doc.add(action("URI", Some(Object::Ref(r)))); + } + tail = Some(r); + vec![link_with(Some(Object::Ref(r)), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let a = annots_of(&doc, p)[0].get(b"A").cloned().unwrap(); + assert!(matches!(a, Object::Ref(r) if Some(r) != tail), "a new indirect action: {a:?}"); + let old = Object::Ref(tail.unwrap()); + assert_eq!(chain(&doc, &old).last().map(String::as_str), Some("GoToR"), "the old chain is not edited"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let a = annots_of(&reopened, p)[0].get(b"A").cloned().unwrap(); + assert_eq!(chain(&reopened, &a), vec!["URI"; 130], "every web link reads back"); + } + + #[test] + fn promoted_action_chains_read_back_after_saving() { + let (doc, p, _) = linked_doc( + |doc, _| { + let tail = (0..126).fold(action("Named", None), |next, _| action("Named", Some(next))); + let first = doc.add(tail); + let next = Object::Array(vec![Object::Ref(first), action("URI", None)]); + let link = doc.add(link_with(Some(action("GoToR", Some(next))), None)); + vec![Object::Ref(link)] + }, + &[], + ); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let original = annots_of(&doc, p)[0].get(b"A").cloned().unwrap(); + assert_eq!(chain(&doc, &original).len(), 129); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let a = annots_of(&reopened, p)[0].get(b"A").cloned().unwrap(); + let expected: Vec<_> = std::iter::repeat_n("Named".to_string(), 127).chain(["URI".to_string()]).collect(); + assert_eq!(chain(&reopened, &a), expected); + } + + #[test] + fn shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document() { + use pdfcraft_optimize::OptimizeError; + // Forty levels of indirect actions, each listing the next level twice: 2^40 paths. + let shared = |doc: &mut Document, last: &str| { + let mut r = doc.add(action(last, None)); + for _ in 0..40 { + r = doc.add(action("URI", Some(Object::Array(vec![Object::Ref(r), Object::Ref(r)])))); + } + r + }; + let mut link = None; + let (doc, _, _) = linked_doc( + |doc, _| { + let a = shared(doc, "GoToR"); + let r = doc.add(link_with(Some(Object::Ref(a)), None)); + link = Some(r); + vec![Object::Ref(r)] + }, + &[], + ); + let on = Settings { discard_external_refs: true, ..Settings::default() }; + let r = optimize(&mut doc.clone(), &on); + assert!(matches!(r, Err(OptimizeError::ExternalRefLimit(o)) if Some(o) == link), "{r:?}"); + let (doc, p, _) = linked_doc( + |doc, _| { + let a = shared(doc, "Named"); + vec![link_with(Some(Object::Ref(a)), None)] + }, + &[], + ); + let before = annots_of(&doc, p); + let mut doc = doc; + let report = optimize(&mut doc, &on).unwrap(); + assert_eq!(report.external_links, 0, "{report:?}"); + assert_eq!(annots_of(&doc, p), before, "nothing to remove, however the actions are shared"); + } + + #[test] + fn a_shared_chain_without_remote_actions_is_looked_at_once() { + // 600 levels that each list a 2,000-action chain of web links sixty times, then a remote go-to. + let (doc, p, _) = linked_doc( + |doc, _| { + let mut g = doc.add(action("URI", None)); + for _ in 0..2_000 { + g = doc.add(action("URI", Some(Object::Ref(g)))); + } + let a = (0..600).fold(action("GoToR", None), |next, _| { + let mut list = vec![Object::Ref(g); 60]; + list.push(next); + action("URI", Some(Object::Array(list))) + }); + vec![link_with(Some(a), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left = annots_of(&doc, p); + assert!(!chain(&doc, left[0].get(b"A").unwrap()).iter().any(|s| s == "GoToR")); + } + + #[test] + fn a_link_named_through_an_indirect_subtype_is_discarded_and_a_form_field_keeps_its_action() { + let (doc, p, _) = linked_doc( + |doc, _| { + let name = doc.add(Object::name("Link")); + let Object::Dict(mut indirect) = link_with(Some(action("GoToR", None)), None) else { panic!("link_with() builds a dictionary") }; + indirect.set(b"Subtype".to_vec(), Object::Ref(name)); + let Object::Dict(mut widget) = link_with(Some(action("GoToR", None)), None) else { panic!("link_with() builds a dictionary") }; + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + vec![Object::Dict(indirect), Object::Dict(widget)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left = annots_of(&doc, p); + assert_eq!(left.len(), 1, "the link goes"); + assert_eq!(left[0].name(b"Subtype"), Some(&b"Widget"[..])); + assert_eq!(chain(&doc, left[0].get(b"A").unwrap()), ["GoToR"], "the form field keeps its action"); + } + + #[test] + fn thread_actions_into_another_file_and_data_imports_are_discarded_and_threads_here_stay() { + let with_file = |s: &str| { + let Object::Dict(mut d) = action(s, None) else { panic!("action() builds a dictionary") }; + d.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"other.pdf".to_vec()))); + d.set(b"D".to_vec(), Object::Int(0)); + Object::Dict(d) + }; + let here = { + let Object::Dict(mut d) = action("Thread", None) else { panic!("action() builds a dictionary") }; + d.set(b"D".to_vec(), Object::Int(0)); + Object::Dict(d) + }; + let (doc, p, _) = linked_doc( + |_, _| { + vec![link_with(Some(with_file("Thread")), None), link_with(Some(here.clone()), None), link_with(Some(with_file("ImportData")), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 2, "{report:?}"); + let left = annots_of(&doc, p); + assert_eq!(left.len(), 1); + assert_eq!(left[0].get(b"A"), Some(&here), "the thread in this document stays"); + } + + #[test] + fn an_outline_over_the_limit_stops_the_optimization() { + use pdfcraft_optimize::OptimizeError; + let bookmarks = vec![action("Named", None); 100_001]; + let (doc, _, _) = linked_doc(|_, _| Vec::new(), &bookmarks); + let r = optimize(&mut doc.clone(), &Settings { discard_external_refs: true, ..Settings::default() }); + assert!(matches!(r, Err(OptimizeError::ExternalRefLimit(_))), "{r:?}"); + } + + #[test] + fn bookmarks_stored_inside_other_bookmarks_lose_their_actions_too() { + let (mut doc, _, _) = linked_doc(|_, _| Vec::new(), &[]); + let bookmark = |title: &str, a: Object| { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(title.as_bytes().to_vec()))); + d.set(b"A".to_vec(), a); + d + }; + let third = doc.add(Object::Dict(bookmark("C", action("GoToR", None)))); + let mut second = bookmark("B", action("Launch", None)); + second.set(b"First".to_vec(), Object::Ref(third)); + let mut first = bookmark("A", action("GoToR", Some(action("Named", None)))); + first.set(b"Next".to_vec(), Object::Dict(second)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Dict(first)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(outlines))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_bookmarks, 3, "{report:?}"); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + let first = catalog.get(b"Outlines").and_then(Object::as_dict).and_then(|o| o.get(b"First")).and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(chain(&doc, first.get(b"A").unwrap()), ["Named"]); + let second = first.get(b"Next").and_then(Object::as_dict).cloned().unwrap(); + assert!(second.get(b"A").is_none()); + assert!(doc.get(third).as_dict().unwrap().get(b"A").is_none()); + } + + #[test] + fn a_removed_link_kept_by_the_structure_tree_reads_back_without_its_action() { + let mut link = None; + let (doc, p, _) = linked_doc( + |doc, _| { + let r = doc.add(link_with(Some(action("GoToR", None)), None)); + link = Some(r); + vec![Object::Ref(r)] + }, + &[], + ); + let mut doc = doc; + let mut objr = Dict::new(); + objr.set(b"Type".to_vec(), Object::name("OBJR")); + objr.set(b"Obj".to_vec(), Object::Ref(link.unwrap())); + objr.set(b"Pg".to_vec(), Object::Ref(p)); + let mut element = Dict::new(); + element.set(b"S".to_vec(), Object::name("Link")); + element.set(b"K".to_vec(), Object::Dict(objr)); + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"K".to_vec(), Object::Dict(element)); + let tree = doc.add(Object::Dict(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert!(annots_of(&doc, p).is_empty(), "the link leaves its page"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let catalog = reopened.get(reopened.root().unwrap()).as_dict().cloned().unwrap(); + let tree = reopened.dict(catalog.get(b"StructTreeRoot").unwrap()).unwrap(); + let element = reopened.dict(tree.get(b"K").unwrap()).unwrap(); + let objr = reopened.dict(element.get(b"K").unwrap()).unwrap(); + let kept = reopened.dict(objr.get(b"Obj").unwrap()).unwrap(); + assert_eq!(kept.name(b"Subtype"), Some(&b"Link"[..])); + assert!(kept.get(b"A").is_none(), "the link's object holds no action to another document: {kept:?}"); + } +} + #[test] fn progress_is_reported_per_image_and_a_refusal_cancels() { use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress}; @@ -3986,6 +4490,135 @@ fn named_dest_keys(doc: &Document) -> Vec> { arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() } +/// A `/PieceInfo` data dictionary holding `private`. +fn app_data(private: Object) -> Object { + let mut d = Dict::new(); + d.set(b"LastModified".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"D:20260101000000Z".to_vec()))); + d.set(b"Private".to_vec(), private); + Object::Dict(d) +} + +fn marked(doc: &mut Document, marker: &str, extra: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Marker".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(marker.as_bytes().to_vec()))); + for (k, v) in extra { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Dict(d)) +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap() +} + +fn contains(bytes: &[u8], text: &str) -> bool { + bytes.windows(text.len()).any(|w| w == text.as_bytes()) +} + +#[test] +fn search_indexes_in_the_catalog_go_and_other_application_data_stays() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + // The first index holds two objects that refer to each other, and one the page also uses. + let shared = marked(&mut doc, "SHARED-DATA", &[]); + let a = marked(&mut doc, "INDEX-A", &[("Shared", Object::Ref(shared))]); + let b = marked(&mut doc, "INDEX-B", &[("Next", Object::Ref(a))]); + doc.update_dict(a, |d| d.set(b"Next".to_vec(), Object::Ref(b))).unwrap(); + let c = marked(&mut doc, "INDEX-C", &[]); + let keep = marked(&mut doc, "KEEP-APP", &[]); + let mut info = Dict::new(); + info.set(b"IBM-ODIndexes".to_vec(), app_data(Object::Ref(a))); + info.set(b"MySearchINDEX".to_vec(), app_data(Object::Ref(c))); + info.set(b"Illustrator".to_vec(), app_data(Object::Ref(keep))); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + let mut page_info = Dict::new(); + page_info.set(b"PageIndexer".to_vec(), app_data(Object::Ref(shared))); + doc.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Dict(page_info))).unwrap(); + + // Off by default: nothing changes. + let mut untouched = doc.clone(); + assert!(!Settings::default().discard_search_index); + assert_eq!(optimize(&mut untouched, &Settings::default()).unwrap().search_indexes, 0); + let bytes = saved(&untouched); + assert!(["INDEX-A", "INDEX-B", "INDEX-C", "KEEP-APP"].iter().all(|m| contains(&bytes, m))); + + let r = optimize(&mut doc, &Settings { discard_search_index: true, ..Settings::default() }).unwrap(); + assert_eq!(r.search_indexes, 2); + let cat = doc.get(root).as_dict().cloned().unwrap(); + let info = cat.get(b"PieceInfo").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(info.iter().map(|(k, _)| k.clone()).collect::>(), vec![b"Illustrator".to_vec()]); + // The saved file leaves out the objects only the indexes held, the cycle included; the + // object the page also uses, the other application's data and the page's own entry stay. + let bytes = saved(&doc); + assert!(!["INDEX-A", "INDEX-B", "INDEX-C"].iter().any(|m| contains(&bytes, m))); + assert!(["SHARED-DATA", "KEEP-APP", "PageIndexer"].iter().all(|m| contains(&bytes, m))); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let cat = reopened.get(reopened.root().unwrap()).as_dict().cloned().unwrap(); + let info = reopened.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + assert!(info.contains(b"Illustrator") && info.len() == 1); +} + +#[test] +fn an_indirect_piece_info_is_left_for_its_other_users() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let idx = marked(&mut doc, "INDEX-DATA", &[]); + let mut info = Dict::new(); + info.set(b"Index".to_vec(), app_data(Object::Ref(idx))); + let info = doc.add(Object::Dict(info)); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + doc.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + let settings = Settings { discard_search_index: true, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + // The catalog's only entry was an index, so /PieceInfo goes; the page keeps the shared one. + assert_eq!(r.search_indexes, 1); + assert!(!doc.get(root).as_dict().unwrap().contains(b"PieceInfo")); + assert!(doc.get(info).as_dict().unwrap().contains(b"Index")); + assert!(contains(&saved(&doc), "INDEX-DATA")); + + // A /PieceInfo that is not a dictionary is left as it is. + let mut doc = Document::new_empty(); + page(&mut doc, &[], "BT ET"); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Int(7))).unwrap(); + assert_eq!(optimize(&mut doc, &settings).unwrap().search_indexes, 0); + assert_eq!(doc.get(root).as_dict().unwrap().int(b"PieceInfo"), Some(7)); +} + +#[test] +fn retained_deep_application_data_reads_back_after_discarding_an_index() { + let mut doc = Document::new_empty(); + page(&mut doc, &[], "BT ET"); + let deep = (0..127).fold(Object::Int(7), |child, _| { + let mut d = Dict::new(); + d.set(b"Child".to_vec(), child); + Object::Dict(d) + }); + let mut info = Dict::new(); + info.set(b"Index".to_vec(), app_data(Object::Int(0))); + info.set(b"OtherApp".to_vec(), deep); + let info = doc.add(Object::Dict(info)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + let retained = |doc: &Document| { + let catalog = doc.get(doc.root().unwrap()); + let info = doc.resolve(catalog.as_dict().unwrap().get(b"PieceInfo").unwrap()); + let mut value = info.as_dict().unwrap().get(b"OtherApp").unwrap().clone(); + for _ in 0..127 { + value = doc.resolve(&value).as_dict().unwrap().get(b"Child").unwrap().clone(); + } + value + }; + let mut doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(retained(&doc), Object::Int(7)); + let report = optimize(&mut doc, &Settings { discard_search_index: true, ..Settings::default() }).unwrap(); + assert_eq!(report.search_indexes, 1); + let reopened = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(retained(&reopened), Object::Int(7)); +} + /// Page content long enough for LZW codes to grow past 9 bits, where `EarlyChange` matters. fn lzw_ops() -> Vec { (0..300).flat_map(|i| format!("{} {} m {} {} l S\n", i % 97, i * 7 % 389, i * 13 % 612, i * 31 % 792).into_bytes()).collect() @@ -4684,6 +5317,32 @@ fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() { assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}"); } +/// Recolouring keeps a picture's soft mask, but not a picture whose mask has a `/Matte`: that is +/// a colour in the picture's own space. +#[test] +fn recolouring_keeps_soft_masks_and_leaves_matted_pictures() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + use pdfcraft_optimize::{ImageTarget, recolour_image}; + let mut doc = Document::new_empty(); + let picture = |mask| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(1)); + d.set(b"Height".to_vec(), Object::Int(1)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::flate(d, &[255, 0, 0]) + }; + let plain = doc.add(Object::Stream(Stream::flate(Dict::new(), &[128]))); + let new = recolour_image(&doc, &picture(plain), ImageTarget::Gray).unwrap(); + assert_eq!(new.dict.get(b"SMask"), Some(&Object::Ref(plain))); + let mut matted = Dict::new(); + matted.set(b"Matte".to_vec(), Object::Array(vec![Object::Int(0); 3])); + let matted = doc.add(Object::Stream(Stream::flate(matted, &[128]))); + assert!(recolour_image(&doc, &picture(matted), ImageTarget::Gray).is_none()); +} + /// A one-bit stencil of `w` × `h`, solid but for a hole in its middle. fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef { let row = (w as usize).div_ceil(8); diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs index b0bf222991..694755c074 100644 --- a/crates/organize/src/attachments.rs +++ b/crates/organize/src/attachments.rs @@ -7,7 +7,7 @@ use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; use crate::OrganizeError; -use crate::import::{name_tree_entries, set_attachments}; +use crate::import::{name_tree_entries, set_attachments, set_attachments_exact}; /// The keys of the document-level attachments, in name-tree order. pub fn attachment_keys(doc: &Document) -> Vec { @@ -82,6 +82,37 @@ pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeEr if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) } } +/// Set or clear the description of a document-level attachment without replacing its file +/// specification or embedded stream. +pub fn set_attachment_description(doc: &mut Document, key: &str, description: Option<&str>) -> Result<(), OrganizeError> { + let mut all = entries(doc); + let Some((_, value)) = all.iter_mut().find(|(name, _)| PdfString::literal(name.clone()).to_text() == key) else { + return Err(OrganizeError::Invalid(format!("there is no attachment named {key}"))); + }; + let description = description.map(str::trim).filter(|s| !s.is_empty()).map(PdfString::text).map(Object::String); + match value { + Object::Ref(reference) => { + doc.update_dict(*reference, |spec| match description { + Some(description) => spec.set(b"Desc".to_vec(), description), + None => { + spec.remove(b"Desc"); + } + })?; + } + Object::Dict(spec) => { + match description { + Some(description) => spec.set(b"Desc".to_vec(), description), + None => { + spec.remove(b"Desc"); + } + } + set_attachments_exact(doc, all)?; + } + _ => return Err(OrganizeError::Invalid(format!("attachment {key} has an invalid file specification"))), + } + Ok(()) +} + /// The `/EmbeddedFiles` entries (key, file specification), in tree order. fn entries(doc: &Document) -> Vec<(Vec, Object)> { let Some(tree) = doc diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index c9b4b85a67..3d4791c83d 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -31,8 +31,8 @@ mod combine; mod copy; mod fields; -pub(crate) use carry::set_attachments; use carry::{Mark, source_outline, write_outline_level}; +pub(crate) use carry::{set_attachments, set_attachments_exact}; pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index fdfb9ab597..8f0e349ea2 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -23,35 +23,48 @@ pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut /// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + write_attachments(dst, &mut entries, true) +} + +/// Rewrite the name tree without changing attachment keys (used when editing one file spec). +pub(crate) fn set_attachments_exact(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + write_attachments(dst, &mut entries, false) +} + +/// Write a flat, sorted `/EmbeddedFiles` name tree, optionally renaming duplicate keys. +fn write_attachments(dst: &mut Document, entries: &mut [(Vec, Object)], uniquify: bool) -> Result<(), OrganizeError> { if entries.is_empty() { return Ok(()); } // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent // or Chinese letters) must not get ASCII bytes appended to it. - let reserved: std::collections::HashSet = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect(); - let mut seen = std::collections::HashSet::new(); - for (k, _) in entries.iter_mut() { - let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); - let mut text = base.clone(); - let mut n = 2; - if !seen.insert(text.clone()) { - loop { - text = format!("{base} ({n})"); - n += 1; - if !reserved.contains(&text) && seen.insert(text.clone()) { - break; + if uniquify { + let reserved: std::collections::HashSet = + entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect(); + let mut seen = std::collections::HashSet::new(); + for (k, _) in entries.iter_mut() { + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); + let mut n = 2; + if !seen.insert(text.clone()) { + loop { + text = format!("{base} ({n})"); + n += 1; + if !reserved.contains(&text) && seen.insert(text.clone()) { + break; + } } } - } - if text != base { - *k = pdfcraft_cos::PdfString::text(&text).bytes; + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } } } entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut names = Vec::new(); - for (k, v) in entries { - names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); - names.push(v); + for (k, v) in entries.iter() { + names.push(Object::String(pdfcraft_cos::PdfString::literal(k.clone()))); + names.push(v.clone()); } let mut tree = Dict::new(); tree.set(b"Names".to_vec(), Object::Array(names)); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 2fd997f549..7996ee8ea6 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -27,7 +27,7 @@ mod transition; pub mod view; mod xmp; -pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; +pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name, set_attachment_description}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use comments::import_comments; pub use dedupe::dedupe_resources; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 451a6dc397..a04d9f2dcb 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4177,6 +4177,43 @@ fn attachments_are_added_listed_and_deleted() { assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none()); } +#[test] +fn attachment_description_changes_only_the_file_spec_and_can_be_cleared() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let key = add_attachment(&mut doc, "notes.txt", b"keep these bytes", Some("Original"), Some("text/plain"), None).unwrap(); + let root = doc.root().unwrap(); + let names = doc.get(root).as_dict().unwrap().get(b"Names").cloned().unwrap(); + let tree = doc.resolve(&names).as_dict().unwrap().get(b"EmbeddedFiles").cloned().unwrap(); + let tree = doc.resolve(&tree); + let pairs = tree.as_dict().unwrap().get(b"Names").cloned().unwrap(); + let resolved_pairs = doc.resolve(&pairs); + let pairs = resolved_pairs.as_array().unwrap(); + let spec_ref = pairs.get(1).and_then(Object::as_ref).unwrap(); + let stream_ref = doc.get(spec_ref).as_dict().unwrap().get(b"EF").and_then(Object::as_dict).unwrap().reference(b"F").unwrap(); + doc.update_dict(spec_ref, |spec| spec.set(b"CustomKey".to_vec(), Object::name("preserved"))).unwrap(); + + set_attachment_description(&mut doc, &key, Some("Revised description")).unwrap(); + let spec_object = doc.get(spec_ref); + let spec = spec_object.as_dict().unwrap(); + assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some("Revised description")); + assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some("notes.txt")); + assert_eq!(spec.get(b"CustomKey"), Some(&Object::name("preserved"))); + assert_eq!(spec.get(b"EF").and_then(Object::as_dict).and_then(|ef| ef.reference(b"F")), Some(stream_ref)); + let stream_object = doc.get(stream_ref); + let pdfcraft_cos::Object::Stream(stream) = stream_object.as_ref() else { panic!("the attachment file remains a stream") }; + assert_eq!(stream.decoded().unwrap(), b"keep these bytes"); + assert_eq!(attachment_keys(&doc), ["notes.txt"]); + + set_attachment_description(&mut doc, &key, Some(" ")).unwrap(); + assert!(!doc.get(spec_ref).as_dict().unwrap().contains(b"Desc")); + let reopened = save_and_reopen(&doc); + assert_eq!(attachment_keys(&reopened), ["notes.txt"]); + assert_eq!( + set_attachment_description(&mut doc, "missing.txt", Some("x")), + Err(OrganizeError::Invalid("there is no attachment named missing.txt".into())) + ); +} + #[test] fn rotate_repeated_indices_accumulate_from_the_current_page_value() { for level in 0..3 { diff --git a/crates/print/README.md b/crates/print/README.md index e846243db5..ba1de0cac7 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -49,9 +49,11 @@ Headless `doc_print` defaults are unchanged. `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). PdfCraft retains page selection, imposition, annotations and print-permission checks. -CUPS jobs stream through stdin with `fit-to-page=false`; on macOS they also carry the job -name and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`) as the system print dialog -sends them, which some vendors' filters need (#5743). Windows renders sheets through +CUPS jobs stream through stdin with `fit-to-page=false`, and with `orientation-requested=4` when +every sheet is landscape (`cups_orientation`), since CUPS would otherwise shrink a landscape +sheet into portrait paper. On macOS they also carry the job name and owner +(`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`) as the system print dialog sends them, which +some vendors' filters need (#5743). Windows renders sheets through `Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex use the corresponding Windows binding edges. Random, exclusively created Windows job files diff --git a/crates/print/build.rs b/crates/print/build.rs new file mode 100644 index 0000000000..82b3ce3fac --- /dev/null +++ b/crates/print/build.rs @@ -0,0 +1,23 @@ +//! Windows (MSVC) only: delay-load the print DLLs in this crate's own test executable, as the +//! applications do (`apps/pdfcraft{,-cli}/build.rs`), so it starts on a Windows installation that +//! lacks them (#2630) and its tests of the missing-DLL guard in `src/spool.rs` run there. Link +//! arguments from a build script reach only this package's own executables (its tests), never +//! the crates that depend on it; each application sets its own. +//! +//! On every other target this does nothing. + +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Same list in `apps/pdfcraft{,-cli}/build.rs` and +/// `src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") || std::env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("msvc") { + return; + } + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg=delayimp.lib"); +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index c05aeaa1e3..74810db8d6 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -42,4 +42,6 @@ pub use settings::{ }; #[cfg(test)] use sheets::text_direction; -pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up, texts_read_up}; +pub use sheets::{ + LABEL_MARGIN, MARGIN, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, +}; diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index dfdf4b7644..cca4bdc07e 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -3,6 +3,30 @@ use super::imposition::decoded_contents; use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; use super::{DecodeBudget, Dict, Document, Matrix, Object}; +/// The CUPS job option that keeps landscape sheets landscape (`orientation-requested` 4). +/// +/// The print-ready PDF's sheets are already laid out on their paper, but the paper is named +/// portrait (`Letter`), and without this option CUPS leaves a landscape sheet unrotated and +/// shrinks it into the portrait paper instead of turning it to fill it (#2713). It is a property +/// of the whole job, so it is given only when *every* sheet is landscape; a job that mixes +/// orientations is left to the spooler, as portrait sheets would be turned wrongly. +pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { + let landscape = !sheets.is_empty() && sheets.iter().all(|s| s.size.0 > s.size.1); + landscape.then(|| ("orientation-requested".to_string(), "4".to_string())) +} + +/// Add [`cups_orientation`] to a CUPS job's `options`, for the Print dialog's jobs and +/// `doc_print`'s alike, unless they already name an orientation (a driver choice, or one a +/// caller passed, in any case: CUPS reads option names without regard to case): `lp` must not be +/// given the option twice. Nothing on Windows, whose print ticket sets each sheet's orientation +/// itself. +pub fn add_cups_orientation(options: &mut Vec<(String, String)>, sheets: &[Sheet]) { + if cfg!(windows) || options.iter().any(|(k, _)| k.eq_ignore_ascii_case("orientation-requested")) { + return; + } + options.extend(cups_orientation(sheets)); +} + /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index a83da3ed26..b16f57026d 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -3,9 +3,62 @@ pub use craft_print::{ Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, Scaling, lp_args, lpstat_command, parse_lpoptions, parse_lpstat, - parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers, printers_parsed, + parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, }; +/// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load +/// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list) +/// so a Windows installation without them still starts (#2630); a call into a missing +/// delay-loaded DLL would end the process, so printing checks for them first. +#[cfg(windows)] +pub(crate) const PRINT_DLLS: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + +/// The first print DLL missing from `system32`, if any. +#[cfg(windows)] +pub(crate) fn missing_print_dll_in(system32: &std::path::Path) -> Option<&'static str> { + PRINT_DLLS.into_iter().find(|dll| !system32.join(dll).is_file()) +} + +#[cfg(all(windows, test))] +thread_local! { + /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the + /// public entry points can be checked against a Windows that lacks them. + pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell + /// the guard answered without relying on the machine having no printers. + pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The first print DLL this Windows installation lacks. `None` when all are there, and when the +/// system folder is unknown (the loader then decides, as before). +fn missing_print_dll() -> Option<&'static str> { + #[cfg(windows)] + { + #[cfg(test)] + if let Some(dir) = SYSTEM32_FOR_TEST.with(|d| d.borrow().clone()) { + return missing_print_dll_in(&dir); + } + let root = std::env::var_os("SystemRoot").or_else(|| std::env::var_os("windir"))?; + missing_print_dll_in(&std::path::Path::new(&root).join("System32")) + } + #[cfg(not(windows))] + None +} + +fn unavailable(dll: &str) -> crate::PrintError { + crate::PrintError::Spool(format!("printing needs {dll}, which this Windows installation lacks; use Save as PDF instead")) +} + +/// The system's printers; none on a Windows installation without the print DLLs. +pub fn printers() -> Vec { + if missing_print_dll().is_some() { + return Vec::new(); + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::printers() +} + /// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. @@ -192,12 +245,18 @@ fn spooled(job: &Job) -> Job { } pub fn submit(pdf: &[u8], job: &Job) -> Result { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } /// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the /// whole PDF at once and reports nothing). pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 4f77607bde..0cd844a53d 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -678,6 +678,8 @@ fn printer_options_come_from_the_ppd() { assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped"); } +/// A driver option's current choice is read from `lpoptions`, and a job passes `lp` only the +/// driver options that are well-formed PPD keywords, never the ones PdfCraft sets itself. #[test] fn printer_option_defaults_and_job_arguments() { let current = @@ -698,6 +700,53 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +/// CUPS is asked to turn the sheets (`orientation-requested` 4) when every one is landscape, a +/// booklet's included; portrait, mixed, square and empty jobs say nothing (#2713). +#[test] +fn landscape_sheets_ask_cups_to_turn_the_page() { + let landscape = |size: (f64, f64)| Sheet { size, ..Sheet::default() }; + let ask = Some(("orientation-requested".to_string(), "4".to_string())); + // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. + let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&page), ask); + let booklet = + layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) + .unwrap(); + assert_eq!(cups_orientation(&booklet), ask); + // Portrait sheets, mixed jobs and nothing at all say nothing. + let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&portrait), None); + assert_eq!(cups_orientation(&[landscape((792.0, 612.0)), landscape((612.0, 792.0))]), None, "a mixed job is not turned as a whole"); + assert_eq!(cups_orientation(&[landscape((612.0, 612.0))]), None, "a square sheet has no orientation"); + assert_eq!(cups_orientation(&[]), None); + // `lp` is given it, among the job's other options. + let job = Job { options: ask.into_iter().collect(), ..Job::default() }; + assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); +} + +/// Both submission paths add the orientation through `add_cups_orientation`: after the job's +/// other options, never over an orientation the job already names (a driver choice), so `lp` is +/// never given it twice, and not on Windows, whose print ticket sets it. +#[test] +fn a_job_that_names_its_orientation_keeps_it() { + let landscape = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + let media = ("media".to_string(), "Letter".to_string()); + let mut options = vec![media.clone()]; + add_cups_orientation(&mut options, &landscape); + let turned = if cfg!(windows) { vec![media] } else { vec![media, ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(options, turned); + // Added again, it isn't doubled. + add_cups_orientation(&mut options, &landscape); + assert_eq!(options, turned); + // A choice already made stands, however its name is cased: CUPS reads option names without + // regard to case, so a second one would replace it. + for key in ["orientation-requested", "Orientation-Requested", "ORIENTATION-REQUESTED"] { + let mut chosen = vec![(key.to_string(), "5".to_string())]; + add_cups_orientation(&mut chosen, &landscape); + assert_eq!(chosen, [(key.to_string(), "5".to_string())], "{key}"); + } +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; @@ -1334,6 +1383,52 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); } +/// #2630: a Windows installation without the print DLLs is told so, before anything calls into +/// a delay-loaded DLL that is not there. +#[cfg(windows)] +#[test] +fn missing_print_dlls_are_named() { + use crate::spool::{PRINT_DLLS, missing_print_dll_in}; + let dir = std::env::temp_dir().join(format!("pdfcraft-print-dlls-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!(missing_print_dll_in(&dir), Some(PRINT_DLLS[0])); + std::fs::write(dir.join(PRINT_DLLS[0]), b"").unwrap(); + assert_eq!(missing_print_dll_in(&dir), Some(PRINT_DLLS[1])); + std::fs::write(dir.join(PRINT_DLLS[1]), b"").unwrap(); + assert_eq!(missing_print_dll_in(&dir), None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2630: on a Windows without the print DLLs, the public entry points answer before any call +/// into the delay-loaded DLLs: no printers, and printing refused naming the DLL. The job names a +/// printer that doesn't exist and the bytes aren't a PDF, so a missing guard fails here with +/// another error rather than reaching a real printer. +#[cfg(windows)] +#[test] +fn printing_without_the_print_dlls_is_refused_before_the_spooler() { + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = Some(dir.clone())); + let job = crate::spool::Job { printer: Some("PdfCraft test: no such printer".into()), ..Default::default() }; + let named = |r: Result| match r { + Err(crate::PrintError::Spool(m)) => m.contains(PRINT_DLLS[0]), + _ => false, + }; + assert!(named(submit(b"not a PDF", &job)), "submit names the missing DLL"); + let mut called = false; + assert!(named(submit_with_progress(b"not a PDF", &job, &mut |_, _| called = true)), "submit_with_progress names it"); + assert!(!called, "no sheet reached a spooler"); + // Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked. + PRINTER_BACKEND_CALLS.with(|c| c.set(0)); + assert!(printers().is_empty(), "no printers are listed"); + assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked"); + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_page_form_without_annotations_shares_the_pages_indirect_resources() { let mut doc = fixture(2); diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 70e7fc4dd0..8b6db74534 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -24,6 +24,10 @@ pdfcraft-xfa = { workspace = true } pdfcraft-filters = { workspace = true } regex = { workspace = true } thiserror = { workspace = true } +# NFKC for the metadata check; already in the build through pdfcraft-crypt (stringprep). +unicode-normalization = "0.1" +# Reads the PDF/A and PDF/UA identification by namespace; already in the build through pdfcraft-xfa. +roxmltree = "0.21" sha2 = "0.11" [dev-dependencies] diff --git a/crates/redact/README.md b/crates/redact/README.md index f78f819c03..51ac11f086 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -47,7 +47,61 @@ clear_marks(&mut doc, None)?; // remove marks without app - **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the - page as a `/PCMark /Redaction` stream. + page as a `/PCMark /Redaction` stream; +- **metadata** that repeats the removed text (#2448): `/Info` entries containing it or wholly + part of it as whole words (three letters or digits at least), and the catalog's and pages' + XMP streams whose text (element content, attribute values other than `xmlns` declarations, + comments and processing instructions, entities decoded) contains it or has a value wholly + part of it, removed whole. When the catalog's packet goes, its PDF/A (`pdfaid`) and PDF/UA + (`pdfuaid`) identification, read by namespace, is written back in a packet holding nothing + else: the part number, the PDF/A conformance letter, amendment and revision, and PDF/UA's + part and revision, each kept only if it is the short code it must be and doesn't itself hold + the removed text (a one-character code can't, so it is kept even when the text couldn't be + searched for), plus a fixed extension schema for `pdfuaid` under PDF/A-1 to -3, left out + with the PDF/UA claim if the redacted words are among its words. `Report::identification` + says, for each, whether the claim was kept or lost (a value that isn't a valid code or holds + the text, or a packet that can't be read); the notification and `redact_apply` say it too. + `/Info` entries that remain aren't mirrored into the new packet, so a PDF/A validator may + still report them. The removed + glyphs' text is collected in runs by position (character and word spacing included, so + letter-spaced text stays one word), and each run (two or more letters or digits) and each of + its words of three or more are matched without regard to case or spacing, and also by their + letters and digits alone when they have three or more: "Blue fin" and "BLUE-FIN" hold + "Bluefin", and so does a heading whose letters `TJ` numbers spread apart (collected as + "B L U E F I N", since a `TJ` gap can't be told from a word space). A value is wholly part of + a run when its letters and digits are, starting and ending where the run's words do, so + "True" isn't part of "untrue". This over-matches only towards removal: "is a" also holds + "Lisa". Both sides are compared in Unicode compatibility form (NFKC) and lower case, so a + decomposed "Müller" (u and U+0308), full-width letters and a "fi" ligature from a + `ToUnicode` map match their plain forms. A string behind an `FF FE` mark, which ISO 32000-2 + §7.9.2.2.1 doesn't define, is read as UTF-16LE, here and in the places reported below, rather + than through PDFDocEncoding, in which it could never match. A value that shares only words of one or two + letters with the removed text, and isn't wholly part of it, stays: + Sanitize's metadata category removes the rest. `Report::metadata` counts what went. + - **Fail-closed.** When the check can't know what the removed text was, every `/Info` + string and every catalog and page XMP stream goes: more than 100,000 runs on a page, a run + over 64 KiB, or more than 16 MiB in the whole document removed (the collection limits); a removed glyph whose text isn't known (no + `/ToUnicode` or encoding gives it); a form XObject removed without being read, because it + is nested past the depth limit (`MAX_DEPTH`, 50) or its content can't be read; or more + than 20,000 terms, or a run longer than 4,096 characters, to search for. Once the + search's work limit (2^28 steps) runs out, every entry and packet not yet checked goes + too. An XMP packet that isn't valid UTF-8 or UTF-16, can't be decoded strictly (damaged or + cut-short filter data, or more than one filter), or decodes to more than + 16 MiB goes without being read, and a value whose normalized form would be more than four + times as long (U+FDFA alone is 18 letters) counts as holding the text. + - **Not scrubbed, but reported** (Remove hidden information's categories remove them): bookmark + titles, attachments (names in the document's list of embedded files, file names and + descriptions, and the files of file attachment comments), the comments that remain (their + text, rich text, subject and author; not form fields or pop-ups), and XMP streams on anything + but the catalog and the pages (images, fonts, form XObjects, annotations and other objects). + `apply` changes none of them, but counts those that still hold the removed text, matched as + above, in `Report::still_holding` (`Elsewhere`), so the interface and the `redact_apply` tool + can say where it is; this search has a work limit of its own, past which every place counts. + When the removed text couldn't be searched for (the cases above in which every `/Info` + string goes), every one of them that holds any text is counted and `unsearched` is set; an XMP + stream or rich text stream that can't be read counts as holding it. The walks are bounded: at + most 100,000 bookmarks and 100,000 name-tree nodes (32 deep) are read, each once, and a walk + cut short counts one more place. A verification pass re-reads each redacted page and fails the operation if any glyph or inline image is still under an area. It checks every content stream the page had, whatever its diff --git a/crates/redact/src/elsewhere.rs b/crates/redact/src/elsewhere.rs new file mode 100644 index 0000000000..42ddbcf5bc --- /dev/null +++ b/crates/redact/src/elsewhere.rs @@ -0,0 +1,222 @@ +//! Places [`crate::apply`] leaves as they are that may still hold the removed text (#2448 +//! review): bookmark titles, attachments, the comments that remain, and XMP on objects other than +//! the catalog and the pages. Removing them is Remove hidden information's job (its bookmarks, +//! attachments, comments and metadata categories); applying redactions only counts them, so the +//! user can be told. Nothing here changes the document. +//! +//! The text is matched as the metadata check matches it ([`crate::metadata::Search`]), with a +//! work limit of its own. Every walk is bounded: outline items and name-tree nodes are each read +//! once (a seen-set for indirect ones) up to a cap, and the XMP walk is Remove hidden +//! information's own. + +use std::collections::HashSet; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use crate::metadata::Search; + +/// Places that may still hold the removed text after [`crate::apply`], which it doesn't change. +/// Remove hidden information removes them. Each count is of places, not of occurrences. +/// +/// When the removed text couldn't be searched for (`unsearched`: the text of a removed glyph +/// isn't known, a form was removed unread, or there was too much removed text to collect or +/// search for, as for the metadata check), every place holding any text is counted, since any +/// of them may hold it. A count may also include places that couldn't be read or that lie past +/// a walk's cap (one for each walk cut short), which count as holding it, and, once the search's +/// work limit runs out, every place not yet checked that holds any text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Elsewhere { + /// Bookmarks (outline items) whose title holds it. + pub bookmarks: usize, + /// Embedded files whose name in the document's list of attachments (`/EmbeddedFiles`), + /// file name (`/F`, `/UF`) or description (`/Desc`) holds it, and file attachment + /// annotations whose file's name or description does. + pub attachments: usize, + /// Annotations left on the pages (not form field widgets or pop-ups) whose text + /// (`/Contents`, rich text `/RC`, subject `/Subj`) or author (`/T`) holds it. + pub comments: usize, + /// XMP streams on objects other than the catalog and the pages (images, fonts, form + /// XObjects, annotations…) whose text holds it, or that can't be read. + pub xmp: usize, + /// The removed text couldn't be searched for, so the counts are of every place that holds + /// any text. + pub unsearched: bool, +} + +impl Elsewhere { + /// All the places counted. + pub fn total(&self) -> usize { + self.bookmarks.saturating_add(self.attachments).saturating_add(self.comments).saturating_add(self.xmp) + } +} + +/// The most outline items read. +const MAX_OUTLINE_ITEMS: usize = 100_000; +/// The most name-tree nodes read, and how deep the tree is followed. +const MAX_NAME_TREE_NODES: usize = 100_000; +const MAX_NAME_TREE_DEPTH: usize = 32; + +/// Count the places that hold the `removed` text (see [`Elsewhere`]). `complete` is false when +/// the collection of removed text was cut short. +pub(crate) fn find(doc: &Document, removed: &[String], complete: bool) -> Elsewhere { + let search = Search::of(removed, complete); + if search.is_nothing() { + return Elsewhere::default(); + } + // A blank value holds nothing, even when everything counts. + let hit = |o: Option<&Object>| { + o.and_then(|o| doc.resolve(o).as_string().map(crate::metadata::text_string)).is_some_and(|t| !t.trim().is_empty() && search.text(&t)) + }; + Elsewhere { + bookmarks: bookmarks(doc, &hit), + attachments: attachments(doc, &hit), + comments: comments(doc, &search, &hit), + xmp: xmp(doc, &search), + unsearched: search.is_everything(), + } +} + +/// Outline items whose `/Title` holds the text. +fn bookmarks(doc: &Document, hit: &dyn Fn(Option<&Object>) -> bool) -> usize { + let Some(root) = doc.root() else { return 0 }; + let Some(first) = + doc.get(root).as_dict().and_then(|c| c.get(b"Outlines").cloned()).and_then(|o| doc.resolve(&o).as_dict()?.get(b"First").cloned()) + else { + return 0; + }; + let (mut n, mut read) = (0usize, 0usize); + let mut seen = HashSet::new(); + let mut next = vec![first]; + while let Some(o) = next.pop() { + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + let Some(item) = doc.resolve(&o).as_dict().cloned() else { continue }; + if read >= MAX_OUTLINE_ITEMS { + // Items past the cap aren't read: count one for them. + return n.saturating_add(1); + } + read += 1; + n += usize::from(hit(item.get(b"Title"))); + next.extend([b"Next".as_slice(), b"First"].into_iter().filter_map(|k| item.get(k).cloned())); + } + n +} + +/// Embedded files in the `/EmbeddedFiles` name tree whose name, file name or description holds +/// the text, and file attachment annotations whose file's do. +fn attachments(doc: &Document, hit: &dyn Fn(Option<&Object>) -> bool) -> usize { + let file = |spec: Option<&Object>| { + spec.and_then(|s| doc.resolve(s).as_dict().cloned()).is_some_and(|d| [b"F".as_slice(), b"UF", b"Desc"].iter().any(|k| hit(d.get(k)))) + }; + let mut n = 0usize; + let tree = doc + .root() + .and_then(|r| doc.get(r).as_dict()?.get(b"Names").cloned()) + .and_then(|names| doc.resolve(&names).as_dict()?.get(b"EmbeddedFiles").cloned()); + if let Some(tree) = tree { + let mut seen = HashSet::new(); + let mut next = vec![(tree, 0usize)]; + let mut read = 0usize; + while let Some((o, depth)) = next.pop() { + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + let Some(node) = doc.resolve(&o).as_dict().cloned() else { continue }; + if read >= MAX_NAME_TREE_NODES || depth > MAX_NAME_TREE_DEPTH { + // A node past the cap isn't read: count one for what it holds. + n = n.saturating_add(1); + break; + } + read += 1; + if let Some(leaves) = node.get(b"Names").map(|l| doc.resolve(l)).and_then(|l| l.as_array().cloned()) { + for pair in leaves.chunks(2) { + n += usize::from(hit(pair.first()) || file(pair.get(1))); + } + } + if let Some(kids) = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + next.extend(kids.into_iter().map(|k| (k, depth.saturating_add(1)))); + } + } + } + for page in pdfcraft_model::pages(doc) { + for a in crate::annots_of(doc, &page.dict) { + let Some(a) = doc.resolve(&a).as_dict().cloned() else { continue }; + if a.name(b"Subtype") == Some(b"FileAttachment".as_slice()) { + n += usize::from(file(a.get(b"FS"))); + } + } + } + n +} + +/// Annotations on the pages, other than form field widgets and pop-ups (which show their +/// parent's text), whose text or author holds it. +fn comments(doc: &Document, search: &Search, hit: &dyn Fn(Option<&Object>) -> bool) -> usize { + let mut seen = HashSet::new(); + let mut n = 0usize; + for page in pdfcraft_model::pages(doc) { + for a in crate::annots_of(doc, &page.dict) { + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } + let Some(a) = doc.resolve(&a).as_dict().cloned() else { continue }; + if matches!(a.name(b"Subtype"), Some(b"Widget" | b"Popup")) { + continue; + } + // Rich text is XHTML: its text is searched, not its markup. A rich text stream that + // can't be read counts as holding it. + let rich = a.get(b"RC").is_some_and(|o| rich_text(doc, o).is_none_or(|t| !t.trim().is_empty() && search.text(&t))); + n += usize::from(rich || [b"Contents".as_slice(), b"T", b"Subj"].iter().any(|k| hit(a.get(k)))); + } + } + n +} + +/// The text of a rich text string or stream (`/RC`), markup left out; `None` when a stream +/// can't be read (strictly, as UTF-8, within the XMP limit). +fn rich_text(doc: &Document, o: &Object) -> Option { + let xml = match &*doc.resolve(o) { + Object::String(s) => crate::metadata::text_string(s), + Object::Stream(s) if s.filters().len() <= 1 => { + String::from_utf8(doc.with_direct_parms(s).decoded_first_filter_strict(crate::metadata::MAX_XMP_BYTES).ok()?).ok()? + } + Object::Stream(_) => return None, + _ => return Some(String::new()), + }; + Some(crate::metadata::xml_values(&xml).join(" ")) +} + +/// XMP streams on anything reachable from the catalog other than the catalog's and the pages' +/// own, each counted once, that hold the text or can't be read. +fn xmp(doc: &Document, search: &Search) -> usize { + let scrubbed: HashSet = crate::metadata::catalog_and_pages(doc).into_iter().collect(); + let Ok(values) = crate::sanitize::owned_values(doc, b"Metadata") else { + // A graph too deep to walk: some XMP may be there, unread. + return 1; + }; + let mut seen = HashSet::new(); + let mut n = 0usize; + for (holder, own, v) in values { + if own && scrubbed.contains(&holder) { + continue; + } + if let Some(r) = v.as_ref() + && !seen.insert(r) + { + continue; + } + // An entry that isn't a stream holds no XMP. + if !matches!(&*doc.resolve(&v), Object::Stream(_)) { + continue; + } + n += usize::from(search.xmp(doc, &v)); + } + n +} diff --git a/crates/redact/src/identification.rs b/crates/redact/src/identification.rs new file mode 100644 index 0000000000..b509481adf --- /dev/null +++ b/crates/redact/src/identification.rs @@ -0,0 +1,204 @@ +//! The PDF/A and PDF/UA identification of a document XMP packet the metadata check removes +//! (#2539 review). +//! +//! The scrub removes the catalog's XMP packet whole when it holds the removed text, and the +//! packet is where a file says it conforms to PDF/A (ISO 19005, the `pdfaid` schema) or PDF/UA +//! (ISO 14289, `pdfuaid`). Rather than drop that claim silently, the scrub writes back a packet +//! holding only those identification properties, read by namespace (not by prefix), each +//! checked to be the short code it must be (a part number, a conformance letter, a year), and +//! none of them holding the removed text. Nothing else of the old packet is copied. What became +//! of each claim is reported ([`Identification`]). + +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use crate::metadata::Search; + +const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; +/// Packets with more `=` than this (attributes, roughly) aren't parsed: the parser checks each +/// element's attributes against each other, so a huge attribute list would be slow. +const MAX_EQUALS: usize = 4096; +/// The most XML nodes parsed. +const MAX_NODES: u32 = 100_000; +/// The PDF/A extension schema describing `pdfuaid:part`, fixed text. +const PDFUA_EXTENSION: &str = concat!( + r#""#, + r#""#, + r#"PDF/UA identification schemahttp://www.aiim.org/pdfua/ns/id/pdfuaid"#, + r#""#, + r#"partIntegerinternalThe part of ISO 14289 the file conforms to"#, + r#""#, +); + +/// What became of a conformance claim when the packet holding it was removed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Claim { + /// No removed packet made the claim. + #[default] + None, + /// The packet written in its place makes it again. + Kept, + /// The removed packet made it (or, when it couldn't be read, seems to have), and it couldn't + /// be written back: a property wasn't a valid code, held the removed text, or the packet + /// couldn't be read. + Lost, +} + +/// What became of the document's PDF/A and PDF/UA identification when the metadata check +/// removed the catalog's XMP packet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Identification { + /// PDF/A (`pdfaid`: part, conformance, and amendment and revision when present). + pub pdfa: Claim, + /// PDF/UA (`pdfuaid`: part, and revision when present). + pub pdfua: Claim, +} + +impl Identification { + /// A claim was lost. + pub fn lost(&self) -> bool { + self.pdfa == Claim::Lost || self.pdfua == Claim::Lost + } +} + +/// The identification of the removed catalog packet `xmp`, and the packet to write in its place +/// (`None` when nothing is kept). +pub(crate) fn keep(doc: &Document, xmp: &Object, search: &Search) -> (Identification, Option) { + let Some(text) = crate::metadata::xmp_text(doc, xmp) else { + // Unreadable: a claim it seems to make, by its namespace or usual prefix in the raw + // bytes, is lost. + let seen = |raw: &[u8], ns: &str, prefix: &[u8]| { + let has = |n: &[u8]| raw.windows(n.len()).any(|w| w == n); + if has(ns.as_bytes()) || has(prefix) { Claim::Lost } else { Claim::None } + }; + let id = match &*doc.resolve(xmp) { + Object::Stream(s) => Identification { pdfa: seen(&s.raw, PDFA_ID, b"pdfaid:"), pdfua: seen(&s.raw, PDFUA_ID, b"pdfuaid:") }, + _ => Identification::default(), + }; + return (id, None); + }; + let Some(found) = read(&text) else { + let seen = |ns: &str, prefix: &str| if text.contains(ns) || text.contains(prefix) { Claim::Lost } else { Claim::None }; + return (Identification { pdfa: seen(PDFA_ID, "pdfaid:"), pdfua: seen(PDFUA_ID, "pdfuaid:") }, None); + }; + // A value is kept when it is a valid code and doesn't hold the removed text. One letter or + // digit can't be a term, so it is kept even when everything counts as holding the text. + let ok = |v: &Option, valid: fn(&str) -> bool| -> Option { + let v = v.as_deref()?; + let short = v.chars().filter(|c| c.is_alphanumeric()).count() < 2; + (valid(v) && (short || !search.text(v))).then(|| v.to_string()) + }; + let mut id = Identification::default(); + let mut body = String::new(); + + let a = &found.pdfa; + if a.any() { + let part = ok(&a.part, part); + let conformance = ok(&a.conformance, |v| v.len() == 1 && v.bytes().all(|b| b.is_ascii_alphabetic())); + // PDF/A-1 to -3 require a conformance level; PDF/A-4 has one only for -4e and -4f. + if part.is_some() && (conformance.is_some() || a.conformance.is_none()) { + id.pdfa = Claim::Kept; + body.push_str(r#""#); + let props = [("part", part), ("conformance", conformance), ("amd", ok(&a.amd, amendment)), ("rev", ok(&a.rev, year))]; + for (name, v) in props { + if let Some(v) = v { + body.push_str(&format!("{v}")); + } + } + body.push_str(""); + } else { + id.pdfa = Claim::Lost; + } + } + if found.pdfua.any() { + // PDF/A-1 to -3 accept only the schemas they define or that the packet describes in an + // extension schema, which pdfuaid needs there. Its fixed text is ours, but a file whose + // redacted words it happens to use doesn't get it (nor, then, the PDF/UA claim). + let extension = id.pdfa == Claim::Kept && a.part.as_deref().is_some_and(|p| matches!(p, "1" | "2" | "3")); + let extension_ok = !extension || !crate::metadata::xml_values(PDFUA_EXTENSION).iter().any(|v| search.text(v)); + match ok(&found.pdfua.part, part).filter(|_| extension_ok) { + Some(p) => { + id.pdfua = Claim::Kept; + // PDF/UA-2 adds a revision year (PDF 2.0, so never beside PDF/A-1 to -3). + let rev = ok(&found.pdfua.rev, year).map(|r| format!("{r}")).unwrap_or_default(); + body.push_str(&format!(r#"{p}{rev}"#)); + if extension { + body.push_str(PDFUA_EXTENSION); + } + } + None => id.pdfua = Claim::Lost, + } + } + if body.is_empty() { + return (id, None); + } + let packet = format!( + "\n{body}\n" + ); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), Object::name("XML")); + // Unfiltered, as PDF/A requires of metadata streams. + (id, Some(Stream::from_raw(dict, packet.into_bytes()))) +} + +/// A part number: one or two digits. +fn part(v: &str) -> bool { + (1..=2).contains(&v.len()) && v.bytes().all(|b| b.is_ascii_digit()) +} + +/// An amendment identifier, such as "2005" or "1:2005". +fn amendment(v: &str) -> bool { + (1..=16).contains(&v.len()) && v.bytes().all(|b| b.is_ascii_digit() || b == b':' || b == b'.' || b == b'-') +} + +/// A revision year. +fn year(v: &str) -> bool { + v.len() == 4 && v.bytes().all(|b| b.is_ascii_digit()) +} + +#[derive(Default)] +struct PdfaId { + part: Option, + conformance: Option, + amd: Option, + rev: Option, +} + +impl PdfaId { + fn any(&self) -> bool { + self.part.is_some() || self.conformance.is_some() || self.amd.is_some() || self.rev.is_some() + } +} + +#[derive(Default)] +struct Found { + pdfa: PdfaId, + pdfua: PdfaId, +} + +/// The identification properties of a packet, by namespace, written as attributes or as +/// elements; the first of each wins. `None` when the packet can't be parsed. +fn read(text: &str) -> Option { + if text.bytes().filter(|&b| b == b'=').count() > MAX_EQUALS { + return None; + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() }; + let xml = roxmltree::Document::parse_with_options(text, options).ok()?; + let mut found = Found::default(); + for node in xml.descendants().filter(roxmltree::Node::is_element) { + for (ns, id) in [(PDFA_ID, &mut found.pdfa), (PDFUA_ID, &mut found.pdfua)] { + for (local, slot) in [("part", &mut id.part), ("conformance", &mut id.conformance), ("amd", &mut id.amd), ("rev", &mut id.rev)] { + if slot.is_some() { + continue; + } + if let Some(v) = node.attribute((ns, local)) { + *slot = Some(v.trim().to_string()); + } else if node.tag_name().namespace() == Some(ns) && node.tag_name().name() == local { + *slot = Some(node.text().unwrap_or("").trim().to_string()); + } + } + } + } + Some(found) +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 326adff769..40f07f1f42 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -46,6 +46,14 @@ pub(crate) const MAX_DEPTH: usize = 50; // A form a reader still shows must be walkable here, or a redaction removes content that is // visible; the build says so rather than a test, so the two limits cannot drift apart. const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); +/// Bounds on the removed text collected for the metadata check (#2448). +const MAX_RUNS: usize = 100_000; +const MAX_RUN_BYTES: usize = 64 * 1024; +/// The removed text kept for the whole document, in bytes. A glyph's `/ToUnicode` text has no +/// length limit, so a short content stream can make many runs of nearly [`MAX_RUN_BYTES`] each, +/// on every page; past this the rest is not kept and the metadata check fails closed, as for +/// any other text it couldn't keep (review on #2539). +pub(crate) const MAX_REMOVED_BYTES: usize = 16 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -162,6 +170,19 @@ pub(crate) struct Scope<'a> { xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, + /// Apply: the text of the glyphs removed under the regions, one entry per run of adjacent + /// glyphs (#2448), so the document's metadata can be checked for it. Call [`Scope::flush`] + /// after processing to close the last run. + pub removed_text: Vec, + /// Some removed text was not kept (too many runs, or a run too long): the metadata check + /// can't search for it and must fail closed. + pub removed_cut: bool, + /// Bytes of removed text this scope may still keep: what is left of the document's + /// [`MAX_REMOVED_BYTES`] (see [`Scope::with_removed_room`]). + pub removed_room: usize, + /// The run being collected, and where its last glyph ended (device space) with its height. + run: String, + run_end: Option<((f64, f64), f64)>, /// Collects the glyph runs removed under a region in apply mode (`None` = not collected). /// The redaction proof's snapshot is what sets it: it must know what applying is about to /// remove, by the interpreter's own judgement, without trusting anything else to it. @@ -193,6 +214,11 @@ impl<'a> Scope<'a> { ocmd_cache: HashMap::new(), xobject_oc_cache: HashMap::new(), layer_blocks: 0, + removed_text: Vec::new(), + removed_cut: false, + removed_room: MAX_REMOVED_BYTES, + run: String::new(), + run_end: None, removed: None, shown: 0, fonts: HashMap::new(), @@ -202,6 +228,49 @@ impl<'a> Scope<'a> { } } + /// Keep at most `room` bytes of removed text: what earlier pages left of the document's + /// [`MAX_REMOVED_BYTES`]. + pub fn with_removed_room(mut self, room: usize) -> Self { + self.removed_room = room; + self + } + + /// Close the run of removed text being collected. + pub fn flush(&mut self) { + self.run_end = None; + let run = std::mem::take(&mut self.run); + if !run.trim().is_empty() { + if self.removed_text.len() < MAX_RUNS && run.len() <= self.removed_room { + self.removed_room -= run.len(); + self.removed_text.push(run); + } else { + self.removed_cut = true; + } + } + } + + /// Note a removed glyph's text: `origin` and `end` are where it starts and ends in device + /// space, `height` its size there. A glyph that starts where the last one ended continues the + /// run, one a little further along the line is a new word of it, and anything else starts a + /// new run, so separate marks don't join their text. + fn removed(&mut self, text: &str, origin: (f64, f64), end: (f64, f64), height: f64) { + if let Some((last, h)) = self.run_end { + let gap = (origin.0 - last.0).hypot(origin.1 - last.1); + let h = h.max(height); + if !(gap.is_finite() && h.is_finite()) || gap > 1.5 * h { + self.flush(); + } else if gap > 0.15 * h && !self.run.ends_with(char::is_whitespace) { + self.run.push(' '); + } + } + if self.run.len().saturating_add(text.len()) <= MAX_RUN_BYTES { + self.run.push_str(text); + } else { + self.removed_cut = true; + } + self.run_end = Some((end, height)); + } + /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { self.hidden_text.is_some() || self.layers_enabled @@ -856,6 +925,26 @@ fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (V !rects.is_empty() } }; + if scope.mode == Mode::Apply && scope.hidden_text.is_none() { + if hit { + // The glyph ends after its character and word spacing, in `trm`'s + // units (the size is in `trm`), so letter-spaced text stays one word. + let spaced = if size != 0.0 { w0 + spacing / size } else { w0 }; + let end = trm.apply(spaced, 0.0); + let top = trm.apply(0.0, 1.0); + let height = (top.0 - origin.0).hypot(top.1 - origin.1); + let text = f.text_of(code); + // A glyph whose text isn't known could stand for anything in the + // metadata, so the metadata check can't rely on the text it has + // (review on #2539): it fails closed, as for text it couldn't keep. + if text.is_none() { + scope.removed_cut = true; + } + scope.removed(text.as_deref().unwrap_or(""), origin, end, height); + } else { + scope.flush(); + } + } glyphs.push(Glyph { bytes: pos..pos + len, advance, hit, rects }); *tm = Matrix::translate(advance * gs.scale, 0.0).then(tm); pos += len; @@ -1001,6 +1090,8 @@ fn xobject( return None; } scope.report.forms_removed += 1; + // Its text was never read, so the metadata check can't know what went with it. + scope.removed_cut = true; out.replaced.push(name); out.doomed.push(r); return Some(None); @@ -1027,6 +1118,7 @@ fn xobject( } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; + scope.removed_cut = true; out.replaced.push(name); out.doomed.push(r); return Some(None); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 1f5d31a426..8846b7a501 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -11,6 +11,9 @@ //! //! A verification pass then re-reads every redacted page; if any glyph or inline image is still //! under a region the whole operation fails (callers keep the previous document: fail-closed). +//! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed. +//! Places it leaves as they are (bookmark titles, attachments, remaining comments, other XMP) +//! are only counted when they still hold that text ([`Report::still_holding`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -19,8 +22,11 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; +mod elsewhere; +mod identification; mod image; mod interp; +mod metadata; mod overlay; mod pattern_guard; pub mod patterns; @@ -35,6 +41,8 @@ pub(crate) mod type3_tables; pub mod user_regex; pub mod verify; +pub use elsewhere::Elsewhere; +pub use identification::{Claim, Identification}; use interp::{Mode, Scope, process}; pub use pattern_guard::PatternProblem; pub use verify::{Proof, ProofOptions, Snapshot}; @@ -178,6 +186,16 @@ pub struct Report { /// only streams a check for text left under the marks may skip: an input stream labelled /// like an overlay (`/PCMark /Redaction`) is checked like any other. pub overlays: Vec, + /// Document information entries and XMP streams removed because they held redacted text. + pub metadata: usize, + /// What became of the PDF/A and PDF/UA identification when the catalog's XMP packet was + /// removed: written back in a packet of its own, or lost (see [`Identification`]). + pub identification: Identification, + /// Places this apply leaves as they are that still hold the removed text (bookmark titles, + /// attachments, the comments that remain, XMP on other objects), counted so the user can be + /// told; Remove hidden information removes them. See [`Elsewhere`] for when the counts are + /// of every place holding any text. + pub still_holding: Elsewhere, } pub(crate) fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -498,6 +516,9 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co doomed_xobjects: Vec::new(), retired_properties: Vec::new(), widget_owner, + removed_text: Vec::new(), + removed_cut: false, + removed_room: interp::MAX_REMOVED_BYTES, removed_everywhere: HashSet::new(), images, removed_appearances: HashSet::new(), @@ -519,6 +540,8 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co doomed_xobjects, retired_properties, mut removed_everywhere, + removed_text, + removed_cut, mut images, mut removed_appearances, mut appearance_scan_limit, @@ -667,6 +690,12 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co // Whatever reference keeps a pruned pattern in the file -- one no walk above follows -- the // full save would write it: prove it safe or refuse. pattern_guard::check_pruned_unreachable(doc, &pruned_patterns, first_page)?; + // 6. The document's metadata must not keep what the pages no longer show (#2448). + let scrubbed = metadata::scrub(doc, &removed_text, !removed_cut)?; + report.metadata = scrubbed.removed; + report.identification = scrubbed.identification; + // What it doesn't scrub is counted, never changed. + report.still_holding = elsewhere::find(doc, &removed_text, !removed_cut); // The previous revision still holds the removed content: the next save must rewrite, and the // rewritten file is a different file from its source, so it gets a new identifier. doc.require_full_save_with_new_id(); @@ -752,6 +781,13 @@ struct Applying { retired_properties: Vec, widget_owner: Vec<(ObjRef, ObjRef)>, removed_everywhere: HashSet, + /// The text of the removed glyphs, for the metadata check at the end. + removed_text: Vec, + /// Some removed text was too long to keep whole for that check. + removed_cut: bool, + /// Bytes of removed text later pages may still keep (`interp::MAX_REMOVED_BYTES` for the + /// document). + removed_room: usize, /// What clearing images may spend, across the run's pages. images: pdfcraft_cos::ImageBudget, /// Appearance streams of the annotations removed, on any page: one shared with a kept @@ -771,14 +807,19 @@ fn redact_page_content( page: &pdfcraft_model::Page, rects: &[[f64; 4]], ) -> Result<(), RedactError> { - let Applying { report, doomed_xobjects, retired_properties, images, pruned_patterns, .. } = applying; + let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, removed_room, images, pruned_patterns, .. } = applying; // 1. Content. let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(rects, Mode::Apply, report, budget, images); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + let mut scope = Scope::new(rects, Mode::Apply, report, budget, images).with_removed_room(*removed_room); + let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); + scope.flush(); + removed_text.append(&mut scope.removed_text); + *removed_cut |= scope.removed_cut; + *removed_room = scope.removed_room; + out }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot // tell whether those glyphs lie under a mark. Rather than report success and possibly diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs new file mode 100644 index 0000000000..a2a1605f25 --- /dev/null +++ b/crates/redact/src/metadata.rs @@ -0,0 +1,547 @@ +//! Metadata that repeats redacted text (#2448). +//! +//! [`crate::apply`] removes the glyphs under the marks, but the same words are often in the +//! document information dictionary (`/Title`, `/Subject`, `/Keywords`…) and the XMP metadata +//! that mirrors it. Once the page no longer shows them, the metadata must not keep them either. +//! +//! An `/Info` entry whose text contains a redacted term is removed, and so is one whose whole +//! value is part of a removed run (an author "J. Li" redacted from a sentence, whose words are too +//! short to be terms; the reverse check #2598 also proposed). An XMP stream whose text +//! (element content, attribute values other than namespace declarations, comments and +//! processing instructions, entities decoded) contains one, or has a value that is part of a +//! removed run, is removed whole: editing the XML in place could leave it malformed or keep the +//! term in another spelling. A PDF/A identification in that packet goes with it. +//! +//! Fail-closed: XMP that isn't UTF-8 or UTF-16, or whose filter data is damaged or behind more than +//! one filter (it is decoded strictly, so nothing after damage goes unsearched), goes; text too +//! large to search against every term counts as matching, and when the removed text itself was cut short (too many runs or terms, +//! or a run too long to keep) every `/Info` string and every XMP stream goes, since what was +//! never collected can't be searched for. Terms are matched without regard to case or spacing, +//! and by their letters and digits alone, so "Blue fin", "BLUE-FIN" and a tracked heading +//! collected as "B L U E F I N" all match "Bluefin" (#2539 review). + +use std::collections::HashSet; + +use pdfcraft_cos::{Document, ObjRef, Object}; +use unicode_normalization::UnicodeNormalization; + +/// Terms longer than this are cut (matching still finds the prefix). +const MAX_TERM_CHARS: usize = 4096; +const MAX_TERMS: usize = 20_000; +/// XMP packets larger than this, decoded, are removed without reading them. +pub(crate) const MAX_XMP_BYTES: usize = 16 * 1024 * 1024; +/// The searching (text length times terms, or value length times removed text) one scrub may +/// do in all; past it every entry and packet counts as matching (fail-closed), so a page of +/// removed text against a large packet, or against very many small values, stays quick. +const MAX_WORK: usize = 1 << 28; +/// Normalized text longer than this many times its input (plus 64 bytes) isn't searched: it +/// counts as matching. Ordinary text barely changes length; a few compatibility characters +/// (U+FDFA, the square katakana words) decompose to many. +const MAX_GROWTH: usize = 4; + +/// `s` in Unicode compatibility form (NFKC), lower case, every run of whitespace made one space, +/// trimmed: "Mu\u{308}ller" (decomposed), "MÜLLER" (full width) and "Müller" are all +/// "müller", and the ligature "fi" is "fi" (#2539 review). `None` when it would grow past +/// [`MAX_GROWTH`] times `s`. +fn normalize(s: &str) -> Option { + let cap = s.len().saturating_mul(MAX_GROWTH).saturating_add(64); + let mut out = String::new(); + let mut space = false; + for c in s.nfkc().flat_map(char::to_lowercase).nfkc() { + if c.is_whitespace() { + space = !out.is_empty(); + continue; + } + if space { + out.push(' '); + space = false; + } + out.push(c); + if out.len() > cap { + return None; + } + } + Some(out) +} + +fn significant(s: &str, min: usize) -> bool { + s.chars().filter(|c| c.is_alphanumeric()).count() >= min +} + +/// The letters and digits of `s` alone: "blue-fin" and "b l u e f i n" are both "bluefin". +fn squeeze(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).collect() +} + +/// A removed run (normalized), and its letters and digits alone with where its words begin and +/// end among them, so a value can be found in it whatever its spacing but still as whole words. +struct Run { + text: String, + letters: String, + /// `edges[i]`: byte `i` of `letters` begins a word (something other than a letter or digit + /// comes before it in the run, or nothing does), or `i` is the end. One entry per byte of + /// `letters`, and one for the end. + edges: Vec, +} + +impl Run { + fn new(text: String) -> Self { + let mut letters = String::new(); + let mut edges = Vec::new(); + let mut gap = true; + for c in text.chars() { + if c.is_alphanumeric() { + edges.resize(letters.len(), false); + edges.push(gap); + letters.push(c); + gap = false; + } else { + gap = true; + } + } + edges.resize(letters.len(), false); + edges.push(true); + Self { text, letters, edges } + } + + /// Are the letters and digits `value` (squeezed) among the run's, beginning and ending at + /// word edges? "bluefin" is in "b l u e f i n" and "bluef in"; "true" isn't in "untrue". + fn holds_letters(&self, value: &str) -> bool { + let edge = |i: usize| self.edges.get(i).copied().unwrap_or(false); + !value.is_empty() && self.letters.match_indices(value).any(|(at, m)| edge(at) && edge(at.saturating_add(m.len()))) + } +} + +/// The terms to look for: each removed run (at least two letters or digits), and each of its +/// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was +/// redacted. Shorter pieces would match nearly any text. `None` when a run is longer than +/// [`MAX_TERM_CHARS`] (or grows past [`MAX_GROWTH`] when normalized) or there are more than +/// [`MAX_TERMS`] terms: the caller then can't search +/// for all of it and must clear the metadata instead. +pub(crate) fn terms(removed: &[String]) -> Option> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for run in removed { + let run = normalize(run)?; + if run.chars().nth(MAX_TERM_CHARS).is_some() { + return None; + } + let words = run.split(|c: char| !c.is_alphanumeric()).filter(|w| significant(w, 3)).map(str::to_string).collect::>(); + for t in std::iter::once(run.clone()).filter(|r| significant(r, 2)).chain(words) { + if seen.insert(t.clone()) { + if out.len() >= MAX_TERMS { + return None; + } + out.push(t); + } + } + } + Some(out) +} + +/// The terms ([`terms`]) with three letters or digits or more, by their letters and digits +/// alone, each once: "blue fin" holds the term "bluefin" this way, and "bluefin" the run +/// "b l u e f i n". +fn squeezed_terms(terms: &[String]) -> Vec { + let mut seen = HashSet::new(); + terms.iter().filter(|t| significant(t, 3)).map(|t| squeeze(t)).filter(|t| seen.insert(t.clone())).collect() +} + +/// The searching one scrub may do, across every `/Info` entry and XMP value: past it, each +/// further question is answered "yes" (fail-closed), so a packet of many small values can't add +/// up to more than one large one. +struct Work(std::cell::Cell); + +impl Work { + fn new(limit: usize) -> Self { + Self(std::cell::Cell::new(limit)) + } + + /// Take `cost` from what is left; `false` (and nothing left) when it doesn't fit. + fn spend(&self, cost: usize) -> bool { + match self.0.get().checked_sub(cost) { + Some(left) => { + self.0.set(left); + true + } + None => { + self.0.set(0); + false + } + } + } +} + +/// Does `text` hold a term, as written or, for `squeezed` terms, by its letters and digits alone? +fn mentions(text: &str, terms: &[String], squeezed: &[String], work: &Work) -> bool { + let Some(text) = normalize(text) else { return true }; + let letters = squeeze(&text); + !work.spend(text.len().saturating_mul(terms.len().saturating_add(squeezed.len()))) + || terms.iter().any(|t| text.contains(t.as_str())) + || squeezed.iter().any(|t| letters.contains(t.as_str())) +} + +/// Is `value` itself part of one of the removed `runs`, as whole words, compared as written or by +/// letters and digits alone ("Bluefin" is part of "b l u e f i n")? It needs three letters or +/// digits, so an entry such as "by" doesn't go with every sentence that contains it, and word +/// edges, so an XMP value "True" doesn't go with "untrue". Too much text to search counts as +/// part (fail-closed). +fn within(value: &str, runs: &[Run], work: &Work) -> bool { + let Some(value) = normalize(value) else { return true }; + if !significant(&value, 3) { + return false; + } + let letters = squeeze(&value); + let total = runs.iter().map(|r| r.text.len().saturating_add(r.letters.len())).fold(0usize, usize::saturating_add); + let word = |c: Option| c.is_some_and(char::is_alphanumeric); + let whole = |r: &Run| { + r.holds_letters(&letters) + || r.text.match_indices(value.as_str()).any(|(at, m)| { + let before = r.text.get(..at).and_then(|s| s.chars().next_back()); + let after = r.text.get(at.saturating_add(m.len())..).and_then(|s| s.chars().next()); + !(word(before) && value.starts_with(char::is_alphanumeric)) && !(word(after) && value.ends_with(char::is_alphanumeric)) + }) + }; + !work.spend(value.len().saturating_mul(total)) || runs.iter().any(whole) +} + +/// Decode the five predefined XML entities and character references; anything else stays. +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(rest.get(..at).unwrap_or("")); + let tail = rest.get(at..).unwrap_or(""); + // A named entity ends within 12 bytes; looking further made a text of bare `&`s + // quadratic. A character reference may carry any number of leading zeros, so its digits + // are read to their end: each digit belongs to one `&` only, which keeps it linear. + let end = match tail.strip_prefix("&#") { + Some(digits) => { + let hex = digits.starts_with(['x', 'X']); + let from = 2 + usize::from(hex); + let run = tail.get(from..).unwrap_or("").bytes().take_while(|b| if hex { b.is_ascii_hexdigit() } else { b.is_ascii_digit() }).count(); + let e = from + run; + (tail.as_bytes().get(e) == Some(&b';')).then_some(e) + } + None => tail.char_indices().take(13).find(|&(_, c)| c == ';').map(|(e, _)| e), + }; + let entity = end.and_then(|e| tail.get(1..e)); + let decoded = entity.and_then(|e| match e { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => { + let n = match e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) { + // Leading zeros dropped, so they can't overflow the number; `�` isn't a + // character XML allows, and stays as written. + Some(h) => u32::from_str_radix(h.trim_start_matches('0'), 16).ok(), + None => e.strip_prefix('#').and_then(|d| d.trim_start_matches('0').parse().ok()), + }; + n.and_then(char::from_u32) + } + }); + match (decoded, end) { + (Some(c), Some(e)) => { + out.push(c); + rest = tail.get(e + 1..).unwrap_or(""); + } + _ => { + out.push('&'); + rest = tail.get(1..).unwrap_or(""); + } + } + } + out.push_str(rest); + out +} + +/// The text values an XML document carries, entities decoded: element content, attribute values +/// (XMP stores simple properties as attributes), CDATA, and the text of comments and processing +/// instructions other than the `xpacket` wrapper, which a reader can still see. Names and markup are left out, so a term can't +/// match a tag such as `dc:title`, and so are namespace declarations (`xmlns`), whose URIs +/// would match words such as "adobe" or "application" in every packet. +pub(crate) fn xml_values(xml: &str) -> Vec { + let mut out = Vec::new(); + let mut push = |s: String| { + if !s.trim().is_empty() { + out.push(s); + } + }; + let mut rest = xml; + while !rest.is_empty() { + let Some(open) = rest.find('<') else { + push(unescape(rest)); + break; + }; + push(unescape(rest.get(..open).unwrap_or(""))); + let tag = rest.get(open..).unwrap_or(""); + let delimited = [(""), (""), ("")].into_iter().find(|(start, _)| tag.starts_with(start)); + if let Some((start, stop)) = delimited { + let body = tag.get(start.len()..).unwrap_or(""); + let end = body.find(stop).unwrap_or(body.len()); + let text = body.get(..end).unwrap_or(""); + // The packet wrapper's fixed `begin`/`id`/`end` would match words such as "end". + if !(start == "` (which can't be inside quotes). + let mut chars = tag.char_indices().skip(1); + let mut end = tag.len(); + // The attribute name being read, and the last one read before whitespace: XML allows + // whitespace around `=` (`xmlns:x = "…"`). + let mut name = String::new(); + let mut last = String::new(); + while let Some((i, c)) = chars.next() { + match c { + '>' => { + end = i + 1; + break; + } + '"' | '\'' => { + let mut value = String::new(); + for (_, d) in chars.by_ref() { + if d == c { + break; + } + value.push(d); + } + let attr = if name.is_empty() { &last } else { &name }; + let declaration = attr == "xmlns" || attr.starts_with("xmlns:"); + if !declaration { + push(unescape(&value)); + } + name.clear(); + last.clear(); + } + '=' => {} + '/' => { + name.clear(); + last.clear(); + } + c if c.is_whitespace() => { + if !name.is_empty() { + last = std::mem::take(&mut name); + } + } + c => name.push(c), + } + } + rest = tag.get(end..).unwrap_or(""); + } + out +} + +/// Decode an XMP packet: UTF-8 (with or without a byte-order mark), or UTF-16 told by its mark +/// or, as a packet usually begins ` Option { + let utf16 = |body: &[u8], be: bool| -> Option { + let (units, rest) = body.as_chunks::<2>(); + if !rest.is_empty() { + return None; + } + char::decode_utf16(units.iter().map(|&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) })).collect::>().ok() + }; + match bytes { + [0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).ok().map(str::to_string), + [0xFE, 0xFF, rest @ ..] => utf16(rest, true), + [0xFF, 0xFE, rest @ ..] => utf16(rest, false), + [0, a, ..] if *a != 0 => utf16(bytes, true), + [a, 0, ..] if *a != 0 => utf16(bytes, false), + _ => std::str::from_utf8(bytes).ok().map(str::to_string), + } +} + +/// The text of a text string, as searched for the removed text. ISO 32000-2 §7.9.2.2.1 defines +/// PDFDocEncoding, UTF-16BE (`FE FF`) and UTF-8 (`EF BB BF`) only, but a string behind an `FF FE` +/// mark is UTF-16LE to any reader that takes it as such, and read through PDFDocEncoding it +/// could never match: it is read as UTF-16LE (an odd last byte is left out, a lone surrogate +/// becomes U+FFFD), so the search stays fail-closed (#2539 review). +pub(crate) fn text_string(s: &pdfcraft_cos::PdfString) -> String { + match s.bytes.as_slice() { + [0xFF, 0xFE, rest @ ..] => { + let units = rest.as_chunks::<2>().0.iter().map(|&p| u16::from_le_bytes(p)); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + } + _ => s.to_text(), + } +} + +/// The text values of an XMP stream, or `None` when it can't be read. +fn xmp_values(doc: &Document, o: &Object) -> Option> { + xmp_text(doc, o).map(|t| xml_values(&t)) +} + +/// The decoded text of an XMP stream, or `None` when it can't be read. +pub(crate) fn xmp_text(doc: &Document, o: &Object) -> Option { + let Object::Stream(s) = &*doc.resolve(o) else { return None }; + // Decoded strictly: a tolerant decode returns whatever came before the damage, and the part + // after it would go unsearched. A chain of more than one filter has no strict decoder here, + // so it isn't read either (fail-closed; XMP is rarely filtered at all). Decoding stops at the + // cap, so a small stream that inflates hugely costs no more memory. + if s.filters().len() > 1 { + return None; + } + // `/DecodeParms` given by reference are read too ([`Document::with_direct_parms`]). + let bytes = doc.with_direct_parms(s).decoded_first_filter_strict(MAX_XMP_BYTES).ok()?; + decode_xmp(&bytes) +} + +/// What the removed text is looked for with, and the searching left to do it. +pub(crate) struct Search { + lookup: Lookup, + work: Work, +} + +enum Lookup { + /// Nothing with text was removed: nothing can hold it. + Nothing, + /// The removed text couldn't be collected or searched for in full: everything counts as + /// holding it (fail-closed). + Everything, + /// The terms ([`terms`]), the same by their letters and digits alone ([`squeezed_terms`]), + /// and the distinct runs, normalized. + Terms(Vec, Vec, Vec), +} + +impl Search { + /// The search for `removed` text, with `limit` steps of searching ([`MAX_WORK`] outside + /// tests). `complete` is false when the collection of removed text was cut short; then, as + /// when the terms exceed their limits, everything matches. + pub(crate) fn new(removed: &[String], complete: bool, limit: usize) -> Self { + let runs: Option> = removed.iter().map(|r| normalize(r)).collect(); + let lookup = match (terms(removed).filter(|_| complete), runs) { + (Some(t), _) if t.is_empty() => Lookup::Nothing, + (Some(t), Some(runs)) => { + // The same run removed many times is searched for once. + let mut seen = HashSet::new(); + let runs: Vec = runs.into_iter().filter(|r| seen.insert(r.clone())).map(Run::new).collect(); + let squeezed = squeezed_terms(&t); + Lookup::Terms(t, squeezed, runs) + } + _ => Lookup::Everything, + }; + Self { lookup, work: Work::new(limit) } + } + + /// [`Search::new`] with the whole work limit. + pub(crate) fn of(removed: &[String], complete: bool) -> Self { + Self::new(removed, complete, MAX_WORK) + } + + /// Nothing with text was removed. + pub(crate) fn is_nothing(&self) -> bool { + matches!(self.lookup, Lookup::Nothing) + } + + /// The removed text couldn't be searched for: everything matches. + pub(crate) fn is_everything(&self) -> bool { + matches!(self.lookup, Lookup::Everything) + } + + /// Does `text` hold the removed text: a term, or wholly part of a removed run? Past the + /// work limit, yes (fail-closed). + pub(crate) fn text(&self, text: &str) -> bool { + match &self.lookup { + Lookup::Nothing => false, + Lookup::Everything => true, + Lookup::Terms(terms, squeezed, runs) => mentions(text, terms, squeezed, &self.work) || within(text, runs, &self.work), + } + } + + /// Does the XMP stream `o` hold the removed text? A stream that can't be read does + /// (fail-closed), unless nothing with text was removed. + pub(crate) fn xmp(&self, doc: &Document, o: &Object) -> bool { + match &self.lookup { + Lookup::Nothing => false, + Lookup::Everything => true, + Lookup::Terms(terms, squeezed, runs) => xmp_values(doc, o) + .is_none_or(|values| mentions(&values.join(" "), terms, squeezed, &self.work) || values.iter().any(|v| within(v, runs, &self.work))), + } + } +} + +/// What [`scrub`] did. +#[derive(Debug, Default)] +pub(crate) struct Scrubbed { + /// `/Info` entries and XMP streams removed. + pub(crate) removed: usize, + /// What became of the PDF/A and PDF/UA identification of a removed catalog packet. + pub(crate) identification: crate::Identification, +} + +/// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the +/// catalog's and pages' XMP streams that mention it. `complete` is false when the collection of +/// removed text was cut short; then, as when the terms themselves exceed their limits, every +/// `/Info` string and every XMP stream goes. A removed catalog packet's PDF/A and PDF/UA +/// identification is written back in a packet of its own ([`crate::identification`]). +pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { + scrub_within(doc, removed, complete, MAX_WORK) +} + +/// [`scrub`] with the searching it may do given, so tests can run out of it. +pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { + let search = Search::new(removed, complete, limit); + let mut out = Scrubbed::default(); + if search.is_nothing() { + return Ok(out); + } + let mut n = 0; + + // The information dictionary, direct in the trailer or an object of its own. + let info_obj = doc.trailer().get(b"Info").cloned(); + if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) { + let doomed: Vec> = + info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| search.text(&text_string(s)))).map(|(k, _)| k.to_vec()).collect(); + if !doomed.is_empty() { + n += doomed.len(); + let mut kept = info.clone(); + for k in &doomed { + kept.remove(k); + } + match info_obj.as_ref().and_then(Object::as_ref) { + Some(r) => doc.update_dict(r, |d| *d = kept)?, + None => doc.trailer_mut().set(b"Info".to_vec(), Object::Dict(kept)), + } + } + } + + // XMP on the catalog and on every page. + let root = doc.root(); + for holder in catalog_and_pages(doc) { + let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue }; + if search.xmp(doc, &xmp) { + // The catalog's packet says whether the file conforms to PDF/A or PDF/UA: that much + // is written back, in a packet of its own. + let replacement = if Some(holder) == root { + let (id, packet) = crate::identification::keep(doc, &xmp, &search); + out.identification = id; + packet.map(|p| doc.add(Object::Stream(p))) + } else { + None + }; + doc.update_dict(holder, |d| match replacement { + Some(r) => d.set(b"Metadata".to_vec(), Object::Ref(r)), + None => { + d.remove(b"Metadata"); + } + })?; + n += 1; + } + } + out.removed = n; + Ok(out) +} + +/// The catalog and the pages: the objects whose XMP [`scrub`] checks. +pub(crate) fn catalog_and_pages(doc: &Document) -> Vec { + let mut holders: Vec = doc.root().into_iter().collect(); + holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj)); + holders +} diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 5a4ea3bfcd..2734dcd7ee 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -419,6 +419,47 @@ fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { } } +/// Every value of `key` held outside a name map, as [`owners`] finds them: with the indirect +/// object that holds it and whether it is that object's own entry (rather than one in a +/// dictionary written inside it). Fails as [`owners`] does for a graph nested too deeply. +pub(crate) fn owned_values(doc: &Document, key: &[u8]) -> Result, RedactError> { + let (_, holders) = owners(doc, &[key])?; + let mut out = Vec::new(); + for (r, role) in holders { + collect_owned(&doc.get(r), role, key, 0, &mut |own, v| out.push((r, own, v.clone()))); + } + Ok(out) +} + +/// Calls `found` with each value of `key` in `o` and what it holds in place, outside name maps +/// (the reading counterpart of [`strip_owned`]), and whether it is `o`'s own entry. +fn collect_owned(o: &Object, role: Held, key: &[u8], depth: usize, found: &mut dyn FnMut(bool, &Object)) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + collect_owned(v, Held::Owner, key, depth + 1, found); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map + && let Some(v) = d.get(key) + { + found(depth == 0, v); + } + for (k, v) in d.iter() { + if matches!(v, Object::Array(_) | Object::Dict(_)) { + collect_owned(v, role.child(k), key, depth + 1, found); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 939af1d21c..031296a01e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -252,6 +252,406 @@ fn a_whole_line_under_a_mark_goes_even_without_widths() { assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); } +/// Issue #2448: the redacted words are often in the document information dictionary and the XMP +/// metadata too. Applying removes the entries and streams that repeat them, and keeps the rest. +#[test] +fn redacted_text_does_not_survive_in_info_or_xmp() { + let xmp = |body: &str| { + stream( + "/Type /Metadata /Subtype /XML", + format!( + r#"{body}"# + ) + .as_bytes(), + ) + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 9 0 R >>".to_vec(), + // "BLUEFIN" spans x 55-90. + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + // 7: the document's XMP names it in an attribute, through a character reference. + xmp(r#""#), + // 8: the information dictionary. + b"<< /Title (Codename BLUEFIN report) /Keywords (tuna, bluefin) /Author (Ada Lovelace) /Producer (Test) >>".to_vec(), + // 9: the page's XMP doesn't mention it. + xmp(r#"Annual figures"#), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.metadata), (7, 3), "{r:?}"); + // Without object streams, so the byte search below can see the `/Info` dictionary. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)); + assert!(has(b"Ada Lovelace"), "the kept /Info entry is visible to the search"); + assert!(!has(b"bluefin") && !has(b"BlueFin"), "the redacted word survived in the metadata"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let info = saved.trailer().get(b"Info").and_then(|i| saved.resolve(i).as_dict().cloned()).unwrap(); + let text = |k: &[u8]| info.get(k).and_then(|v| saved.resolve(v).as_string().map(|s| s.to_text())); + assert_eq!((text(b"Title"), text(b"Keywords")), (None, None)); + assert_eq!((text(b"Author").as_deref(), text(b"Producer").as_deref()), (Some("Ada Lovelace"), Some("Test"))); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Metadata"), "the document XMP named the redacted word"); + assert!(pdfcraft_model::pages(&saved)[0].dict.contains(b"Metadata"), "the page XMP didn't, and stays"); +} + +/// An `/Info` value that is itself part of the removed text goes, though its words are too short +/// to be terms ("J. Li"); a value with fewer than three letters or digits stays. +#[test] +fn an_info_value_inside_the_redacted_text_goes() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (Prepared by J. Li) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Author (J. li) /Subject (by) /Producer (Test) >>".to_vec(), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 7 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 1, "{r:?}"); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Author"), "the author's name was redacted from the page"); + assert!(info.contains(b"Subject") && info.contains(b"Producer")); +} + +/// Removed glyphs whose text isn't known (no `/ToUnicode`, glyph names outside the glyph list) +/// leave nothing to search the metadata for, so every `/Info` string goes, as when the collected +/// text was cut short (review on #2539). Glyphs whose text is known keep the targeted check. +#[test] +fn removed_glyphs_with_no_known_text_clear_the_metadata() { + let page = |codes: &[u8], font: &str| -> Document { + let content = [&b"BT /F1 10 Tf 10 100 Td ("[..], codes, b") Tj ET"].concat(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", &content), + font.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Title (Project Nightjar) /Producer (Test) >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 7 0 R").build())).unwrap() + }; + let unnamed = FONT.replace(" /Widths", " /Encoding << /Differences [65 /g17 /g18 /g19] >> /Widths"); + let mut doc = page(b"ABC", &unnamed); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Title") && !info.contains(b"Producer"), "fail closed: {r:?}"); + // Control: the same glyphs with known text don't touch unrelated metadata. + let mut doc = page(b"ABC", FONT); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + apply(&mut doc, None).unwrap(); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(info.contains(b"Title") && info.contains(b"Producer")); +} + +/// One page showing `content`, with `/Info` `info` and catalog XMP whose `rdf:RDF` holds +/// `xmp_body`. +fn with_metadata(content: &[u8], info: &str, xmp_body: &str) -> Document { + let xmp = format!( + r#"{xmp_body}"# + ); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", xmp.as_bytes()), + info.as_bytes().to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap() +} + +fn info_keys(doc: &Document) -> Vec { + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect() +} + +fn has_xmp(doc: &Document) -> bool { + doc.get(doc.root().unwrap()).as_dict().is_some_and(|c| c.contains(b"Metadata")) +} + +/// Letter-spaced text (character spacing wider than a word gap between plain glyphs) is still +/// one word, so `/Title (Bluefin)` goes when "B L U E F I N" is redacted (#2539 review). +#[test] +fn letter_spaced_redacted_text_still_matches_metadata() { + let body = r#"Bluefin"#; + let mut doc = with_metadata(b"BT /F1 10 Tf 3 Tc 10 100 Td (BLUEFIN) Tj ET", "<< /Title (Bluefin) /Producer (Test) >>", body); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 2, "{r:?}"); + assert_eq!(info_keys(&doc), ["Producer"]); + assert!(!has_xmp(&doc)); +} + +/// An XMP value that is part of the removed text goes, as an `/Info` value does, though its +/// words are too short to be terms ("J. Li"). Values are compared as whole words, so "True" +/// doesn't go with "untrue", and namespace URIs and the `xpacket` wrapper aren't searched, so +/// redacting "Adobe … end" keeps a packet declared under `adobe:ns:meta/` and closed by +/// ``. +#[test] +fn xmp_values_inside_the_redacted_text_go_as_whole_words() { + let creator = r#"J. Li"#; + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Prepared by J. Li) Tj ET", "<< /Producer (Test) >>", creator); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 1, "{r:?}"); + assert!(!has_xmp(&doc), "the packet named J. Li"); + + let trapped = r#""#; + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Adobe said untrue at the end) Tj ET", "<< /Subject (True) /Producer (Test) >>", trapped); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 0, "{r:?}"); + assert!(has_xmp(&doc)); + assert_eq!(info_keys(&doc), ["Subject", "Producer"]); +} + +/// When the removed text was too much to collect, what wasn't collected can't be searched for: +/// every `/Info` string and the XMP go (fail-closed), even though the runs that were kept match +/// nothing. +#[test] +fn metadata_is_cleared_when_the_removed_text_was_cut_short() { + // More separate runs of one letter than are kept: each `A` is too far from the last to join. + let mut content = b"BT /F1 10 Tf 10 100 Td".to_vec(); + for _ in 0..50_001 { + content.extend_from_slice(b" (A) Tj 30 0 Td (A) Tj -30 0 Td"); + } + content.extend_from_slice(b" ET"); + let body = r#"Annual figures"#; + let mut doc = with_metadata(&content, "<< /Title (Annual figures) /Producer (Test) /Trapped /False >>", body); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 3, "{r:?}"); + assert_eq!(info_keys(&doc), ["Trapped"], "strings go, the name stays"); + assert!(!has_xmp(&doc)); +} + +/// #2539 review: the removed text kept for the metadata check is bounded for the whole document, +/// not per page. One glyph code can stand for 256 UTF-16 units of `/ToUnicode` text (768 bytes of +/// UTF-8), so here each page makes 150 runs of 80 such glyphs (9.2 MB, under the page's run and +/// byte limits); two pages pass `MAX_REMOVED_BYTES`, so the rest isn't kept and the check fails +/// closed. +#[test] +fn removed_text_kept_for_the_metadata_check_is_bounded_per_document() { + let long = "4E00".repeat(256); + let to_unicode = format!("/CIDInit /ProcSet findresource begin 1 beginbfchar <41> <{long}> endbfchar end"); + let run = "A".repeat(80); + let mut content = b"BT /F1 1 Tf 10 100 Td".to_vec(); + for _ in 0..75 { + content.extend_from_slice(format!(" ({run}) Tj 30 0 Td ({run}) Tj -30 0 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let page = "/Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >>"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>".to_vec(), + format!("<< {page} >>").into_bytes(), + stream("", &content), + FONT.replace("95 0 R", "6 0 R").replace(" /Widths", " /ToUnicode 7 0 R /Widths").into_bytes(), + widths(), + stream("", to_unicode.as_bytes()), + format!("<< {page} >>").into_bytes(), + ]; + let mut doc = pdf(objs); + let mut applying = Applying { + report: Report::default(), + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner: Vec::new(), + removed_text: Vec::new(), + removed_cut: false, + removed_room: interp::MAX_REMOVED_BYTES, + removed_everywhere: HashSet::new(), + images: pdfcraft_cos::ImageBudget::new(&doc), + removed_appearances: HashSet::new(), + appearance_scan_limit: false, + pruned_patterns: Vec::new(), + }; + let rects = [[5.0, 99.0, 200.0, 102.0]]; + let kept = |a: &Applying| a.removed_text.iter().map(String::len).sum::(); + let first = pdfcraft_model::pages(&doc).swap_remove(0); + redact_page_content(&mut doc, &mut applying, 0, &first, &rects).unwrap(); + assert_eq!((kept(&applying), applying.removed_cut), (9_216_000, false), "one page fits"); + let second = pdfcraft_model::pages(&doc).swap_remove(1); + redact_page_content(&mut doc, &mut applying, 1, &second, &rects).unwrap(); + assert!(applying.removed_cut, "the second page passes the document's limit"); + assert!(kept(&applying) <= interp::MAX_REMOVED_BYTES, "kept {} bytes", kept(&applying)); +} + +/// #2539 review: the searching a scrub may do is one budget for all of it, not a check per +/// value: very many small `/Info` values each fit, but together they don't, and past the budget +/// the rest counts as matching (fail-closed). With room to search, nothing that doesn't mention +/// the removed text goes. +#[test] +fn metadata_search_has_one_budget_for_every_value() { + let entries: String = (0..50).map(|i| format!("/K{i} (an unrelated value number {i:02} in the list) ")).collect(); + let info = format!("<< {entries}/Producer (Test) >>"); + let fresh = || with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", &info, ""); + let mut doc = fresh(); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap().removed, 0, "room to search: nothing goes"); + let mut doc = fresh(); + // Each value costs about 40; fifty of them don't fit in 1000. + let n = metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1000).unwrap().removed; + assert!(n > 20, "past the budget the rest goes ({n} went)"); +} + +/// #2539 review: an entity is looked for only in the 12 bytes after its `&`, so a packet of bare +/// `&` characters is read in linear time, and decoding is unchanged. +#[test] +fn xml_entities_are_found_near_their_ampersand_only() { + assert_eq!(metadata::xml_values("Résumé & AA �"), ["Résumé & AA �"]); + // #2539 review: a character reference with many leading zeros still decodes, so it can't + // hide a redacted word from the search; a reference that never ends stays as written. + assert_eq!(metadata::xml_values("BLUEFIN BLUEFIN �"), ["BLUEFIN BLUEFIN �"]); + let bare = "&".repeat(200_000); + let started = std::time::Instant::now(); + let values = metadata::xml_values(&format!("{bare}")); + assert_eq!(values.concat().len(), bare.len()); + let zeros = format!("&#{}", "0".repeat(10)).repeat(20_000); + assert_eq!(metadata::xml_values(&format!("{zeros}")).concat().len(), zeros.len()); + eprintln!("200000 bare ampersands, 20000 unended references: {:?}", started.elapsed()); +} + +/// #2539 review: Apply removes a form XObject whole when it is nested past the depth limit (or +/// its stream can't be decoded), without reading its text, so what it held is unknown: the +/// metadata check fails closed. +#[test] +fn a_form_removed_unread_clears_the_metadata() { + // Forms 5, 6, ... each draw the next, one more than the depth limit deep, and the last one + // paints. The page draws form 5. + let last = 5 + crate::interp::MAX_DEPTH; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm 5 0 R >> >> >>".to_vec(), + stream("", b"q /Fm Do Q"), + ]; + for n in 5..=last { + objs.push(stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Fm {} 0 R >> >>", n + 1), + b"q /Fm Do Q", + )); + } + objs.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"0 g 10 10 50 50 re f")); + objs.push(b"<< /Title (Annual figures) /Producer (Test) >>".to_vec()); + let info = objs.len(); + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry(&format!("/Info {info} 0 R")).build())).unwrap(); + mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.forms_removed >= 1, "{r:?}"); + assert!(!info_keys(&doc).contains(&"Title".to_string()), "the deep form's text was never read: {:?}", info_keys(&doc)); +} + +/// A value shorter than the redacted sentence goes from XMP too, in a custom schema's element or +/// attribute, as from `/Info` (the regression #2598 suggested keeping): the packets holding it are +/// removed, one that doesn't stays. +#[test] +fn a_shorter_value_in_custom_xmp_goes() { + let xmp = |body: &str| { + stream( + "/Type /Metadata /Subtype /XML", + format!( + r#"{body}"# + ) + .as_bytes(), + ) + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 8 0 R >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (JAMES BOND IS A SPY) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + // 7: an element of a custom schema. + xmp(r#"James Bond"#), + // 8: an attribute of a custom schema. + xmp(r#""#), + // 9: a third packet, on no holder yet; swapped onto the page below to show a clean one stays. + xmp(r#"Annual figures"#), + b"<< /Author (James Bond) /Subject (Annual figures) >>".to_vec(), + ]; + for (holder_of_8, expected) in [("8 0 R", 3), ("9 0 R", 2)] { + let objs: Vec> = + objs.iter().map(|o| String::from_utf8_lossy(o).replace("/Metadata 8 0 R", &format!("/Metadata {holder_of_8}")).into_bytes()).collect(); + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 10 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, expected, "{holder_of_8}: {r:?}"); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Metadata"), "the custom element named him"); + let page_keeps = pdfcraft_model::pages(&doc)[0].dict.contains(b"Metadata"); + assert_eq!(page_keeps, holder_of_8 == "9 0 R", "the attribute packet goes, a clean one stays"); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Author") && info.contains(b"Subject"), "the author goes, the unrelated subject stays"); + } +} + +/// The removed text is collected in runs by position, not by operator: glyphs shown one operator +/// at a time still make one word, a gap makes a space, and separate marks make separate runs. +#[test] +fn removed_text_is_collected_in_runs() { + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (BLU) Tj 15 0 Td (E) Tj 5 0 Td [(F) -300 (IN)] TJ ( 7) Tj ET BT /F1 10 Tf 10 200 Td (Gary) Tj ET", + "", + vec![], + ); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let (_, data) = page_streams(&doc, &page.dict, 0).unwrap(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut rep = Report::default(); + let rects = [[10.0, 95.0, 300.0, 110.0], [10.0, 195.0, 30.0, 210.0]]; + let mut images = pdfcraft_cos::ImageBudget::new(&doc); + let mut scope = Scope::new(&rects, Mode::Apply, &mut rep, pdfcraft_cos::DecodeBudget::page(), &mut images); + process(&mut doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY); + scope.flush(); + assert_eq!(scope.removed_text, ["BLUEF IN 7", "Gary"]); +} + +#[test] +fn metadata_terms_and_xmp_text() { + // Runs of two letters or digits or more are matched whole, and by their words of three or + // more, ignoring case and spacing. + let t = metadata::terms(&["John Smith".into(), "7".into(), "of".into(), "A-12".into()]); + assert_eq!(t.as_deref(), Some(&["john smith", "john", "smith", "of", "a-12"].map(String::from)[..])); + // A run too long to search for whole can't be matched: the caller clears everything. + assert_eq!(metadata::terms(&["x".repeat(5000)]), None); + // XMP values: element content, attribute values, CDATA, comments and processing instructions, + // entities decoded; not names, and not namespace declarations. + let x = metadata::xml_values( + r#"Résumé ]]>"#, + ); + // Only the `xpacket` wrapper itself is skipped, and a declaration is one with spaces around + // `=` too. + assert_eq!(x, ["audit James Bond", "xpacketAudit SECRET", "spaced", "x & y", " hidden ", "Résumé ", ""]); +} + +/// XMP is decoded from UTF-8 or UTF-16, the latter also without a byte-order mark at the start +/// (a packet's mark sits inside `Bond"; + let utf16 = |be: bool| text.encode_utf16().flat_map(|u| if be { u.to_be_bytes() } else { u.to_le_bytes() }).collect::>(); + assert_eq!(metadata::decode_xmp(text.as_bytes()).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(&utf16(true)).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(&utf16(false)).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(&[[0xFF, 0xFE].as_slice(), &utf16(false)].concat()).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(b"Bond \xE9"), None, "Latin-1 isn't searched lossily"); + assert_eq!(metadata::decode_xmp(&utf16(false)[1..]), None, "odd length"); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. @@ -4152,9 +4552,13 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod descriptions; +mod elsewhere; mod hidden_scope; +mod identification; mod inline_images; mod limits; +mod metadata_fail_closed; +mod metadata_matching; mod property_retirement; mod surviving_references; mod text_state; diff --git a/crates/redact/src/tests/elsewhere.rs b/crates/redact/src/tests/elsewhere.rs new file mode 100644 index 0000000000..7379af19cc --- /dev/null +++ b/crates/redact/src/tests/elsewhere.rs @@ -0,0 +1,113 @@ +//! #2539 review: what applying leaves as it is but still holds the removed text is counted in +//! [`Report::still_holding`], and nothing of it is changed. + +use super::*; + +const XMP_BLUEFIN: &str = r#"Photo for Bluefin"#; +const XMP_OTHER: &str = r#"Annual figures"#; + +/// The objects whose text the check reads, by number. +const HELD: [u32; 20] = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]; + +/// A page showing "Codename BLUEFIN report" (BLUEFIN at x 55–90) with `font`, and the word, or +/// other text, in a bookmark, attachments, comments and XMP the mark doesn't touch: +/// - bookmarks: "Bluefin plans", "Annual figures", and under it "BLUEFIN appendix"; +/// - attachments: one named `bluefin.txt`, one described "About Bluefin", one with neither, and +/// a file attachment annotation for `Bluefin.pdf`; +/// - comments: one whose text mentions it, one by "Bluefin Team", one whose rich text does, one +/// that doesn't, and a pop-up repeating it (pop-ups aren't counted); +/// - XMP: an image's mentions it, a font's doesn't. +fn page_with_places(font: &str) -> Document { + let annot = |rest: &str| format!("<< /Type /Annot /Rect [200 200 220 220] {rest} >>").into_bytes(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /Names << /EmbeddedFiles 11 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im 24 0 R >> >> /Annots [16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R] >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET q 10 0 0 10 200 250 cm /Im Do Q"), + format!("{} /Metadata 26 0 R >>", font.replace("95 0 R", "6 0 R").strip_suffix(">>").unwrap()).into_bytes(), + widths(), + // 7–10: the outline. + b"<< /Type /Outlines /First 8 0 R /Last 9 0 R /Count 3 >>".to_vec(), + b"<< /Title (Bluefin plans) /Parent 7 0 R /Next 9 0 R >>".to_vec(), + b"<< /Title (Annual figures) /Parent 7 0 R /Prev 8 0 R /First 10 0 R /Last 10 0 R /Count 1 >>".to_vec(), + b"<< /Title (BLUEFIN appendix) /Parent 9 0 R >>".to_vec(), + // 11–15: the attachments' name tree and file specifications. + b"<< /Kids [12 0 R] >>".to_vec(), + b"<< /Limits [(a.txt) (bluefin.txt)] /Names [(a.txt) 13 0 R (b.txt) 14 0 R (bluefin.txt) 15 0 R] >>".to_vec(), + b"<< /Type /Filespec /F (a.txt) /UF (a.txt) >>".to_vec(), + b"<< /Type /Filespec /F (b.txt) /Desc (About Bluefin) >>".to_vec(), + b"<< /Type /Filespec /F (c.txt) >>".to_vec(), + // 16–21: the annotations. + annot("/Subtype /Text /Contents (Ask about bluefin) /T (Ada)"), + annot("/Subtype /Text /Contents (Seen) /T (Bluefin Team)"), + annot("/Subtype /FreeText /Contents (plain) /RC (

BLUEFIN

) /DA (/F1 10 Tf)"), + annot("/Subtype /Text /Contents (Looks good) /T (Ada) /Popup 21 0 R"), + annot("/Subtype /FileAttachment /Contents (Attached) /T (Ada) /FS << /Type /Filespec /UF (Bluefin.pdf) >>"), + annot("/Subtype /Popup /Parent 19 0 R /Contents (bluefin)"), + // 22–23: unused. + b"null".to_vec(), + b"null".to_vec(), + // 24–26: an image with XMP that mentions it, and the two packets. + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Metadata 25 0 R", b"\x80"), + stream("/Type /Metadata /Subtype /XML", XMP_BLUEFIN.as_bytes()), + stream("/Type /Metadata /Subtype /XML", XMP_OTHER.as_bytes()), + ]; + pdf(objs) +} + +fn held(doc: &Document) -> Vec { + HELD.iter().map(|&n| (*doc.get(ObjRef::new(n, 0))).clone()).collect() +} + +#[test] +fn places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept() { + let mut doc = page_with_places(FONT); + let before = held(&doc); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 7, "{r:?}"); + assert_eq!(r.still_holding, Elsewhere { bookmarks: 2, attachments: 3, comments: 3, xmp: 1, unsearched: false }, "{r:?}"); + assert_eq!(r.still_holding.total(), 9); + assert_eq!(held(&doc), before, "counting changes nothing"); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + assert!(catalog.contains(b"Outlines") && catalog.contains(b"Names")); + assert_eq!(annots_of(&doc, &pdfcraft_model::pages(&doc)[0].dict).len(), 6, "the comments stay"); +} + +/// Removed text that is unrelated to every place counts nothing. +#[test] +fn places_without_the_removed_text_are_not_counted() { + let mut doc = page_with_places(FONT); + // "Codename" alone, at x 10–50. + mark(&mut doc, 0, &[[8.0, 95.0, 50.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 8, "{r:?}"); + assert_eq!(r.still_holding, Elsewhere::default(), "{r:?}"); +} + +/// When the removed text can't be known (a removed glyph whose text isn't known), every place +/// that holds any text counts, and says why. +#[test] +fn places_count_whenever_they_hold_text_if_the_removed_text_is_unknown() { + let unnamed = FONT.replace(" /Widths", " /Encoding << /Differences [66 /g17 /g18 /g19 /g20 /g21 /g22 /g23 /g24 /g25 /g26 /g27 /g28 /g29 /g30 /g31 /g32 /g33 /g34 /g35 /g36 /g37 /g38 /g39 /g40 /g41 /g42] >> /Widths"); + let mut doc = page_with_places(&unnamed); + let before = held(&doc); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + // Every bookmark title, every attachment (each has a name), every comment but the pop-up, + // and both XMP packets (the font's too). + assert_eq!(r.still_holding, Elsewhere { bookmarks: 3, attachments: 4, comments: 5, xmp: 2, unsearched: true }, "{r:?}"); + assert_eq!(held(&doc), before); +} + +/// A bookmark list that loops back on itself is walked once. +#[test] +fn a_looping_outline_is_read_once() { + let mut doc = page_with_places(FONT); + // The last item's /Next goes back to the first. + doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"Next".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(10, 0), |d| d.set(b"First".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.still_holding.bookmarks, 2, "{r:?}"); +} diff --git a/crates/redact/src/tests/identification.rs b/crates/redact/src/tests/identification.rs new file mode 100644 index 0000000000..c896e335fe --- /dev/null +++ b/crates/redact/src/tests/identification.rs @@ -0,0 +1,123 @@ +//! #2539 review: the PDF/A and PDF/UA identification of a catalog XMP packet the metadata check +//! removes is written back in a packet of its own, or reported lost. + +use super::*; +use crate::{Claim, Identification}; + +const DOC_XMP_HEAD: &str = r#""#; +const DOC_XMP_TAIL: &str = r#""#; + +/// One page showing "Codename BLUEFIN report", whose catalog XMP is `xmp` (raw bytes). +fn page(xmp: &[u8]) -> Document { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", xmp), + b"<< /Producer (Test) >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap() +} + +/// Apply a mark over "BLUEFIN" and return the report and the catalog's XMP text afterwards. +fn redact(doc: &mut Document) -> (Report, Option) { + mark(doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(doc, None).unwrap(); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()); + let text = xmp.and_then(|x| crate::metadata::xmp_text(doc, &x)); + (r, text) +} + +/// The packet that names the redacted word goes, and one holding only its PDF/A and PDF/UA +/// identification takes its place: the PDF/A properties written as attributes, PDF/UA's as an +/// element under a prefix of its own, both found by namespace. Nothing else is copied. +#[test] +fn the_identification_of_a_removed_packet_is_written_back() { + let body = r#"1Bluefin reportAda Lovelace"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!(r.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::Kept }, "{r:?}"); + assert_eq!(r.metadata, 1, "{r:?}"); + let text = text.expect("a packet is written back"); + assert!(!text.to_lowercase().contains("bluefin") && !text.contains("Lovelace"), "{text}"); + let xml = roxmltree::Document::parse(&text).expect("well-formed"); + let value = |ns: &str, local: &str| { + xml.descendants().find(|n| n.tag_name().namespace() == Some(ns) && n.tag_name().name() == local).and_then(|n| n.text()).map(str::to_string) + }; + let a = "http://www.aiim.org/pdfa/ns/id/"; + let ua = "http://www.aiim.org/pdfua/ns/id/"; + assert_eq!((value(a, "part").as_deref(), value(a, "conformance").as_deref(), value(ua, "part").as_deref()), (Some("2"), Some("B"), Some("1"))); + // PDF/A-2 accepts pdfuaid only through an extension schema describing it. + assert_eq!(value("http://www.aiim.org/pdfa/ns/schema#", "prefix").as_deref(), Some("pdfuaid"), "{text}"); +} + +/// A packet without identification is removed as before, and nothing is said about it. +#[test] +fn a_packet_without_identification_is_only_removed() { + let body = r#"Bluefin report"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification::default(), None), "{r:?}"); +} + +/// The identification is lost, and said to be, when its value isn't the code it must be (a +/// conformance level that is a word) or when the packet can't be read (here it isn't UTF-8). +#[test] +fn an_identification_that_cannot_be_kept_is_reported_lost() { + let wordy = r#""#; + let mut doc = page(format!("{DOC_XMP_HEAD}{wordy}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification { pdfa: Claim::Lost, pdfua: Claim::None }, None), "{r:?}"); + + let latin1 = [ + DOC_XMP_HEAD.as_bytes(), + br#""#, + DOC_XMP_TAIL.as_bytes(), + ] + .concat(); + let mut doc = page(&latin1); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification { pdfa: Claim::None, pdfua: Claim::Lost }, None), "{r:?}"); + assert!(r.identification.lost()); +} + +/// When the removed text couldn't be searched for, every catalog packet goes, but a part number +/// and a conformance letter, one letter or digit each, can't hold it and are written back; a +/// revision year could, and isn't. +#[test] +fn unsearched_text_keeps_only_one_character_codes() { + let body = r#"Annual figures"#; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (ABC) Tj ET"), + // Glyph names outside the glyph list: the removed text isn't known. + FONT.replace("95 0 R", "6 0 R").replace(" /Widths", " /Encoding << /Differences [65 /g17 /g18 /g19] >> /Widths").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::None }, "{r:?}"); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()).unwrap(); + let text = crate::metadata::xmp_text(&doc, &xmp).unwrap(); + assert!(text.contains("4") && !text.contains("2020") && !text.contains("Annual"), "{text}"); +} + +/// The extension schema PDF/A-2 needs for pdfuaid is fixed text, but if the redacted words are +/// among its words ("schema" here) it isn't written, and the PDF/UA claim is reported lost. +#[test] +fn the_extension_schema_is_left_out_when_it_uses_a_redacted_word() { + let body = r#"Schema notes"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let s = crate::metadata::scrub_within(&mut doc, &["SCHEMA".into()], true, 1 << 28).unwrap(); + assert_eq!(s.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::Lost }); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()).unwrap(); + let text = crate::metadata::xmp_text(&doc, &xmp).unwrap(); + assert!(!text.to_lowercase().contains("schema") && text.contains("U"), "{text}"); +} diff --git a/crates/redact/src/tests/metadata_fail_closed.rs b/crates/redact/src/tests/metadata_fail_closed.rs new file mode 100644 index 0000000000..2735b302ba --- /dev/null +++ b/crates/redact/src/tests/metadata_fail_closed.rs @@ -0,0 +1,180 @@ +//! #2539 review: each way the metadata check fails closed, end to end. When the removed text +//! can't be known in full, every `/Info` string and both the catalog's and the page's XMP go; +//! when one XMP packet can't be read, that packet goes and the rest of the metadata is checked +//! as usual. + +use super::*; + +const CLEAN_XMP: &str = r#"Annual figures"#; + +/// One page showing `content`, with `/Info` (two strings and a name), catalog XMP (object 7) +/// and page XMP (object 8). None of the metadata mentions anything the tests remove. +fn page_with_metadata(content: &[u8], catalog_xmp: Vec) -> Document { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 8 0 R >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + catalog_xmp, + stream("/Type /Metadata /Subtype /XML", CLEAN_XMP.as_bytes()), + b"<< /Title (Annual figures) /Producer (Test) /Trapped /False >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 9 0 R").build())).unwrap() +} + +fn clean_xmp() -> Vec { + stream("/Type /Metadata /Subtype /XML", CLEAN_XMP.as_bytes()) +} + +fn catalog_has_xmp(doc: &Document) -> bool { + doc.get(doc.root().unwrap()).as_dict().is_some_and(|c| c.contains(b"Metadata")) +} + +fn page_has_xmp(doc: &Document) -> bool { + pdfcraft_model::pages(doc)[0].dict.contains(b"Metadata") +} + +/// Mark the line at y 100 across the page and apply. +fn redact_line(doc: &mut Document) -> Report { + mark(doc, 0, &[[0.0, 95.0, 300.0, 110.0]], ""); + apply(doc, None).unwrap() +} + +/// Every `/Info` string and both XMP streams went, though none of them mentions what was removed. +fn assert_cleared(doc: &Document, r: &Report) { + assert_eq!(r.metadata, 4, "{r:?}"); + assert_eq!(info_keys(doc), ["Trapped"], "strings go, the name stays"); + assert!(!catalog_has_xmp(doc) && !page_has_xmp(doc), "both XMP streams go"); +} + +/// Control for the tests below: removing a short word that the metadata doesn't mention keeps +/// all of it. +#[test] +fn metadata_that_does_not_mention_the_removed_text_stays() { + let mut doc = page_with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.metadata, 0, "{r:?}"); + assert_eq!(info_keys(&doc), ["Title", "Producer", "Trapped"]); + assert!(catalog_has_xmp(&doc) && page_has_xmp(&doc)); +} + +/// A single removed run longer than the collection keeps (64 KiB): the part past the limit is +/// never seen. The run is one letter, 70,000 spaces (one space once normalized) and another +/// letter, so what would be collected without the limit is short enough to search, and only +/// the limit itself clears the metadata. +#[test] +fn a_removed_run_over_64_kib_clears_the_metadata() { + // At 0.004 pt each glyph advances 0.002 pt, so all 70,002 fit in 141 pt of one line. + let text = format!("A{}B", " ".repeat(70_000)); + let content = format!("BT /F1 0.004 Tf 10 100 Td ({text}) Tj ET"); + let mut doc = page_with_metadata(content.as_bytes(), clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.glyphs, 70_002, "{r:?}"); + assert_cleared(&doc, &r); +} + +/// More than 20,000 distinct terms: each removed run is one word, far enough from the last to +/// start a run of its own, and each word is different. +#[test] +fn more_than_20000_terms_clear_the_metadata() { + let mut content = b"BT /F1 10 Tf 10 100 Td".to_vec(); + // Words alternate between x 10 and x 70; each is 30 pt wide, so the gap between two is + // 30 pt, more than a run spans. + for i in 0..20_001u32 { + let x = if i % 2 == 0 { 60 } else { -60 }; + content.extend_from_slice(format!(" (q{i:05}) Tj {x} 0 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let mut doc = page_with_metadata(&content, clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.glyphs, 20_001 * 6, "{r:?}"); + assert_cleared(&doc, &r); +} + +/// A removed run longer than 4,096 characters can't be searched for whole. +#[test] +fn a_removed_run_over_4096_characters_clears_the_metadata() { + // 4,100 letters at 0.05 pt advance 0.025 pt each: 103 pt in all, one run, 4 KiB. + let content = format!("BT /F1 0.05 Tf 10 100 Td ({}) Tj ET", "Z".repeat(4_100)); + let mut doc = page_with_metadata(content.as_bytes(), clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.glyphs, 4_100, "{r:?}"); + assert_cleared(&doc, &r); +} + +/// The catalog's packet can't be read: that packet goes, unread, while the page's packet and +/// `/Info`, which don't mention the removed word, stay. +fn assert_only_the_catalog_packet_went(catalog_xmp: Vec) { + let mut doc = page_with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", catalog_xmp); + let r = redact_line(&mut doc); + assert_eq!(r.metadata, 1, "{r:?}"); + assert!(!catalog_has_xmp(&doc), "the packet that couldn't be read goes"); + assert!(page_has_xmp(&doc), "the readable packet is checked, and stays"); + assert_eq!(info_keys(&doc), ["Title", "Producer", "Trapped"]); +} + +/// XMP whose Flate data is corrupt can't be decoded, so it can't be searched. +#[test] +fn an_xmp_stream_whose_filter_data_is_corrupt_goes() { + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter /FlateDecode", b"this is not deflate data at all")); +} + +/// A valid Flate stream cut off half way decodes, leniently, to the first part of the packet; +/// the rest was never seen, so the packet isn't searched but goes. +#[test] +fn an_xmp_stream_whose_filter_data_is_cut_short_goes() { + let full = Stream::flate(Dict::new(), CLEAN_XMP.as_bytes()); + let cut = full.raw.get(..full.raw.len() / 2).unwrap(); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter /FlateDecode", cut)); +} + +/// XMP that decodes to more than 16 MiB isn't read at all. +#[test] +fn an_xmp_stream_over_16_mib_goes() { + // A valid packet, padded with whitespace past the limit. + let padded = CLEAN_XMP.replace("", &format!("{}", " ".repeat(16 * 1024 * 1024))); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML", padded.as_bytes())); +} + +/// XMP that is neither UTF-8 nor UTF-16 (here Latin-1) isn't searched lossily. +#[test] +fn an_xmp_stream_in_another_encoding_goes() { + let latin1 = CLEAN_XMP.replace("Annual figures", "Ann\u{e9}e").replace('\u{e9}', "\u{0}"); + let bytes: Vec = latin1.bytes().map(|b| if b == 0 { 0xE9 } else { b }).collect(); + assert!(std::str::from_utf8(&bytes).is_err()); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML", &bytes)); +} + +/// XMP behind two filters has no strict decoder here, so it isn't read either. The first filter +/// alone decodes to valid ASCII (the packet in hex), which must not be searched as the packet. +#[test] +fn an_xmp_stream_behind_two_filters_goes() { + let hex: String = CLEAN_XMP.bytes().map(|b| format!("{b:02x}")).chain(std::iter::once(">".to_string())).collect(); + let Stream { raw, .. } = Stream::flate(Dict::new(), hex.as_bytes()); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter [/FlateDecode /ASCIIHexDecode]", &raw)); +} + +/// A form XObject under a mark whose content can't be decoded is removed without being read, so +/// what it showed is unknown (the deep-form case is `a_form_removed_unread_clears_the_metadata`). +#[test] +fn a_form_whose_content_cant_be_read_clears_the_metadata() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 9 0 R >> >> /Metadata 8 0 R >>".to_vec(), + stream("", b"q 1 0 0 1 0 0 cm /Fm Do Q"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + clean_xmp(), + clean_xmp(), + // 9: a form whose data no filter here decodes. + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /NoSuchDecode", b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET"), + b"<< /Title (Annual figures) /Producer (Test) /Trapped /False >>".to_vec(), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 10 0 R").build())).unwrap(); + let r = redact_line(&mut doc); + assert_eq!(r.forms_removed, 1, "{r:?}"); + assert_cleared(&doc, &r); +} diff --git a/crates/redact/src/tests/metadata_matching.rs b/crates/redact/src/tests/metadata_matching.rs new file mode 100644 index 0000000000..8cab62b0ce --- /dev/null +++ b/crates/redact/src/tests/metadata_matching.rs @@ -0,0 +1,98 @@ +//! #2539 review (second round): spellings of the removed text the metadata check has to match. + +use super::*; + +fn dc_title(title: &str) -> String { + format!(r#"{title}"#) +} + +/// A tracked heading, its letters spread by `TJ` numbers rather than character spacing, is +/// collected as "B L U E F I N": its letters and digits still match `/Title (Bluefin)` and the +/// XMP title, while `/Producer` stays. +#[test] +fn a_heading_tracked_with_tj_numbers_matches_the_metadata() { + let content = b"BT /F1 10 Tf 10 100 Td [(B) -300 (L) -300 (U) -300 (E) -300 (F) -300 (I) -300 (N)] TJ ET"; + let mut doc = with_metadata(content, "<< /Title (Bluefin) /Producer (Test) >>", &dc_title("Bluefin")); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 7, "{r:?}"); + assert_eq!(info_keys(&doc), ["Producer"], "{r:?}"); + assert!(!has_xmp(&doc), "the XMP title named it"); + assert_eq!(r.metadata, 2, "{r:?}"); +} + +/// A value that spells the removed word with a space or a hyphen in it still holds it: "Blue fin" +/// and "BLUE-FIN" go when "BLUEFIN" is redacted, while "Bluefish", which only shares letters +/// with it, stays. +#[test] +fn a_value_spaced_or_hyphenated_otherwise_matches() { + let info = "<< /Title (Blue fin) /Keywords (tuna, BLUE-FIN) /Subject (Bluefish) /Producer (Test) >>"; + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET", info, &dc_title("Blue Fin")); + // "BLUEFIN" spans x 55-90. + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(info_keys(&doc), ["Subject", "Producer"], "{r:?}"); + assert!(!has_xmp(&doc), "the XMP title spelled it with a space"); + assert_eq!(r.metadata, 3, "{r:?}"); +} + +/// `s` as a UTF-16BE text string in hex, byte order mark first. +fn utf16be(s: &str) -> String { + format!("", s.encode_utf16().map(|u| format!("{u:04X}")).collect::()) +} + +/// The removed text and the metadata are compared after Unicode compatibility normalization +/// (NFKC) and lower case on both sides: a decomposed "Müller" (u + U+0308) in a UTF-16BE `/Title` +/// matches the composed one removed from the page, a "fi" ligature from a `ToUnicode` map matches +/// "fi", and full-width letters match their ASCII forms. +#[test] +fn unicode_forms_of_the_removed_text_match() { + let info = format!( + "<< /Title {} /Subject (Finance plan) /Keywords {} /Author (Ada Lovelace) /Producer (Test) >>", + utf16be("Mu\u{308}ller"), + utf16be("\u{FF22}\u{FF2C}\u{FF35}\u{FF25}\u{FF26}\u{FF29}\u{FF2E}") + ); + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); + let removed = ["M\u{FC}ller".to_string(), "\u{FB01}nance".to_string(), "Bluefin".to_string()]; + let n = metadata::scrub_within(&mut doc, &removed, true, 1 << 28).unwrap().removed; + assert_eq!(info_keys(&doc), ["Author", "Producer"]); + assert_eq!(n, 3); +} + +/// A value whose normalized form would be far longer than itself (U+FDFA is 18 letters in NFKC) +/// isn't normalized in full: it counts as holding the removed text (fail-closed). +#[test] +fn a_value_that_normalizes_to_far_more_text_counts_as_holding_it() { + let info = format!("<< /Subject {} /Producer (Test) >>", utf16be(&"\u{FDFA}".repeat(50))); + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap().removed, 1); + assert_eq!(info_keys(&doc), ["Producer"]); +} + +/// `s` as UTF-16LE bytes in hex, behind an `FF FE` byte order mark. +fn utf16le(s: &str) -> String { + format!("", s.encode_utf16().map(|u| format!("{:02X}{:02X}", u & 0xFF, u >> 8)).collect::()) +} + +/// ISO 32000-2 §7.9.2.2.1 defines text strings in PDFDocEncoding, UTF-16BE (`FE FF`) and UTF-8 +/// (`EF BB BF`) only, but a string behind an `FF FE` mark is UTF-16LE to anyone who reads it as +/// such; read through PDFDocEncoding, Cyrillic or CJK text in it could never match. It is +/// searched as UTF-16LE in `/Info` and in the places applying leaves (here a bookmark title). +#[test] +fn a_utf16le_string_is_searched_as_utf16le() { + let info = format!("<< /Title {} /Subject {} /Producer (Test) >>", utf16le("Синий план"), utf16le("Годовой отчёт")); + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); + assert_eq!(metadata::scrub_within(&mut doc, &["СИНИЙ".into()], true, 1 << 28).unwrap().removed, 1); + assert_eq!(info_keys(&doc), ["Subject", "Producer"]); + + // A bookmark titled the same way is counted as still holding it. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(), + b"<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>".to_vec(), + format!("<< /Title {} /Parent 4 0 R /Dest [3 0 R /Fit] >>", utf16le("Синий план")).into_bytes(), + ]; + let doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap(); + assert_eq!(crate::elsewhere::find(&doc, &["СИНИЙ".into()], true), Elsewhere { bookmarks: 1, ..Default::default() }); +} diff --git a/crates/redact/src/tests_verify.rs b/crates/redact/src/tests_verify.rs index 28853cc4a4..136cb2b207 100644 --- a/crates/redact/src/tests_verify.rs +++ b/crates/redact/src/tests_verify.rs @@ -115,6 +115,37 @@ pub(super) fn run(doc: &mut Document) -> (Result, Proof) { (result, proof) } +/// Metadata a redaction that missed it would have left: applying removes the metadata holding +/// the removed text (#2448), so a proof test of those surfaces puts it back afterwards. +enum Left<'a> { + Info(&'a [u8]), + Xmp(&'a [u8]), +} + +/// Capture, apply, put `left` back, prove. +fn run_leaving(doc: &mut Document, left: Left<'_>) -> (Result, Proof) { + let mut snap = Snapshot::capture(doc, None).unwrap(); + let result = applied(doc, &mut snap); + match left { + Left::Info(title) => { + let mut info = Dict::new(); + info.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(title.to_vec()))); + let info = doc.add(Object::Dict(info)); + doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(info)); + } + Left::Xmp(packet) => { + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::Name(b"Metadata".to_vec())); + dict.set(b"Subtype".to_vec(), Object::Name(b"XML".to_vec())); + let xmp = doc.add(Object::Stream(Stream::from_raw(dict, packet.to_vec()))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(xmp))).unwrap(); + } + } + let proof = snap.prove(doc, None, &ProofOptions::default()); + (result, proof) +} + /// Prove a document the redaction was never applied to (as a full save would write it). pub(super) fn prove_unapplied(doc: &mut Document) -> Proof { let snap = Snapshot::capture(doc, None).unwrap(); @@ -207,7 +238,7 @@ fn the_manifest_has_digests_and_counts_but_no_plaintext() { #[test] fn survivors_do_not_echo_the_text_either() { let mut doc = marked(Spec { trailer: "/Info 7 0 R", extra: vec![b"<< /Title (SECRETVALUE) >>".to_vec()], ..Spec::default() }); - let (_, proof) = run(&mut doc); + let (_, proof) = run_leaving(&mut doc, Left::Info(b"SECRETVALUE")); assert!(!proof.passed()); for shown in [proof.to_text(), format!("{proof:?}")] { assert!(!shown.contains(SECRET), "{shown}"); @@ -224,7 +255,7 @@ fn numeric_only_tj_is_not_glyph_carrying() { fn a_survivor_in_the_info_dictionary() { let mut doc = marked(Spec { trailer: "/Info 7 0 R", extra: vec![b"<< /Title (Report SECRETVALUE) /Author (Someone) >>".to_vec()], ..Spec::default() }); - let (_, proof) = run(&mut doc); + let (_, proof) = run_leaving(&mut doc, Left::Info(b"Report SECRETVALUE")); assert_eq!(surface(&proof, Surface::Info).verdict, Verdict::Survivor); assert_eq!(proof.entries[0].status, Status::Failed); assert!(!proof.passed()); @@ -232,20 +263,38 @@ fn a_survivor_in_the_info_dictionary() { #[test] fn a_survivor_in_xmp() { - let xmp = stream("/Type /Metadata /Subtype /XML", b"Quarterly & SECRETVALUE"); + let packet: &[u8] = b"Quarterly & SECRETVALUE"; + let xmp = stream("/Type /Metadata /Subtype /XML", packet); let mut doc = marked(Spec { catalog: "/Metadata 7 0 R", extra: vec![xmp], ..Spec::default() }); - let (_, proof) = run(&mut doc); + let (_, proof) = run_leaving(&mut doc, Left::Xmp(packet)); assert_eq!(surface(&proof, Surface::Xmp).verdict, Verdict::Survivor, "{}", proof.to_text()); assert!(!proof.passed()); } +#[test] +fn applying_removes_the_metadata_survivors_the_proof_finds() { + // The same files as the two tests above, applied without putting anything back: the + // metadata holding the removed text is gone (#2448), and the proof finds nothing there. + let mut doc = + marked(Spec { trailer: "/Info 7 0 R", extra: vec![b"<< /Title (Report SECRETVALUE) /Author (Someone) >>".to_vec()], ..Spec::default() }); + let (r, proof) = run(&mut doc); + assert_eq!(r.unwrap().metadata, 1); + assert_ne!(surface(&proof, Surface::Info).verdict, Verdict::Survivor, "{}", proof.to_text()); + let xmp = stream("/Type /Metadata /Subtype /XML", b"Quarterly & SECRETVALUE"); + let mut doc = marked(Spec { catalog: "/Metadata 7 0 R", extra: vec![xmp], ..Spec::default() }); + let (r, proof) = run(&mut doc); + assert_eq!(r.unwrap().metadata, 1); + assert_ne!(surface(&proof, Surface::Xmp).verdict, Verdict::Survivor, "{}", proof.to_text()); +} + #[test] fn a_long_numeric_reference_in_xmp_is_a_survivor() { // A character reference may carry any number of leading zeros: the padded form reads the // same, and is swept the same. - let xmp = stream("/Type /Metadata /Subtype /XML", b"SECRETVALUE"); + let packet: &[u8] = b"SECRETVALUE"; + let xmp = stream("/Type /Metadata /Subtype /XML", packet); let mut doc = marked(Spec { catalog: "/Metadata 7 0 R", extra: vec![xmp], ..Spec::default() }); - let (_, proof) = run(&mut doc); + let (_, proof) = run_leaving(&mut doc, Left::Xmp(packet)); assert_eq!(surface(&proof, Surface::Xmp).verdict, Verdict::Survivor, "{}", proof.to_text()); assert!(!proof.passed()); } @@ -855,14 +904,15 @@ fn an_ambiguous_needle_is_still_swept_outside_page_content() { let content = b"BT /F1 10 Tf 10 200 Td (SECRETVALUE) Tj ET BT /F1 10 Tf 10 100 Td (SECRETVALUE) Tj ET"; let spec = Spec { trailer: "/Info 7 0 R", extra: vec![b"<< /Title (SECRETVALUE) >>".to_vec()], ..Spec::default() }; let mut doc = marked_content(content, spec); - let (_, proof) = run(&mut doc); + let (_, proof) = run_leaving(&mut doc, Left::Info(b"SECRETVALUE")); assert_eq!(surface(&proof, Surface::Info).verdict, Verdict::Survivor, "{}", proof.to_text()); assert!(!proof.passed()); // Likewise an XMP packet and an annotation string. - let xmp = stream("/Type /Metadata /Subtype /XML", b"SECRETVALUE"); + let packet: &[u8] = b"SECRETVALUE"; + let xmp = stream("/Type /Metadata /Subtype /XML", packet); let mut doc = marked_content(content, Spec { catalog: "/Metadata 7 0 R", extra: vec![xmp], ..Spec::default() }); - assert_eq!(surface(&run(&mut doc).1, Surface::Xmp).verdict, Verdict::Survivor); + assert_eq!(surface(&run_leaving(&mut doc, Left::Xmp(packet)).1, Surface::Xmp).verdict, Verdict::Survivor); let annot = b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (SECRETVALUE) >>".to_vec(); let mut doc = marked_content(content, Spec { page: "/Annots [7 0 R]", extra: vec![annot], ..Spec::default() }); assert_eq!(surface(&run(&mut doc).1, Surface::ObjectStrings).verdict, Verdict::Survivor); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 1112cfbf0b..015f32449d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,8 +37,8 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, - MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, - group_primaries, link_replies, thread_tops, + MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, + fill_group_text, group_primaries, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; @@ -86,6 +86,8 @@ pub struct DocInfo { pub layer_order: Vec, pub fonts: Vec, pub attachments: Vec, + /// The document is a PDF portfolio (`/Collection`): its documents are `attachments`. + pub portfolio: Option, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. pub transitions: Vec, /// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination, @@ -95,5 +97,7 @@ pub struct DocInfo { pub warnings: Vec, } +#[cfg(test)] +mod portfolio_tests; #[cfg(test)] mod tests; diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index e4ead8d264..88dc13c34a 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,5 +1,5 @@ use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES}; -use super::model::{AttachmentSource, PageInfo, Xfa}; +use super::model::{Attachment, AttachmentSource, PageInfo, Portfolio, PortfolioView, Xfa}; use super::navigation::text_string; use super::{ Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object, @@ -177,8 +177,8 @@ impl<'a> Inspector<'a> { } /// Reads what `info` holds beyond the page list: encryption, document information, tagging, - /// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments, - /// JavaScript and the opening destination. + /// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments + /// (and, for a PDF portfolio, how it presents them), JavaScript and the opening destination. pub(super) fn fill(&self, info: &mut DocInfo) { // lopdf drops /Encrypt from the trailer once it has decrypted the file. info.encrypted = self.doc.encrypted(); @@ -284,6 +284,7 @@ impl<'a> Inspector<'a> { } info.has_javascript |= names.get(b"JavaScript").is_ok(); } + info.portfolio = catalog.get(b"Collection").ok().and_then(|o| self.dict(o)).map(|c| self.portfolio(c, &info.attachments)); if let Ok(action) = catalog.get(b"OpenAction") && let Some(dict) = self.dict(action) && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) @@ -293,6 +294,26 @@ impl<'a> Inspector<'a> { info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); } + /// A portfolio's collection dictionary (Table 153). `/D` names the initial document's key in + /// the `/EmbeddedFiles` name tree, decoded as the keys are. + fn portfolio(&self, collection: &Dictionary, attachments: &[Attachment]) -> Portfolio { + let view = match self.name(collection, b"View").as_deref() { + Some("T") => PortfolioView::Tile, + Some("H") => PortfolioView::Hidden, + Some("C") => PortfolioView::Custom, + _ => PortfolioView::Details, + }; + let initial = match collection.get(b"D").map(|o| self.resolve(o)) { + Ok(Object::String(bytes, _)) => { + let name = text_string(bytes); + attachments.iter().position(|a| matches!(&a.source, AttachmentSource::Document { key } if *key == name)) + } + _ => None, + }; + Portfolio { initial, view } + } + + /// The object a reference points at, or `o` itself for a direct object or a broken reference. pub(super) fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index a83f59aa06..18b7533f2c 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -796,6 +796,8 @@ pub enum LayerItem { pub struct Attachment { pub name: String, pub description: Option, + /// Whether the file specification contained `/Desc`, including an empty value. + pub has_description: bool, pub size: Option, pub source: AttachmentSource, } @@ -809,6 +811,44 @@ pub enum AttachmentSource { Annotation { page: usize, index: usize }, } +/// A PDF portfolio: a catalog with a `/Collection` (ISO 32000-2 §7.11.6, Table 153). Its pages +/// are a cover sheet for viewers that show no file list; its documents are its attachments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Portfolio { + /// The document to present first (`/D`): the index in `DocInfo::attachments` of the + /// `/EmbeddedFiles` entry it names. `None` without `/D` (the portfolio's own pages come + /// first) or when it names no listed attachment. + pub initial: Option, + /// How the file list first shows (`/View`). + pub view: PortfolioView, +} + +/// A portfolio's initial view (`/Collection /View`, Table 153). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PortfolioView { + /// `D`, the default: the files with their details. + #[default] + Details, + /// `T`: the files as tiles. + Tile, + /// `H`: the file list starts hidden, shown when the reader asks for it. + Hidden, + /// `C`: a custom navigator (`/Navigator`). + Custom, +} + +impl PortfolioView { + /// The view's name in `doc_info`. + pub fn name(self) -> &'static str { + match self { + Self::Details => "details", + Self::Tile => "tile", + Self::Hidden => "hidden", + Self::Custom => "custom", + } + } +} + /// A font used by the document (Document Properties ▸ Fonts). #[derive(Clone, Debug, PartialEq, Eq)] pub struct FontInfo { diff --git a/crates/render/src/inspect/portfolio_tests.rs b/crates/render/src/inspect/portfolio_tests.rs new file mode 100644 index 0000000000..301a9dab73 --- /dev/null +++ b/crates/render/src/inspect/portfolio_tests.rs @@ -0,0 +1,53 @@ +//! PDF portfolios (`/Collection`, ISO 32000-2 §7.11.6): the attachment a portfolio presents +//! first, and how its file list first shows. + +use super::{Arc, Portfolio, PortfolioView, inspect}; + +/// A one-page portfolio of `a.txt` and `b.pdf`, keyed in UTF-16 as portfolio writers key them, +/// with `collection` as its `/Collection` entries (`None`: not a portfolio). +fn portfolio(collection: Option<&str>) -> Vec { + let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default(); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [ 5 0 R 7 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +4 0 obj << /Type /EmbeddedFile /Length 5 >> stream +hello +endstream endobj +5 0 obj << /Type /Filespec /F (a.txt) /UF (a.txt) /EF << /F 4 0 R >> >> endobj +6 0 obj << /Type /EmbeddedFile /Length 8 >> stream +%PDF-1.7 +endstream endobj +7 0 obj << /Type /Filespec /F (b.pdf) /UF (b.pdf) /EF << /F 6 0 R >> >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() +} + +/// The portfolio [`portfolio`] with `collection` reads as. +fn read(collection: Option<&str>) -> Option { + inspect(Arc::new(portfolio(collection)), None).expect("opens").portfolio +} + +/// `/D` picks the attachment presented first, matched as the keys decode, and `/View` how the +/// list first shows. +#[test] +fn a_portfolio_presents_the_attachment_its_d_names() { + // `/D` names `b.pdf` in the keys' own UTF-16, then as a literal string: both decode as the + // keys do. + let tile = Some(Portfolio { initial: Some(1), view: PortfolioView::Tile }); + assert_eq!(read(Some("/D /View /T")), tile); + assert_eq!(read(Some("/D (b.pdf) /View /T")), tile); + // Without `/D` the portfolio's own pages come first, and a `/D` naming no file presents none. + assert_eq!(read(Some("")), Some(Portfolio { initial: None, view: PortfolioView::Details })); + assert_eq!(read(Some("/D (c.pdf) /View /D")), Some(Portfolio { initial: None, view: PortfolioView::Details })); + // `/View /H` keeps the file list hidden; `/C` is a custom navigator; an unknown view is + // the default, details. + assert_eq!(read(Some("/D (a.txt) /View /H")), Some(Portfolio { initial: Some(0), view: PortfolioView::Hidden })); + assert_eq!(read(Some("/View /C")).map(|p| p.view), Some(PortfolioView::Custom)); + assert_eq!(read(Some("/View /Bogus /D 7")), Some(Portfolio { initial: None, view: PortfolioView::Details })); + // The same attachments without a `/Collection` are no portfolio. + assert_eq!(read(None), None); +} diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 75c65c6bf9..3dbc1c665f 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -372,7 +372,8 @@ impl<'a> Inspector<'a> { .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) .or_else(|| fallback(text_budget)) .unwrap_or_else(|| "attachment".into()); - Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } + let has_description = d.is_some_and(|d| d.get(b"Desc").is_ok()); + Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), has_description, size, source } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 03e6db7a2b..0edaf558bc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -20,8 +20,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, - MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, - check_pages, fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops, + MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, + attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops, }; pub use link_area::{MAX_LINK_QUADS, MAX_URI_BASE_BYTES, link_quads, quad_contains, resolve_uri}; pub use pixels::Pixels; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3d31d9543d..f95aaf36d4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -88,6 +88,11 @@ impl PdfCraftApp { right_before_read: None, comments_panel_closed: false, right_panel_closed: false, + portfolio_initial: None, + portfolio_seen: None, + #[cfg(target_arch = "wasm32")] + web_reads: Default::default(), + portfolio_panel: None, quick_tool: QuickTool::Select, space_hand: None, full_screen_tool: None, @@ -111,6 +116,11 @@ impl PdfCraftApp { system_text_scale: 1.0, ui_scale_applied: None, flatten_fill_sign_on_save: false, + ask_to_lock_signatures: true, + placed_signatures: Default::default(), + pending_lock: None, + lock_answered: None, + lock_remember: false, fill_text_size: fill_sign::TEXT_SIZE, fill_tool: fill_sign::FillTool::Signature, fill_text_color: [0.0; 3], @@ -122,6 +132,7 @@ impl PdfCraftApp { updates: updates::Updates::default(), printer_source: None, options_source: None, + spooler: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), @@ -192,6 +203,7 @@ impl PdfCraftApp { pending_overwrite: None, save_override: None, props_draft: None, + attachment_description_draft: None, view_draft: None, custom_draft: None, requests: Default::default(), @@ -301,6 +313,7 @@ impl PdfCraftApp { community_card_closed: false, pending_link: None, pending_full_screen: None, + pending_window: None, pending_remote_link: None, protect_draft: Default::default(), text_style_draft: Default::default(), @@ -334,6 +347,8 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + printer_marks_draft: Default::default(), + convert_colours_draft: Default::default(), hairlines_draft: Default::default(), } } @@ -629,6 +644,7 @@ impl PdfCraftApp { OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), } } + self.present_portfolios(); if let Some((_, id)) = self.active_ids() { let out = self.session.take_js_output(id); self.handle_js(id, out); @@ -777,7 +793,12 @@ impl PdfCraftApp { window_controls::resize_edges(&ctx); } chrome::tab_strip(self, ui); - chrome::mode_bar(self, ui); + // A document can hide the toolbars, the menu and the panels around it in its tab (ISO + // 32000-2 Table 147); the tab strip stays, and F8 brings the rest back (`viewer_prefs`). + let hidden = self.hidden_interface(); + if !hidden.mode_bar(self.native_menu.is_some()) { + chrome::mode_bar(self, ui); + } // Another mode or the Home tab puts the OCR Verify screen away, as Exit does; Correct // recognized text brings it back with its review as it was. if self.ocr_verify.open && (self.active.is_none() || self.ocr_verify.shown_in.is_some_and(|m| m != self.mode)) { @@ -804,15 +825,19 @@ impl PdfCraftApp { if self.all_tools_full && self.all_tools_tab != self.shown_tab() { self.all_tools_full = false; } - side_panels::show(self, ui); + if !hidden.window_ui { + side_panels::show(self, ui); + } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.mode == Mode::AllTools && self.all_tools_full { + if self.mode == Mode::AllTools && self.all_tools_full && !hidden.window_ui { all_tools_ui::show(self, ui); } else { panes::page_area(self, ui); } }); + // FitWindow and CenterWindow, once the document just opened is laid out. + self.window_preferences_frame(&ctx); self.process_pending_edits(); palette::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index aaf5c009f8..9381664527 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -116,6 +116,7 @@ impl PdfCraftApp { "header-footer" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)), "watermark" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)), "background" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Background)), + "printer-marks" => Some(Dialog::PrinterMarks), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), "word-count" => Some(Dialog::WordCount), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), @@ -142,6 +143,7 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "convert-colours" => Some(Dialog::ConvertColours), "fix-hairlines" => { // Through the command, so it is refused with no document or none that // can be modified, as the menu is. diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 889a0b8476..d18c98d540 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -323,8 +323,9 @@ impl PdfCraftApp { self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) } - /// Add a prepared document as a tab: its view, the recent-files entry, and what its scripts - /// said while it opened (at once in front, or when a background tab is first selected). + /// Add a prepared document as a tab: its view, the panels it opens with, a portfolio's file list + /// and initial document, the recent-files entry, and what its scripts said while it opened (at + /// once in front, or when a background tab is first selected). fn finish_open( &mut self, name: &str, @@ -335,6 +336,12 @@ impl PdfCraftApp { ) -> Result { let id = self.session.commit_open(prepared); self.password_prompt = None; + // A tab opening in front leaves a portfolio's tab, whose file list gives the panel back: + // the document a portfolio presents opens with the panels any other would. + if activate { + self.leave_portfolio(); + } + let right_before = self.right; // The reading-colours preference is app-wide: a newly opened document starts with it. // Idempotent: the engine returns `false` when the document has the setting already. self.session.set_reading_colors(id, self.reading_colors); @@ -345,6 +352,13 @@ impl PdfCraftApp { // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); let initial = doc.initial_view(); + // A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), kept by its + // name-tree key, which an edit meanwhile can't shift as it can an index. + let portfolio_initial = + doc.info.portfolio.as_ref().and_then(|p| p.initial).and_then(|i| doc.info.attachments.get(i)).and_then(|a| match &a.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }); if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { Some(RightPanel::Comments) @@ -362,9 +376,11 @@ impl PdfCraftApp { self.active = Some(self.views.len() - 1); } self.apply_initial_view(self.views.len() - 1, &initial); - // Where the reader left off wins over the file's own initial view (#1767). + self.apply_viewer_preferences(self.views.len() - 1, &initial, activate); + // Where the reader left off wins over the file's own initial view and the zoom its + // viewer preferences open at (#1767). self.apply_last_view(self.views.len() - 1); - // After both, so full screen shows one fitted page and leaving it comes back to them. + // After all of them, so full screen shows one fitted page and leaving it comes back to them. if self.full_screen && let Some(view) = self.views.last_mut() { @@ -375,6 +391,16 @@ impl PdfCraftApp { if activate && initial.full_screen { self.pending_full_screen = Some(id); } + if activate { + // A portfolio's tab shows its files from the start, in place of the panel there was + // before it opened, whichever panel its page mode asked for. + if opening_mode != Mode::Read && self.lists_files(id) { + self.show_portfolio_files(id, right_before); + } + // A document opening in front of a portfolio that hasn't presented its initial + // document yet (files opened together) keeps the front. + self.portfolio_initial = portfolio_initial.map(|key| (id, key)); + } if activate && self.left_open && self.left == LeftPanel::Tool("organize") @@ -487,6 +513,102 @@ impl PdfCraftApp { } } + /// A PDF portfolio (ISO 32000-2 §7.11.6) shows its documents, not the cover sheet that its + /// pages are. The document a portfolio that just opened presents first (`/D`) opens in a + /// tab of its own, and a portfolio's tab shows its file list, the Attachments panel, even + /// with the right panel closed, unless the portfolio keeps the list hidden (`/View /H`). + /// Leaving the tab gives back the panel the list replaced, unless the reader changed panels + /// meanwhile. Called every frame. + pub(crate) fn present_portfolios(&mut self) { + // A prompt or picker over the window belongs to the document it was raised for (the + // password of a file opened together with the portfolio, say): the initial document + // waits for its answer, and opening it then would close the prompt. A file still + // opening in the background (in the open worker, or a browser read of a drop or a + // picked file) comes to the front when it arrives, so it is waited for too. + #[cfg(not(target_arch = "wasm32"))] + let busy = self.modal_open() || self.pickers.pending() || self.pending_opens.iter().any(|p| p.activate); + #[cfg(target_arch = "wasm32")] + let busy = self.modal_open() || self.web_reads.load(std::sync::atomic::Ordering::Relaxed) > 0; + if !busy + && let Some((doc, key)) = self.portfolio_initial.take() + && self.active_ids().is_some_and(|(_, id)| id == doc) + && let Some(index) = self.portfolio_attachment(doc, &key) + { + self.open_portfolio_initial(doc, index); + // A portfolio inside the portfolio waits until the reader opens it, so a nested + // file can't open tab after tab on its own. + self.portfolio_initial = None; + } + let active = self.active_ids().map(|(_, id)| id); + if active == self.portfolio_seen { + return; + } + self.leave_portfolio(); + if let Some(id) = active + && self.mode != Mode::Read + && self.right != Some(RightPanel::Attachments) + && self.lists_files(id) + { + self.show_portfolio_files(id, self.right); + } + self.portfolio_seen = active; + } + + /// The document is a portfolio whose tab shows its file list: one that doesn't keep the + /// list hidden (`/View /H`). + fn lists_files(&self, id: DocId) -> bool { + self.session.get(id).and_then(|d| d.info.portfolio.as_ref()).is_some_and(|p| p.view != pdfcraft_render::PortfolioView::Hidden) + } + + /// Show portfolio `id`'s file list in place of `before`, the panel given back when its tab + /// is left. + fn show_portfolio_files(&mut self, id: DocId, before: Option) { + self.portfolio_seen = Some(id); + self.portfolio_panel = Some(before); + self.right = Some(RightPanel::Attachments); + } + + /// Where in `doc`'s attachments now the file its name tree keys `key` is: an attachment + /// deleted since the portfolio opened moves it, or it is gone. + fn portfolio_attachment(&self, doc: DocId, key: &str) -> Option { + self.session + .get(doc)? + .info + .attachments + .iter() + .position(|a| matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key: k } if k == key)) + } + + /// Open portfolio `doc`'s attachment `index` in a tab of its own when its bytes are a PDF, + /// whatever its name. A file of another kind, or one that can't be read, stays in the list. + fn open_portfolio_initial(&mut self, doc: DocId, index: usize) { + let Some(d) = self.session.get(doc) else { return }; + let Some(att) = d.info.attachments.get(index).cloned() else { return }; + let Ok(bytes) = pdfcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att) else { return }; + if pdfcraft_engine::source_kind(&att.name, &bytes) != Some(pdfcraft_engine::SourceKind::Pdf) { + return; + } + if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); + } + } + + /// Leave a portfolio's tab: the panel its file list replaced comes back, unless the reader + /// changed panels meanwhile. In Read mode, which put the list away and gives it back on + /// leaving, it is the panel Read mode gives back that changes, and a panel the reader picked + /// in Read mode stays. + fn leave_portfolio(&mut self) { + self.portfolio_seen = None; + let Some(before) = self.portfolio_panel.take() else { return }; + if self.mode == Mode::Read { + if self.right_before_read == Some(RightPanel::Attachments) { + self.right_before_read = before; + } + } else if self.right == Some(RightPanel::Attachments) { + self.right = before; + } + } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; @@ -829,7 +951,9 @@ impl PdfCraftApp { { let inbox = self.inbox.clone(); let ctx = self.ctx.clone(); + let read = super::input::WebRead::start(&self.web_reads); wasm_bindgen_futures::spawn_local(async move { + let _read = read; if let Some(h) = rfd::AsyncFileDialog::new() .add_filter_any_case("PDF", &["pdf"]) .add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE) @@ -865,6 +989,7 @@ impl PdfCraftApp { } self.closed_tabs.push(path); } + self.placed_signatures.remove(&id); self.forget_recovery(id); self.session.close(id); } diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 4d6742cf70..6d24a3e794 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -129,7 +129,9 @@ impl PdfCraftApp { self.startup_superseded = true; let inbox = self.inbox.clone(); let ctx = ctx.clone(); + let read = WebRead::start(&self.web_reads); wasm_bindgen_futures::spawn_local(async move { + let _read = read; let name = crate::files::dropped_name(&f); if let Ok(bytes) = f.bytes_async().await && let Ok(mut q) = inbox.lock() @@ -140,3 +142,25 @@ impl PdfCraftApp { }); } } + +/// One browser file read counted in `PdfCraftApp::web_reads` for as long as it lives, so the +/// count falls however the read ends. +#[cfg(target_arch = "wasm32")] +pub(super) struct WebRead(std::sync::Arc); + +#[cfg(target_arch = "wasm32")] +impl WebRead { + /// Count a read starting in `reads`. + pub(super) fn start(reads: &std::sync::Arc) -> Self { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Self(reads.clone()) + } +} + +#[cfg(target_arch = "wasm32")] +impl Drop for WebRead { + /// The read is over, whether its file arrived or not. + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 37a11e210e..3455029a0d 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -103,6 +103,7 @@ impl PdfCraftApp { // Null follows the system's text scaling. "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, + "ask_to_lock_signatures": self.ask_to_lock_signatures, "fill_text_size": self.fill_text_size, "fill_text_border": self.fill_text_default.width, "fill_text_border_color": self.fill_text_default.color, @@ -320,6 +321,9 @@ impl PdfCraftApp { if let Some(on) = v["flatten_fill_sign"].as_bool() { self.flatten_fill_sign_on_save = on; } + if let Some(on) = v["ask_to_lock_signatures"].as_bool() { + self.ask_to_lock_signatures = on; + } if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f2aecc81f0..eebcece9b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -32,6 +32,7 @@ pub(super) mod page_cursor; mod page_input; pub(super) mod page_pointer; pub(super) mod page_router; +mod presenter; pub mod scroll_speed; mod selection; pub(super) mod signature_drag; @@ -562,9 +563,14 @@ pub struct DocView { pub flash: Option<(usize, [f32; 4], f64)>, /// Compare files: differences shaded on this document's pages (page, user-space box, colour). pub compare_marks: Vec<(usize, [f32; 4], Color32)>, + /// The interface the document hid in this tab, and what is hidden now (ISO 32000-2 Table 147 + /// `HideToolbar`, `HideMenubar`, `HideWindowUI`). Never saved. + pub interface: crate::viewer_prefs::TabInterface, /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. presentation: Option, + /// Full screen's transitions, display times and pointer (see [`presenter`]). + pub(crate) presenter: presenter::Presenter, pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, @@ -640,6 +646,11 @@ pub struct DocView { viewport_screen: Rect, /// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset. zoom_anchor: Option<(usize, f32, f32, Vec2)>, + /// The match a find step just went to: its page and its centre as fractions of the displayed + /// page. A zoom before the step has been laid out, or the rest of an eased ⌘-wheel zoom + /// still arriving, keeps it at the window's centre instead of the point under the pointer + /// (#3530). Let go in the first frame without a zoom gesture once the step is laid out. + find_hold: Option<(usize, f32, f32)>, /// The page view has been shown, so its scroll offset in egui's memory is this document's. shown: bool, /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). @@ -755,6 +766,9 @@ pub struct DocView { pub fill_text: Option, /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. pub(crate) fill_signature_page: Option, + /// The "Signature added … Lock now" notice was dismissed; placing another signature shows it + /// again (#2149). + pub(crate) lock_offer_dismissed: bool, pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// What the page modules claim for the cursor this frame; `document_area` sets it once. pub(crate) cursor: crate::page_cursor::PageCursor, @@ -861,6 +875,8 @@ pub enum ViewAction { CameraLevel, /// After Enhance page: leave the tool for this command (Crop pages, Organize pages). CameraNext(&'static str), + /// Lock now, on the notice shown after a signature is placed (#2149). + LockSignatures, } /// Default page right-click menu actions. @@ -1048,7 +1064,9 @@ impl DocView { single_edges: None, flash: None, compare_marks: Vec::new(), + interface: Default::default(), presentation: None, + presenter: Default::default(), pages: HashMap::new(), errors: HashMap::new(), cut_pages: Default::default(), @@ -1089,6 +1107,7 @@ impl DocView { screen_ppp: 1.0, viewport_screen: Rect::NOTHING, zoom_anchor: None, + find_hold: None, shown: false, auto_ocr_tried: Default::default(), auto_ocr_skipped: Default::default(), @@ -1163,6 +1182,7 @@ impl DocView { marquee_done: None, fill_text: None, fill_signature_page: None, + lock_offer_dismissed: false, signature_drag: Default::default(), cursor: Default::default(), router: Default::default(), @@ -1191,6 +1211,7 @@ impl DocView { self.goto = None; self.goto_point = None; self.zoom_anchor = None; + self.find_hold = None; self.flash = None; // Thumbnail drags hold page indexes from before the change. self.panel_drag = None; @@ -1224,8 +1245,12 @@ impl DocView { fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; + // What the document asked to hide, and what is hidden now (F8), belong to the tab. + fresh.interface = self.interface; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); + // A new document: none of the old one's transition, page or timer carries over. + fresh.presenter = Default::default(); fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); fresh.find = self.find.take(); @@ -1334,6 +1359,8 @@ struct PageEnv<'a> { glide: Vec2, hand: bool, info: &'a DocInfo, + /// The Animation effects preference as it applies now (full screen's transitions follow it). + animates: bool, initials: &'a Option, key_scroll: f32, key_side: f32, @@ -1392,6 +1419,9 @@ struct PageOut { page_action: Option, paste_comments_at: Option<(usize, [f64; 2])>, primary_down: bool, + /// The pointer is over a link, comment, field or what is placed on the page: a click there is + /// for it, and turns no page in full screen. + on_object: bool, refused: Option, /// The current page or spread where it is drawn, every page of it: one off screen (the second /// page of a zoomed spread) has no screen transform, but still decides where the row ends. @@ -1729,9 +1759,12 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A cancelled press takes nothing until the button is up, though the page's menu still opens - // where a right-click is. - let input = if view.press_cancelled { - if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { + // where a right-click is. A page full screen doesn't show yet takes nothing at all. (A press + // there gives nothing on the page an owner, so its release, once the page is in, takes nothing + // either.) + let veiled = page_input::veiled(view); + let input = if view.press_cancelled || veiled { + if !veiled && let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { comments::anchor(&pcx, p, view); } PageInput { @@ -1809,6 +1842,7 @@ fn draw_pages( y_shift, .. } = *env; + let animates = env.animates; // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -1934,6 +1968,7 @@ fn draw_pages( open_table, page_action: _, primary_down: _, + on_object: _, refused, row_on_screen: _, stamp_placed, @@ -1946,8 +1981,20 @@ fn draw_pages( let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } + // Full screen plays transitions over the page just drawn, and keeps time (#379). + let mut hide_pointer = false; + if view.presentation.is_some() { + let now = ui.input(|i| i.time); + hide_pointer = presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + } + // Read before the claims are spent below, and before the pointer hides, which claims the top layer. + let on_object = view.cursor.over_object(tool); + // A still pointer hides over a link too, and its tip with it: the top layer, which every tool reaches. + if hide_pointer { + view.cursor.claim(Layer::Drag, egui::CursorIcon::None); + } // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. - let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); + let hover_text = pointer.filter(|_| unobstructed && !hide_pointer).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole @@ -2004,7 +2051,8 @@ fn draw_pages( // The row as it is now: in continuous view the current page may have changed above. let row_on_screen = shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); - resp.context_menu(|ui| { + let presenting = view.presentation.is_some(); + page_context_menu(&resp, presenting, |ui| { // Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is // the crop's (#5835). if matches!(menu_for, Some(Owner::Picture { part: PicturePart::Crop(_), .. })) { @@ -2173,6 +2221,7 @@ fn draw_pages( page_action, paste_comments_at, primary_down: ptr.primary_down, + on_object, refused, row_on_screen, stamp_placed, @@ -2180,8 +2229,10 @@ fn draw_pages( (wanted, visible_now) } -/// A zoom the fit mode changed keeps the page at the top of the view in place. -fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { +/// A zoom the fit mode changed keeps the page at the top of the view in place, or, just after a +/// find step, the match in the middle of the view (`avail` is the view's size). +fn anchor_zoom_change(view: &mut DocView, avail: Vec2, old_zoom: f32) { + let avail_width = avail.x; if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { let prev_viewport = view.viewport_screen; let top_y = prev_viewport.min.y; @@ -2195,7 +2246,10 @@ fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { }) .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); - if let Some((page, fy, rel_y)) = anchor { + if let Some((page, fx, fy)) = view.find_hold { + // A fit zoom that changes just after a find step keeps the match in the middle too. + view.zoom_anchor = Some((page, fx, fy, avail / 2.0)); + } else if let Some((page, fy, rel_y)) = anchor { view.zoom_anchor = Some((page, 0.5, fy, vec2(avail_width / 2.0, rel_y))); } else { view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail_width / 2.0, MARGIN))); @@ -2564,6 +2618,12 @@ fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), // Back on in Preferences ▸ General. Some(Notice::HideSecured) => app.show_secured_notice = false, + // Handled by the app with the other view actions (`ViewAction`). + Some(Notice::LockSignatures) => { + if let Some(view) = app.views.get_mut(index) { + view.pending_action = Some(ViewAction::LockSignatures); + } + } None => {} } } @@ -2611,6 +2671,11 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry // Every scroll here asks for no animation. An animated area would still queue a zero-length // offset target for the next frame, which overwrites the jump a page turn just set. scroll = scroll.animated(false); + // A document that hides the window's interface (`HideWindowUI`) hides the scroll bars too; + // the wheel, keys and Hand tool still scroll. + if view.interface.hidden.window_ui { + scroll = scroll.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + } // A newly opened document starts at the top. eframe saves egui's memory with the settings // and document ids start again at 1 every launch, so the offset stored under this id can be // where an earlier session left another document. @@ -2790,12 +2855,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim /// Full screen: the click turns the page unless it did something else first. It waits out the /// double-click time: turning on the first click's release sent a double-click's second click, /// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a -/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// click on a link, field or comment (`clicked_object`) and leaving full screen: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65), and +/// fields to fill. A double-click elsewhere still turns once. /// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. -fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, clicked_object: bool) { let now = ui.input(|i| i.time); if let Some(back) = click - && !link_followed + && !clicked_object && view.selection.is_none() && view.clicks < 2 { @@ -2803,7 +2870,7 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option= due { view.present_turn = None; @@ -2884,11 +2951,25 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { } } +/// The pages' context menu, except in full screen, where a right-click turns back a page instead +/// ([`presentation_click_state`]). +fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(&mut egui::Ui)) { + if !presenting { + resp.context_menu(add); + } +} + /// Full screen: a click on the document goes forward a page and a shift-click back, as in /// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages /// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { - let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + let presenting = view.presentation.is_some() && enabled; + // A right-click goes back a page, as a left click goes forward (*Adobe Acrobat Help*, 2019, + // "Full Screen preferences", p. 69). + if presenting && ui.rect_contains_pointer(navigation_rect) && ui.input(|i| i.pointer.secondary_clicked()) { + return Some(true); + } + let clicked = presenting && presentation_click(ui, navigation_rect); // Shift as held now, or as the click's own event carried it (a synthetic click). clicked.then(|| { ui.input(|i| { @@ -2908,6 +2989,12 @@ fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rec Some(p) => view.zoom_at(z, p), None => view.set_zoom(z), } + } else if view.goto_point.is_none() && zoom_delta == 1.0 { + // The find step has been laid out and no zoom is easing on: zoom at the pointer again. + // egui spreads a ⌘/Ctrl-wheel notch over about a quarter of a second; while the pointer + // is off the page view that zoom waits, and the hold must outlast it, or the rest of it + // anchors on the pointer once it is back and the match drifts off the middle. + view.find_hold = None; } } @@ -3113,13 +3200,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); let show_secured_notice = app.show_secured_notice; + // Offer to lock a just-placed signature (#2149) on the same terms the save-time question + // asks: one is still there, saving doesn't lock it anyway, and the document isn't signed. + let lock_offer = !app.flatten_fill_sign_on_save + && app.placed_signatures.contains(&app.views[index].id) + && doc.info.annotations.iter().any(|a| a.fill_sign) + && !doc.is_signed(); let view = &mut app.views[index]; // Opened without the owner password and something is restricted. The notice says so unless // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ // Security, stay as they are. let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); let repaired = !doc.repair_log().is_empty(); - let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); + let notice = notices(view, doc, secured, repaired, lock_offer, crate::sign_ui::banner(&doc.signatures), ui, &t); apply_notice(app, index, notice); let Some(doc) = app.session.get(app.views[index].id) else { return }; let info = &doc.info; @@ -3142,15 +3235,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.viewport_h = avail.height(); let old_zoom = view.zoom; view.fit_zoom(info); - anchor_zoom_change(view, avail.width(), old_zoom); + anchor_zoom_change(view, avail.size(), old_zoom); // Pinch / ctrl+scroll zoom, anchored on the current page. let zoom_delta = crate::motion::zoom_delta(ui.ctx()); // Exclude the previous frame's scrollbar tracks from gesture activation. let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + // Wheel zoom works in a visible window without focus, as wheel scrolling does; the other gestures need focus. + let zoom_enabled = unobstructed && ui.is_enabled(); + let navigation_enabled = zoom_enabled && ui.input(|i| i.focused); let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); - pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); + pinch_zoom(ui, view, zoom_delta, navigation_rect, zoom_enabled); view.viewport_screen = avail; let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed, invert_middle); @@ -3252,6 +3347,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let env = PageEnv { added: &added, allowed, + animates, author: &author, auto_delta, avail, @@ -3343,6 +3439,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { page_action, paste_comments_at, primary_down, + on_object, mut refused, row_on_screen, stamp_placed, @@ -3431,7 +3528,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } - schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + schedule_presentation_turn(ui, view, present, clicked_link.is_some() || on_object && present.is_some()); let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; @@ -3721,7 +3818,8 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, } // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. - if app.mode != crate::Mode::Read && !app.full_screen { + // A document that hides the toolbars (`HideToolbar`) hides this one too. + if app.mode != crate::Mode::Read && !app.full_screen && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { // The page nearest the left edge, whose margin the bar sits in. let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min); quick_bar(app, avail, page_left, ui); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index a8a538e86c..85807e28fe 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -56,16 +56,21 @@ pub(super) enum Notice { Close, /// Stop showing the secured-document notice, in every document (#4035). HideSecured, + /// Lock the placed signatures into the page (#2149). + LockSignatures, } /// The notice bar above the pages: a file changed on disk first, then what a historical -/// snapshot is, then pages cut at the renderer's limits, then the signature status bar, a -/// form's scripts having been turned off, security, forms and warnings. +/// snapshot is, then pages cut at the renderer's limits, then the offer to lock a signature just +/// placed, then the signature status bar, a form's scripts having been turned off, security, +/// forms and warnings. +#[allow(clippy::too_many_arguments)] pub(super) fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, secured: bool, repaired: bool, + lock_offer: bool, signed: Option<(&str, Color32, &str, String)>, ui: &mut egui::Ui, t: &Tokens, @@ -126,6 +131,22 @@ pub(super) fn notices( if view.cut_pages.iter().any(|p| !view.cut_notice_dismissed.contains(p)) { cut_pages_bar(view, ui, t); } + // A signature just placed with Fill & Sign is a comment others can move (#2149): offer to + // lock it into the page now. `lock_offer` is false for a digitally signed document. + if lock_offer && !view.lock_offer_dismissed { + let mut lock = false; + notice_bar(ui, t, "lock", t.accent_text, tl!("Signature added. Others can move it until it's locked.").to_string(), |ui| { + if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() { + view.lock_offer_dismissed = true; + } + if crate::widgets::pill_button(ui, tl!("Lock now"), true).clicked() { + lock = true; + } + }); + if lock { + return Some(Notice::LockSignatures); + } + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index 9d750cfdb2..73b76eb3a6 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -208,11 +208,19 @@ impl DocView { .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0)) .unwrap_or(0.0); self.goto = Some((page, frac)); + self.find_hold = None; // Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a // /FitR destination's rectangle is: a page wider or taller than the window no longer // leaves it off to one side or near an edge. - if let Some([fx, fy]) = self.match_centre() { + // Finite only: a hostile page's glyphs can span -inf..inf, and NaN survives `clamp`. + if let Some([fx, fy]) = self.match_centre().filter(|c| c.iter().all(|v| v.is_finite())) { self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0)); + // The point alone, both axes in one frame. A pending zoom anchor would hold the old + // place for that frame and put `goto` off to the next, where its page-top offset + // undid the centring; a zoom after the step anchors on the match instead. + self.goto = None; + self.zoom_anchor = None; + self.find_hold = Some((page, fx, fy)); } self.current = page; self.reset_coarse_state(); diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index f0c5b46a3a..47196ca140 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -102,8 +102,10 @@ impl DocView { } self.forward.clear(); } - // Partial wheel motion belongs to the old place. + // Partial wheel motion belongs to the old place, and so does a find step's match. self.reset_coarse_state(); + self.find_hold = None; + self.goto_point = None; self.goto = Some((page, 0.0)); self.current = page; } @@ -276,6 +278,8 @@ impl DocView { self.goto = Some((page, 0.0)); self.current = page; self.reset_coarse_state(); + // A find step's match belongs to the place left, as for any other jump. + self.find_hold = None; true } @@ -326,6 +330,18 @@ impl DocView { } } +/// ⇧⌘+ / ⇧⌘−: rotate the view clockwise / counterclockwise. +fn rotation_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); + if pressed(shift_cmd(Key::Plus)) || pressed(shift_cmd(Key::Equals)) { + view.rotate_view(true); + } + if pressed(shift_cmd(Key::Minus)) { + view.rotate_view(false); + } +} + /// ⌘+ / ⌘− / ⌘0 / ⌘1 / ⌘2: zoom the page, or size the pages in the page grid. fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { use egui::{Key, KeyboardShortcut, Modifiers}; @@ -390,14 +406,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } let arrows = !form_box.is_some_and(|(_, radio)| radio); // Rotation first: egui matches ⌘+ loosely with respect to Shift. - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) - || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) - { - view.rotate_view(true); - } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) { - view.rotate_view(false); - } + rotation_shortcuts(view, &pressed); zoom_shortcuts(view, &pressed); // Alt+← / Alt+→ (⌥ on macOS) too, as in Acrobat and browsers, back to where a link was // followed from (#3632). Consumed here, before ← / → turn the page. egui matches the pattern @@ -431,21 +440,31 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s return; } // Home and End, and the keys listed with them for Go to first / last page: Shift+Ctrl - // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434). + // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434; *Adobe Acrobat Help*, 2019, + // "Keys for navigating a PDF", p. 83). These take the key from the page turning and + // scrolling below. let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); - if key(Key::Home) || pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)) { + let to_start = pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)); + let to_end = pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)); + if key(Key::Home) || to_start { view.go_to_page(0); } - if key(Key::End) || pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)) { + if key(Key::End) || to_end { view.go_to_end(); } - // Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the - // arrows and Page Up / Page Down already turn pages where nothing scrolls, below). + if to_end || to_start { + return; + } + let (command, shift) = ctx.input(|i| (i.modifiers.command, i.modifiers.shift)); + // Full screen: Space and Enter go forward a page, and Shift+Enter and Backspace back + // (*Adobe Acrobat Help*, 2019, "Read a document in Full Screen mode", p. 66: "To go to the + // previous page, press Shift+Enter"). The arrows and Page Up / Page Down already turn pages + // where nothing scrolls, below. if view.presentation.is_some() { - if key(Key::Space) || key(Key::Enter) { + if key(Key::Space) || (key(Key::Enter) && !shift) { view.step_page(true); } - if key(Key::Backspace) { + if key(Key::Backspace) || (key(Key::Enter) && shift) { view.step_page(false); } } @@ -454,7 +473,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page // once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← // turn it at once. - let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; let line = KEY_SCROLL_LINE * speed.factor(); // Only an up-to-date view counts: no jump pending. diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index e40dc1c4f3..b820f74d77 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -109,6 +109,16 @@ impl PageCursor { } } + /// Whether the pointer is over something a press with `tool` acts on in its own right: a + /// link, a comment, a field, or what is placed on the page and its grips and editors. Not the + /// page and its text, nor a drag, whose layer also hides the pointer in full screen. + pub(crate) fn over_object(&self, tool: QuickTool) -> bool { + LAYERS + .into_iter() + .zip(&self.claims) + .any(|(layer, claim)| claim.is_some() && (Layer::Link..Layer::Drag).contains(&layer) && layer.reached_by(tool)) + } + /// The claim on the highest layer `tool` reaches. fn winner(claims: [Option; LAYERS.len()], tool: QuickTool) -> Option { LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool))) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index eace9dbbe1..fa0a15562b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -870,6 +870,13 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex } } +/// Full screen is turning to the current page, or waiting for it to render: the page under the +/// pointer isn't the one on screen, so its links, fields, text and comments take no input. +/// Turning the page (a click, a key) is handled apart and still works. +pub(super) fn veiled(view: &DocView) -> bool { + view.presentation.is_some() && !view.presenter.settled_on(view.current) +} + /// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks /// and badges. pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { @@ -915,8 +922,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { + // A cancelled press ends no selection: no markup or redaction is made from it, nor from a + // page full screen doesn't show yet. + if !view.press_cancelled && !veiled(view) { comments::page_after_text(resp, &pcx, view); if tool == QuickTool::Redact && can_modify { crate::redact_ui::after_text(resp, i, info, view); diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs new file mode 100644 index 0000000000..d6c8461bfa --- /dev/null +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -0,0 +1,285 @@ +//! Full screen as a presentation (ISO 32000-2 §12.4.4): a page's transition (`/Trans`, Table 164) +//! plays when the page comes into view, a page with a display time (`/Dur`) advances by itself, +//! and the pointer hides while it stays still. +//! +//! Transitions play in single-page view without view rotation, from the outgoing page's raster +//! to the incoming one's once it has rendered; spreads, a rotated view and animations turned off +//! (the Animation effects preference, which by default follows the system's request for no +//! animations) change pages at once. A style the spec doesn't name is its default, Replace, a +//! cut. Display times count from the end of the transition and stop at the last page; the keys +//! and clicks still turn pages meanwhile (§12.4.4.1, NOTE 1), and leaving full screen stops them. +//! The file's numbers are bounded: a transition plays for at most `MAX_TRANSITION` seconds, and a +//! display time waits at most `MAX_DISPLAY`. + +use egui::{Color32, CornerRadius, Painter, Pos2, Rect, TextureHandle, pos2}; +use pdfcraft_render::transition::{self, Effect, Piece}; +use pdfcraft_render::{DocInfo, PageTransition}; + +use super::{DocView, PageLayout}; + +/// Seconds the pointer stays still before full screen hides it. +pub(crate) const HIDE_POINTER_AFTER: f64 = 3.0; +/// Bounds on a document's numbers, which are untrusted: the longest transition played and the +/// longest display time waited for (a day: a timer can't hold the largest number a file can +/// write). +const MAX_TRANSITION: f64 = 10.0; +const MAX_DISPLAY: f64 = 24.0 * 3600.0; + +/// What full screen remembers between frames. +#[derive(Default)] +pub(crate) struct Presenter { + /// The page wholly in view, and since when (its transition over). + shown: Option<(usize, f64)>, + turn: Option, + /// Where the pointer last moved to, and when. + pointer: Option<(Pos2, f64)>, +} + +/// A transition under way, from one page to the next. +struct Turn { + from_page: usize, + /// The outgoing page's raster, kept while the transition plays. + from: TextureHandle, + to_page: usize, + effect: Effect, + seconds: f64, + /// When the incoming page's raster arrived and the transition began. + started: Option, +} + +impl Presenter { + /// Whether a page transition is playing or waiting for its incoming page. + pub(crate) fn turning(&self) -> bool { + self.turn.is_some() + } + + /// Whether `page` is the page wholly in view: not while it comes in or waits for its raster, + /// nor in the frame that turns to it (this frame's [`frame`] hasn't caught up yet). While a + /// page turns, `shown` is `None`, so a transition under way needs no check of its own. + pub(crate) fn settled_on(&self, page: usize) -> bool { + self.shown.is_some_and(|(p, _)| p == page) + } +} + +/// The transition and display time of page `page` (0-based), if it has either. +fn entry(info: &DocInfo, page: usize) -> Option<&PageTransition> { + info.transitions.iter().find(|t| t.page == page) +} + +/// The effect a page's transition plays and its length in seconds; `None` for no visible effect. +fn effect_of(t: &PageTransition) -> Option<(Effect, f64)> { + let style = t.style?; + let effect = Effect::from_parts(style.name(), t.horizontal, t.inward, t.direction, t.scale); + let seconds = f64::from(t.duration); + (effect != Effect::Replace && seconds.is_finite() && seconds > 0.0).then(|| (effect, seconds.min(MAX_TRANSITION))) +} + +/// `rect` with the aspect ratio of a `w` by `h` page, as large as fits in `within` and centred +/// there. +fn fitted(w: f32, h: f32, within: Rect) -> Rect { + let (w, h) = (w.max(1.0), h.max(1.0)); + let k = (within.width() / w).min(within.height() / h); + if !k.is_finite() || k <= 0.0 { + return within; + } + Rect::from_center_size(within.center(), egui::vec2(w * k, h * k)) +} + +/// Where a piece of a page goes on screen and which part of its raster it shows: `dst` in `page` +/// (screen), `src` as texture coordinates, and its tint. `None` for a piece with nothing to show. +pub(crate) fn piece_on_screen(page: Rect, p: &Piece) -> Option<(Rect, Rect, Color32)> { + let alpha = if p.alpha.is_finite() { p.alpha.clamp(0.0, 1.0) } else { 0.0 }; + let at = |f: [f32; 4]| { + let f = f.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + (pos2(f[0], f[1]), pos2(f[2], f[3])) + }; + let (d0, d1) = at(p.dst); + let (s0, s1) = at(p.src); + let dst = Rect::from_min_max(page.lerp_inside(d0.to_vec2()), page.lerp_inside(d1.to_vec2())); + let uv = Rect::from_min_max(s0, s1); + (alpha > 0.0 && dst.width() > 0.0 && dst.height() > 0.0).then(|| (dst, uv, Color32::WHITE.gamma_multiply(alpha))) +} + +fn paint(painter: &Painter, tex: &TextureHandle, page: Rect, pieces: &[Piece]) { + for p in pieces { + if let Some((dst, uv, tint)) = piece_on_screen(page, p) { + painter.image(tex.id(), dst, uv, tint); + } + } +} + +/// One frame of full screen for `view`, after its pages were drawn: start, paint or finish the +/// current page's transition, and advance a page whose display time is up. Returns whether the +/// pointer has been still long enough to hide: the caller hides it once the page has chosen its +/// cursor, which would otherwise show it again over a link. `animates` is the Animation effects +/// preference as it applies now. +pub(super) fn frame( + view: &mut DocView, + info: &DocInfo, + painter: &Painter, + ctx: &egui::Context, + now: f64, + animates: bool, + pointer: Option, +) -> bool { + let page = view.current; + let showing = view.presenter.shown.map(|(p, _)| p); + let arriving = view.presenter.turn.as_ref().map(|t| t.to_page); + if showing != Some(page) && arriving != Some(page) { + // A new page: play its transition from the page before, when there is one to play. + let plays = animates && view.layout == PageLayout::Single && view.rotation == 0; + let turn = showing.or(arriving).filter(|_| plays).and_then(|from_page| { + let (effect, seconds) = entry(info, page).and_then(effect_of)?; + let from = view.pages.get(&from_page)?.tex.clone(); + Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) + }); + // Its display time starts once the page is on screen, not while it still says Rendering… + // (or at once for a page the renderer can't draw, which never will be). In two-page view + // that is every page of the spread, so the spread isn't turned before both were shown. + let drawn = |p: &usize| view.pages.contains_key(p) || view.errors.contains_key(p); + let ready = drawn(&page) && view.screen_rects.iter().all(|(p, _)| drawn(p)); + view.presenter.shown = if turn.is_some() || !ready { None } else { Some((page, now)) }; + view.presenter.turn = turn; + } + + if let Some(turn) = view.presenter.turn.as_mut() { + // A page the renderer can't draw ends the turn: waiting for it would hold the page before + // forever, and its display time would never start. + let failed = view.errors.contains_key(&turn.to_page); + let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r).filter(|_| !failed); + let incoming = view.pages.get(&turn.to_page).map(|p| p.tex.clone()); + match to_rect { + None => { + view.presenter.turn = None; + view.presenter.shown = Some((page, now)); + } + Some(to_rect) => { + // The outgoing page keeps its own shape, centred where the incoming one is. + let from_rect = info.pages.get(turn.from_page).map_or(to_rect, |p| fitted(p.width, p.height, to_rect.union(view.viewport_screen))); + let from_rect = if (from_rect.aspect_ratio() - to_rect.aspect_ratio()).abs() < 0.01 { to_rect } else { from_rect }; + let t = match (&incoming, turn.started) { + // Hold the outgoing page until the incoming one has rendered. + (None, _) => 0.0, + (Some(_), Some(start)) => ((now - start) / turn.seconds) as f32, + (Some(_), None) => { + turn.started = Some(now); + 0.0 + } + }; + if t >= 1.0 { + view.presenter.turn = None; + view.presenter.shown = Some((page, now)); + } else { + let f = transition::frame(&turn.effect, t); + painter.rect_filled(from_rect.union(to_rect), CornerRadius::ZERO, Color32::BLACK); + paint(painter, &turn.from, from_rect, &f.outgoing); + if let Some(tex) = &incoming { + paint(painter, tex, to_rect, &f.incoming); + ctx.request_repaint(); + } else { + // Nothing moves until the page arrives, and the render cache wakes the window + // while pages are queued: look again now and then rather than every frame. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } + } + } + } + + // A page with a display time advances once it is up (not past the last page). + if let Some((shown, since)) = view.presenter.shown + && shown == page + && page + 1 < info.pages.len() + && let Some(seconds) = entry(info, page).and_then(|t| t.display).map(f64::from).filter(|d| d.is_finite() && *d >= 0.0) + { + let left = since + seconds.min(MAX_DISPLAY) - now; + if left <= 0.0 { + // In two-page view the last spread can't advance: then there is nothing to redraw. + let before = view.current; + view.step_page(true); + if view.current != before { + ctx.request_repaint(); + } + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + } + + // The pointer hides once it has been still for a while, and comes back when it moves. + match pointer { + None => view.presenter.pointer = None, + Some(p) if view.presenter.pointer.map(|(q, _)| q) != Some(p) => view.presenter.pointer = Some((p, now)), + Some(_) => {} + } + let Some((_, since)) = view.presenter.pointer else { return false }; + let left = since + HIDE_POINTER_AFTER - now; + if left > 0.0 { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + left <= 0.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page() -> Rect { + Rect::from_min_size(pos2(100.0, 50.0), egui::vec2(200.0, 400.0)) + } + + #[test] + fn a_piece_maps_page_fractions_to_the_screen_and_its_raster() { + let p = Piece { src: [0.0, 0.0, 0.5, 1.0], dst: [0.5, 0.0, 1.0, 1.0], alpha: 1.0 }; + let (dst, uv, tint) = piece_on_screen(page(), &p).unwrap(); + assert_eq!(dst, Rect::from_min_max(pos2(200.0, 50.0), pos2(300.0, 450.0))); + assert_eq!(uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0))); + assert_eq!(tint, Color32::WHITE); + let faded = piece_on_screen(page(), &Piece { alpha: 0.5, ..p }).unwrap().2; + assert!(faded.a() > 0 && faded.a() < 255, "half transparent: {faded:?}"); + } + + #[test] + fn hostile_pieces_paint_nothing_or_stay_on_the_page() { + for p in [ + Piece { src: [0.0; 4], dst: [0.2, 0.2, 0.2, 0.9], alpha: 1.0 }, + Piece { src: [0.0, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 1.0, 1.0], alpha: 0.0 }, + Piece { src: [0.0, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 1.0, 1.0], alpha: f32::NAN }, + ] { + assert!(piece_on_screen(page(), &p).is_none(), "{p:?}"); + } + let (dst, uv, _) = + piece_on_screen(page(), &Piece { src: [f32::NAN, -3.0, 9.0, f32::INFINITY], dst: [-1.0, f32::NAN, 2.0, 1.5], alpha: 1.0 }).unwrap(); + assert!(page().contains_rect(dst), "{dst:?}"); + assert!(Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)).contains_rect(uv), "{uv:?}"); + } + + #[test] + fn only_a_visible_effect_with_a_length_plays() { + use pdfcraft_render::TransitionStyle; + let t = |style, duration| PageTransition { + page: 1, + style, + duration, + horizontal: true, + inward: true, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + assert_eq!(effect_of(&t(Some(TransitionStyle::Wipe), 1.5)), Some((Effect::Wipe { direction: 0 }, 1.5))); + assert_eq!(effect_of(&t(Some(TransitionStyle::Fade), 600.0)).map(|e| e.1), Some(MAX_TRANSITION), "capped"); + for (style, duration) in + [(None, 1.0), (Some(TransitionStyle::Replace), 1.0), (Some(TransitionStyle::Fade), 0.0), (Some(TransitionStyle::Fade), f32::NAN)] + { + assert_eq!(effect_of(&t(style, duration)), None, "{style:?} {duration}"); + } + } + + #[test] + fn the_outgoing_page_keeps_its_shape() { + let r = fitted(600.0, 300.0, Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 400.0))); + assert_eq!(r, Rect::from_min_max(pos2(0.0, 100.0), pos2(400.0, 300.0))); + assert_eq!(fitted(0.0, f32::NAN, Rect::ZERO), Rect::ZERO); + } +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c05a03bb7b..dab411bdf0 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -504,7 +504,7 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { v.go_to_match(0); } assert_eq!(v.back, vec![0], "explicit search jump must remember its origin"); - assert_eq!(v.goto, Some((3, 0.5)), "keep the match offset instead of the page top"); + assert_eq!(v.goto, None, "the match's point alone: a zoom that frame cannot put a page-top offset off to the next"); assert_eq!(v.current, 3); assert_eq!(v.current_page_label(None), "4"); // Chrome refreshes the idle page box; navigation must not overwrite a draft @@ -537,6 +537,56 @@ fn search_centres_the_match_on_a_rotated_view() { } } +#[test] +fn a_zoom_just_after_a_find_step_keeps_the_match_in_the_middle() { + let mut v = history_search_view(); + v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))]; + // A zoom anchored at the pointer was pending when the step came: the step drops it. + v.zoom_at(2.0, pos2(150.0, 50.0)); + assert!(v.zoom_anchor.is_some()); + v.find_step(true); + assert_eq!((v.goto, v.zoom_anchor), (None, None), "the step alone decides where the view goes"); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + let target = Some((3, Some(40.0 / 300.0), Some(0.675), middle)); + assert_eq!(v.goto_point, target); + // The rest of an eased wheel zoom, or a zoom in the same frame, anchors on the match + // at the window's centre, not on the pointer. + v.zoom_at(3.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor, Some((3, 40.0 / 300.0, 0.675, middle))); + // Another jump lets go of the match: the next zoom is at the pointer again. + v.go_to_page(1); + v.zoom_at(4.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); +} + +#[test] +fn previous_view_after_a_find_step_lets_go_of_the_match() { + let mut v = history_search_view(); + v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))]; + v.find_step(true); + // Previous view while a wheel zoom is still easing: the zoom must not hold the old match + // and put the history jump off until it stops. + assert!(v.view_history(false)); + assert_eq!(v.goto, Some((0, 0.0))); + v.zoom_at(2.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); +} + +#[test] +fn a_match_with_unbounded_glyphs_falls_back_to_its_page() { + let mut v = history_search_view(); + // A hostile page: a glyph from -inf to inf, whose centre is NaN. + if let Some(text) = v.texts.get_mut(&3).and_then(Arc::get_mut) { + text.glyphs[0].rect = [f32::NEG_INFINITY, 260.0, f32::INFINITY, 280.0]; + } + v.zoom_at(2.0, pos2(150.0, 50.0)); + v.find_step(true); + assert!(v.goto_point.is_none(), "no NaN scroll target"); + assert_eq!(v.goto.map(|g| g.0), Some(3), "the match's page instead"); + v.zoom_at(3.0, pos2(150.0, 50.0)); + assert!(v.zoom_anchor.is_none_or(|a| a.1.is_finite() && a.2.is_finite()), "no NaN zoom anchor"); +} + #[test] fn automatic_search_updates_do_not_record_history_or_discard_forward() { let mut v = history_search_view(); @@ -544,7 +594,7 @@ fn automatic_search_updates_do_not_record_history_or_discard_forward() { v.find = Some(Find { query: "match".into(), ..Default::default() }); v.rerun_find(); assert_eq!(v.current, 3); - assert_eq!(v.goto, Some((3, 0.5))); + assert_eq!((v.goto, v.goto_point.map(|g| g.0)), (None, Some(3)), "the match's point on its page"); assert!(v.back.is_empty()); assert_eq!(v.forward, vec![4]); v.find.as_mut().unwrap().current = None; @@ -2134,6 +2184,40 @@ fn a_page_found_cut_after_a_dismissal_is_still_named() { h.get_by_label_contains("2 pages hold more content than PdfCraft can read, page 1 first"); } +/// Ctrl+wheel zooms a visible window that another application has focus over, as plain +/// wheel scrolling does; the palette still blocks it. +#[test] +fn ctrl_wheel_zooms_an_unfocused_window() { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + let bytes = app.session.create_blank(300.0, 400.0, 1).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + let center = h.state().views[0].viewport_rect().center(); + h.hover_at(center); + h.input_mut().focused = false; + h.run_steps(2); + let ctrl_wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; + h.state_mut().palette_open = true; + let before = h.state().views[0].zoom; + h.event(ctrl_wheel.clone()); + h.run_steps(10); + assert_eq!(h.state().views[0].zoom, before, "the palette still blocks zoom"); + h.state_mut().palette_open = false; + h.run_steps(2); + h.event(ctrl_wheel); + h.run_steps(10); + assert!(h.state().views[0].zoom > before, "Ctrl+wheel zooms without window focus"); +} + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; @@ -2611,6 +2695,392 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() h.state_mut().views[0].tiles.clear(); } +/// Review on #4378: `FitWindow` measures the spread the view shows, its pages side by side +/// and as tall as the taller one, with one page in a spread that has only one. +#[test] +fn first_page_now_measures_the_pages_in_the_spread() { + let page = + |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 }; + let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(200.0, 500.0)], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + let one = v.first_page_now(&info).expect("a page"); + assert_eq!((one.size, one.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE)); + v.set_display(PageLayout::TwoUp, false); + let gap = v.page_gap(); + // [0, 1]: 300 + 600 wide, 800 high, one gap between them. + let spread = v.first_page_now(&info).expect("a spread"); + assert_eq!((spread.size, spread.margins.x), (vec2(900.0 * PT, 800.0 * PT), 2.0 * SIDE + gap)); + // [2] on its own at the end: one page, no gap. + v.current = 2; + let last = v.first_page_now(&info).expect("the last page"); + assert_eq!((last.size, last.margins.x), (vec2(200.0 * PT, 500.0 * PT), 2.0 * SIDE)); + // With a cover page: [0] alone, then [1, 2]. + v.set_cover(true); + assert_eq!(v.first_page_now(&info).expect("a spread").size, vec2(800.0 * PT, 800.0 * PT)); + v.current = 0; + let cover = v.first_page_now(&info).expect("the cover"); + assert_eq!((cover.size, cover.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE)); + v.current = 9; + assert!(v.first_page_now(&info).is_none()); +} + +/// Full screen's presenter for one frame of `ctx`, at time `now`: whether the frame asked to be +/// drawn again at once. +fn presenter_frame(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64) -> bool { + presenter_frame_animating(ctx, v, info, now, true) +} + +/// [`presenter_frame`] with the Animation effects preference as given (`false` also when the +/// system asks for no animations). +fn presenter_frame_animating(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64, animates: bool) -> bool { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + presenter::frame(v, info, &painter, ui.ctx(), now, animates, None); + }); + // No renderer here to upload textures: drop the frame's texture changes. + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|o| o.repaint_delay.is_zero()) +} + +/// A transition into a page the renderer could not draw ends at once: the page before is not held +/// on screen waiting for it, and the page's own time can start. +#[test] +fn a_transition_into_a_page_that_failed_to_render_ends_at_once() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + v.errors.insert(1, "the renderer failed".into()); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "no wait for a page that won't render"); +} + +/// While the incoming page is still rendering, the presenter holds the page before without asking +/// for a frame after frame: the render cache wakes the window while pages are queued. +#[test] +fn a_transition_waiting_for_its_page_does_not_redraw_every_frame() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.1, 0.2, 0.3] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 0.4); + assert!(v.presenting_a_transition(), "still waiting for page 2"); + assert!(!again, "without asking for a frame after frame"); +} + +/// A display time on the first page of the last two-page spread can't advance (there is no spread +/// after it): the presenter then stays on it without asking for a frame after frame. +#[test] +fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 2, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() }); + v.enter_presentation(); + v.current = 2; + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(2, super::cache::PageTex { tag: 0, tex }); + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.0, 0.5, 0.6] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 5.0); + assert_eq!(v.current, 2, "the last spread stays"); + assert!(!again, "and asks for no frame after frame"); +} + +/// A page's display time (ISO 32000-2 §12.4.4.1, `/Dur`) counts from when the page is on screen: +/// a slow render that still shows Rendering… does not use it up. +#[test] +fn a_display_time_starts_once_the_page_has_rendered() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 0, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + for now in [0.0, 1.0, 2.0, 5.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "page 1 is still rendering: its second has not started"); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 5.5); + presenter_frame(&ctx, &mut v, &info, 6.0); + assert_eq!(v.current, 0, "half of its second on screen"); + presenter_frame(&ctx, &mut v, &info, 6.6); + assert_eq!(v.current, 1, "its second on screen is up"); +} + +/// In two-page view a display time counts from when the whole spread is on screen: one page of +/// it still showing Rendering… holds the spread (#5316 review). +#[test] +fn a_display_time_in_two_page_view_waits_for_the_whole_spread() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 0, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = |ctx: &egui::Context| ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + let r = Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)); + v.screen_rects = vec![(0, r), (1, r.translate(egui::vec2(300.0, 0.0)))]; + v.pages.insert(0, super::cache::PageTex { tag: 0, tex: tex(&ctx) }); + for now in [0.0, 1.0, 2.0, 5.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "page 2 of the spread is still rendering: the spread's second has not started"); + v.pages.insert(1, super::cache::PageTex { tag: 0, tex: tex(&ctx) }); + presenter_frame(&ctx, &mut v, &info, 5.5); + presenter_frame(&ctx, &mut v, &info, 6.0); + assert_eq!(v.current, 0, "half of its second on screen"); + presenter_frame(&ctx, &mut v, &info, 6.6); + assert_eq!(v.current, 2, "its second on screen is up: the next spread"); +} + +/// Page 1 of `pages` 300×400 pt pages, with `transitions`, in full screen (single page), its +/// raster in. The other pages get theirs with [`give_raster`]. +fn presenting_view(pages: usize, transitions: Vec, ctx: &egui::Context) -> (DocView, DocInfo) { + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let info = DocInfo { pages: vec![page; pages], transitions, ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + give_raster(ctx, &mut v, 0); + (v, info) +} + +/// Page `page`'s raster has arrived (a blank one). +fn give_raster(ctx: &egui::Context, v: &mut DocView, page: usize) { + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(page, super::cache::PageTex { tag: 0, tex }); +} + +/// Turn to `page` as a key would, with its raster in and its place on screen. +fn turn_to(ctx: &egui::Context, v: &mut DocView, page: usize) { + give_raster(ctx, v, page); + v.current = page; + v.screen_rects = vec![(page, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; +} + +/// A page's `/Trans` (style and `/D`) and `/Dur`, the rest as the spec's defaults. +fn trans(page: usize, style: Option, duration: f32, display: Option) -> pdfcraft_render::PageTransition { + pdfcraft_render::PageTransition { page, style, duration, horizontal: true, inward: true, direction: Some(0), scale: 1.0, opaque: false, display } +} + +/// A page with a transition but no display time (ISO 32000-2 Table 31, `Dur`: "By default, the +/// viewer shall not advance automatically") stays until the user turns it, however long it shows. +#[test] +fn a_page_without_a_display_time_never_advances_by_itself() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, Some(TransitionStyle::Wipe), 1.0, None)], &ctx); + for now in [0.0, 1.0, 60.0, 86_400.0, 1.0e7] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "no /Dur: the page waits for the presenter"); +} + +/// A display time of 0 seconds advances as soon as the page is on screen: zero is a number the +/// spec allows, the time a page "shall be displayed" at most. +#[test] +fn a_display_time_of_zero_advances_once_the_page_is_on_screen() { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(0.0))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + assert_eq!(v.current, 1, "page 1's zero seconds are up at once"); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert_eq!(v.current, 1, "page 2 has no display time"); +} + +/// A display time from the file that is negative, not a number or endless is no display time, or +/// at most a day: it neither turns the page at once nor reaches a timer that can't hold it. +#[test] +fn a_negative_or_endless_display_time_is_no_display_time() { + for display in [-1.0, -0.001, f32::NAN, f32::NEG_INFINITY, f32::INFINITY] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx); + for now in [0.0, 0.1, 5.0, 3600.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "/Dur {display}"); + } +} + +/// A display time longer than a day is cut to a day; the largest number the file can hold would +/// otherwise be past what a timer can wait for. +#[test] +fn a_display_time_longer_than_a_day_is_cut_to_a_day() { + for display in [1.0e9, f32::MAX] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + presenter_frame(&ctx, &mut v, &info, 86_399.0); + assert_eq!(v.current, 0, "/Dur {display}: not yet a day"); + presenter_frame(&ctx, &mut v, &info, 86_400.5); + assert_eq!(v.current, 1, "/Dur {display}: a day is up"); + } +} + +/// A transition the file says lasts a day (or longer) ends within ten seconds: it can't hold the +/// screen. +#[test] +fn a_transition_of_a_day_ends_within_ten_seconds() { + use pdfcraft_render::TransitionStyle; + for duration in [86_400.0, f32::MAX] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + presenter_frame(&ctx, &mut v, &info, 5.0); + assert!(v.presenting_a_transition(), "/D {duration}: playing"); + presenter_frame(&ctx, &mut v, &info, 10.2); + assert!(!v.presenting_a_transition(), "/D {duration}: over after ten seconds"); + } +} + +/// A transition of no length, a negative one or one that is not a number changes the page at once +/// (a negative one would otherwise never end). +#[test] +fn a_transition_of_zero_or_negative_length_cuts() { + use pdfcraft_render::TransitionStyle; + for duration in [0.0, -1.0, f32::NAN, f32::NEG_INFINITY] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "/D {duration}: cut"); + assert_eq!(v.current, 1); + } +} + +/// A style the spec doesn't name is read as its default R, Replace (ISO 32000-2 Table 164: "The +/// new page simply replaces the old one with no special transition effect"): the page changes at +/// once, and its display time still counts. +#[test] +fn an_unknown_transition_style_cuts_and_keeps_the_display_time() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let unknown = TransitionStyle::from_name("Spiral"); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(unknown), 5.0, Some(1.0))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "no effect for an unknown style"); + presenter_frame(&ctx, &mut v, &info, 0.9); + assert_eq!(v.current, 1, "its display time is not up"); + presenter_frame(&ctx, &mut v, &info, 1.2); + assert_eq!(v.current, 2, "its display time is up"); +} + +/// With animations off (the preference, or the system asking for none), a transition is a cut and +/// the page's display time still counts from when it is on screen. +#[test] +fn without_animations_a_transition_cuts_and_the_display_time_counts() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), 5.0, Some(1.0))], &ctx); + presenter_frame_animating(&ctx, &mut v, &info, 0.0, false); + turn_to(&ctx, &mut v, 1); + presenter_frame_animating(&ctx, &mut v, &info, 0.1, false); + assert!(!v.presenting_a_transition(), "cut"); + presenter_frame_animating(&ctx, &mut v, &info, 1.2, false); + assert_eq!(v.current, 2, "page 2's second is up"); +} + +/// A page turned by hand while a display time counts starts its own time afresh (ISO 32000-2 +/// §12.4.4.1, NOTE 1: "The user can advance the page manually before the specified time has +/// expired"); the time left on the page before is not carried over. +#[test] +fn a_page_turned_by_hand_starts_its_own_display_time() { + let ctx = egui::Context::default(); + let timed = (0..4).map(|p| trans(p, None, 1.0, Some(2.0))).collect(); + let (mut v, info) = presenting_view(4, timed, &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + presenter_frame(&ctx, &mut v, &info, 1.5); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 1.5); + presenter_frame(&ctx, &mut v, &info, 3.0); + assert_eq!(v.current, 1, "page 2 has shown 1.5 of its 2 seconds"); + presenter_frame(&ctx, &mut v, &info, 3.6); + assert_eq!(v.current, 2, "page 2's own 2 seconds are up"); +} + #[test] fn image_handles_account_for_document_and_view_rotation() { for page_rotation in [0, 90, 180, 270] { diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 8594f33e3a..c3ffcfb6ee 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -6,6 +6,12 @@ impl DocView { self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| xf.rect) } + /// Whether the page full screen shows is wholly in view: no transition playing or waiting for + /// its raster, so a click on it reaches what is on it. Always true outside full screen. + pub fn presentation_settled(&self) -> bool { + self.presentation.is_none() || self.presenter.settled_on(self.current) + } + /// The document area on screen. pub fn viewport_rect(&self) -> Rect { self.viewport_screen diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index 91bc4feac8..1e0e456073 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -35,12 +35,14 @@ impl DocView { self.continuous = false; self.fit = Fit::Page; self.selection = None; + self.presenter = Default::default(); self.goto = Some((self.current, 0.0)); } /// Leave full screen: the display from before it comes back, at the page reached meanwhile. pub fn leave_presentation(&mut self) { let Some(saved) = self.presentation.take() else { return }; + self.presenter = Default::default(); self.layout = saved.layout; self.continuous = saved.continuous; self.cover = saved.cover; @@ -65,6 +67,12 @@ impl DocView { self.presentation.is_some() } + /// Whether full screen is playing a page transition (or holding the page before it until the + /// next one has rendered). + pub fn presenting_a_transition(&self) -> bool { + self.presenter.turning() + } + /// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is. /// A no-op when the layout is already current, so re-selecting it never yanks a scrolled view /// back to the top. @@ -140,6 +148,31 @@ impl DocView { self.layout == PageLayout::TwoUp } + /// The page (or spread) the view shows first, as `FitWindow` sizes the window to it + /// ([`crate::viewer_prefs::window_commands`]). `None` when there is no such page. + pub(crate) fn first_page_now(&self, info: &DocInfo) -> Option { + info.pages.get(self.current)?; + // Two-page view shows the spread the page is in (a cover page on its own); the spread + // is as wide as its pages side by side and as tall as the taller one, as `layout` + // places it. The last spread of an odd page count has one page. + let (first, last) = if self.layout == PageLayout::TwoUp { + let first = self.spread_first(self.current); + (first, if self.cover && first == 0 { 0 } else { first.saturating_add(1) }) + } else { + (self.current, self.current) + }; + let sizes: Vec<(f32, f32)> = + info.pages.iter().skip(first).take(last.saturating_sub(first).saturating_add(1)).map(|p| self.display_size(p)).collect(); + let w: f32 = sizes.iter().map(|s| s.0).sum(); + let h = sizes.iter().map(|s| s.1).fold(0.0, f32::max); + let gap = self.page_gap() * sizes.len().saturating_sub(1) as f32; + Some(crate::viewer_prefs::PageNow { + size: vec2(w * PT, h * PT), + margins: vec2(2.0 * SIDE + gap, 2.0 * MARGIN), + zoom: (self.fit == Fit::None).then_some(self.zoom), + }) + } + /// Displayed page size in points for this view rotation. pub(super) fn display_size(&self, p: &pdfcraft_render::PageInfo) -> (f32, f32) { if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) } @@ -215,15 +248,18 @@ impl DocView { if zoom == self.zoom && self.fit == Fit::None { return; } + // Just after a find step, around the match at the window's centre (#3530). + let held = self.find_hold.map(|(p, fx, fy)| (p, fx, fy, vec2(self.viewport_w, self.viewport_h) / 2.0)); // In gutters/gaps, anchor relative to the nearest page rather than jumping to its top. // Fractions outside 0..1 preserve the pointer's position outside the page as well. - let anchor = + let anchor = held.or_else(|| { self.screen_rects.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(screen_pos).total_cmp(&b.distance_sq_to_pos(screen_pos))).map( |(p, r)| { let f = (screen_pos - r.min) / r.size(); (*p, f.x, f.y, screen_pos - self.viewport_screen.min) }, - ); + ) + }); self.zoom = zoom; self.fit = Fit::None; match anchor { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dba8419919..4cc264e7dc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -588,19 +588,24 @@ const MODE_BAR_SEARCH_MIN: f32 = 140.0; pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + // The document in the active tab may hide the menu or the tools (`crate::viewer_prefs`). + let hidden = app.hidden_interface(); egui::Panel::top("mode_bar") .exact_size(48.0) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.divider))) .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - // The menus, unless the native macOS menu bar has them. - if app.native_menu.is_none() { + // The menus, unless the native macOS menu bar has them or the document hides them. + if app.native_menu.is_none() && !hidden.menubar { app.menu_bar_ui(ui); ui.add_space(6.0); ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); ui.add_space(10.0); } + if hidden.toolbar { + return; + } // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. let modes = [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 3d277fb2e9..ac52586062 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -141,6 +141,7 @@ impl PdfCraftApp { pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), pdfcraft_engine::MarkKind::Background => tl!("background"), + pdfcraft_engine::MarkKind::PrinterMarks => tl!("printer marks"), }; crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) } @@ -218,6 +219,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "prepress.convert_colors" => self.dialog = Some(Dialog::ConvertColours), "prepress.hairlines" => self.dialog = Some(Dialog::FixHairlines), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { @@ -332,6 +334,7 @@ impl PdfCraftApp { } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), "view.split" => self.toggle_split(), + crate::viewer_prefs::TOGGLE => self.toggle_interface(), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { return Some(v.view_history(id == "view.next_view")); @@ -521,11 +524,13 @@ impl PdfCraftApp { self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)); } - "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => { + "prepress.marks" => self.dialog = Some(Dialog::PrinterMarks), + "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" | "prepress.marks.remove" => { use pdfcraft_engine::MarkKind as K; let kind = match id { "edit.header_footer.remove" => K::HeaderFooter, "edit.watermark.remove" => K::Watermark, + "prepress.marks.remove" => K::PrinterMarks, _ => K::Background, }; self.apply_edit(Edit::RemoveMarks { kind }); @@ -828,6 +833,11 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } + // The OCR Verify screen keeps the window's toolbars and owns F8 and Shift+F8 (the next + // and previous word to review), so they don't show or hide the interface there. + if spec.id == crate::viewer_prefs::TOGGLE && self.ocr_verify.open { + continue; + } // A box typed into on the page (a paragraph being edited, added text) keeps Undo and // Redo only while it holds typing the page doesn't have, or Redo has typing to put // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index cf8e5e7fcc..1373313a9c 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -152,6 +152,7 @@ impl Host for crate::PdfCraftApp { "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), + "hidden_interface": self.hidden_interface().to_json(), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, "theme": format!("{:?}", self.theme), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e36a3a59ed..0f93ae8e2d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,12 +10,14 @@ use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, wid pub(super) mod appearance; pub mod bookmark_styles_ui; pub(super) mod comment_props; +pub(super) mod convert_colours_ui; pub(super) mod credits; pub(super) mod hairlines_ui; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; pub(super) mod preferences; +pub(super) mod printer_marks_ui; mod prompts; pub(super) mod protect; pub(super) mod standards_ui; @@ -44,6 +46,8 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + printer_marks_now: bool, + colours_now: bool, fix_hairlines_now: bool, marks_now: bool, export_now: bool, @@ -56,10 +60,12 @@ struct Pending { revert_now: bool, overwrite_now: bool, overwrite_elsewhere: bool, + lock_answer: Option, summarize_now: bool, optimize_now: bool, duplicate_now: Option, page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>, + attachment_description_now: Option<(pdfcraft_engine::DocId, Edit)>, replace_now: bool, open_revision: Option, a11y_now: bool, @@ -294,6 +300,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } } + Dialog::LockSignatures => { + if !dialog_lock_signatures(ui, app, req) { + return false; + } + } Dialog::Revert => { if !dialog_revert(ui, app, req) { return false; @@ -354,6 +365,27 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::AttachmentDescription => { + let Some((doc, generation, _, _, _, _)) = app.attachment_description_draft.as_ref() else { + req.close = true; + return false; + }; + if app.active_ids().is_none_or(|(_, active)| active != *doc) + || app.session.get(*doc).is_none_or(|current| current.edit_generation() != *generation) + { + app.attachment_description_draft = None; + req.close = true; + return false; + } + let Some((_, _, _, display_name, description, _)) = app.attachment_description_draft.as_mut() else { + req.close = true; + return false; + }; + ui.heading(tl!("Edit attachment description")); + ui.label(display_name.as_str()); + let label = ui.label(tl!("Description")); + ui.add(widgets::line(description)).labelled_by(label.id); + } Dialog::Signature => { if !dialog_signature(ui, app, req) { return false; @@ -448,6 +480,18 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::PrinterMarks => { + let (go, cancel) = crate::printer_marks_ui::body(ui, app, &t); + req.printer_marks_now = go; + req.close = go || cancel; + return false; + } + Dialog::ConvertColours => { + let (go, cancel) = crate::convert_colours_ui::body(ui, app, &t); + req.colours_now = go; + req.close = go || cancel; + return false; + } Dialog::FixHairlines => { let (go, cancel) = crate::hairlines_ui::body(ui, app, &t); req.fix_hairlines_now = go; @@ -1278,6 +1322,31 @@ fn dialog_summarize_comments(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut false } +/// `Dialog::LockSignatures` content (#2149). Returns `false` when it has its own buttons and no +/// footer is wanted. +fn dialog_lock_signatures(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.label(egui::RichText::new(tl!("Lock your signature into the page?")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(tl!("A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page.")); + ui.add_space(8.0); + ui.add(widgets::checkbox(&mut app.lock_remember, tl!("Don't ask again"))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Lock and save"), true).clicked() { + req.lock_answer = Some(true); + req.close = true; + } + if widgets::pill_button(ui, tl!("Keep editable"), false).clicked() { + req.lock_answer = Some(false); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + /// `Dialog::Revert` content. Returns `false` when it has its own buttons and no footer is wanted. fn dialog_revert(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); @@ -1709,6 +1778,7 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) true } +/// Draw the active dialog's footer and record any requested action in `req`. fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { ui.add_space(16.0); let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); @@ -1745,6 +1815,30 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { req.close = true; } + } else if dialog == Dialog::AttachmentDescription { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + if let Some((doc, generation, key, _, description, _)) = app.attachment_description_draft.as_ref() { + let current = app.session.get(*doc).and_then(|doc| { + doc.info + .attachments + .iter() + .find(|attachment| matches!(&attachment.source, pdfcraft_render::AttachmentSource::Document { key: attachment_key } if attachment_key == key)) + .map(|attachment| (attachment.description.as_deref().unwrap_or_default().trim().to_owned(), attachment.has_description)) + }); + let normalized = description.trim(); + let still_current = app.session.get(*doc).is_some_and(|doc| doc.edit_generation() == *generation); + if still_current && current.as_ref().is_some_and(|(current, has_description)| current != normalized || (normalized.is_empty() && *has_description)) { + req.attachment_description_now = Some(( + *doc, + Edit::EditAttachmentDescription { key: key.clone(), description: Some(normalized.to_owned()) }, + )); + } + } + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } } else if changed { if widgets::pill_button(ui, tl!("OK"), true).clicked() { req.apply = true; @@ -1816,10 +1910,26 @@ fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, re if dialog == Dialog::OverwriteChanged && (modal_closed || req.close) { app.pending_overwrite = None; } + if let Some(lock) = req.lock_answer { + let remember = std::mem::take(&mut app.lock_remember); + app.answer_lock_signatures(lock, remember); + } else if dialog == Dialog::LockSignatures && (modal_closed || req.close) { + // Cancelled or dismissed: nothing is saved. + app.pending_lock = None; + app.lock_remember = false; + } } /// Runs the document actions the dialog asked for. fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { + if let Some((doc, edit)) = req.attachment_description_now.take() { + app.attachment_description_draft = None; + if app.active_ids().is_some_and(|(_, active)| active == doc) { + app.apply_edit(edit); + } else { + app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open the description editor again."); + } + } if req.summarize_now { app.summarize_comments(); } @@ -1897,11 +2007,19 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { if let Some((_, id)) = app.active_ids() { app.renew_recovery(id); } - if marks == 1 { - app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); + let applied = if marks == 1 { + tl!("Applied 1 redaction mark. Save to remove the content from the file.").to_string() } else { - app.notify_fmt("Applied {n} redaction marks. Save to remove the content from the file.", &[("n", &marks.to_string())]); - } + crate::i18n::fmt(tl!("Applied {n} redaction marks. Save to remove the content from the file."), &[("n", &marks.to_string())]) + }; + // Where the removed text is still held that applying leaves as it is, and what + // became of a PDF/A or PDF/UA claim in the document XMP it removed: said in the + // same notification, so neither can replace the first sentence. + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + let left = doc.and_then(|d| d.redaction_elsewhere()).and_then(|e| still_holding_note(&e)); + let id = doc.and_then(|d| d.redaction_identification()).and_then(|id| identification_note(&id)); + let message = [Some(applied), left, id].into_iter().flatten().collect::>().join(" "); + app.notify(message); } } _ => {} @@ -1950,6 +2068,61 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { } } +/// The sentence telling the user where the removed text is still held that applying redactions +/// leaves as it is, or `None` when it is nowhere else. When the text couldn't be searched for, +/// the places are those holding any text, and the sentence says they may hold it. +pub(crate) fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Option { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(tl!(one).to_string()), + n => Some(crate::i18n::fmt(tl!(many), &[("n", &n.to_string())])), + }; + let places: Vec = [ + count(e.bookmarks, "1 bookmark title", "{n} bookmark titles"), + count(e.attachments, "1 attachment", "{n} attachments"), + count(e.comments, "1 comment", "{n} comments"), + count(e.xmp, "1 other metadata stream", "{n} other metadata streams"), + ] + .into_iter() + .flatten() + .collect(); + let (last, rest) = places.split_last()?; + let list = if rest.is_empty() { last.clone() } else { crate::i18n::fmt(tl!("{list} and {last}"), &[("list", &rest.join(", ")), ("last", last)]) }; + let template = if e.unsearched { + "The redacted text couldn't be searched for, so {places} may still hold it. Remove hidden information removes them." + } else { + "The redacted text also appears in {places}, which redaction doesn't change. Remove hidden information removes them." + }; + Some(crate::i18n::fmt(tl!(template), &[("places", &list)])) +} + +/// The sentence telling the user what became of the document's PDF/A or PDF/UA identification +/// when applying redactions removed the XMP packet that held it (and the redacted text), or +/// `None` when no such packet was removed. +pub(crate) fn identification_note(id: &pdfcraft_engine::RedactionIdentification) -> Option { + use pdfcraft_engine::RedactionClaim as Claim; + let named = |claim: Claim| -> Option { + match (id.pdfa == claim, id.pdfua == claim) { + (true, true) => Some(tl!("PDF/A and PDF/UA").to_string()), + (true, false) => Some("PDF/A".to_string()), + (false, true) => Some("PDF/UA".to_string()), + (false, false) => None, + } + }; + let kept = named(Claim::Kept).map(|s| crate::i18n::fmt(tl!("Its {standard} identification was written back on its own."), &[("standard", &s)])); + let lost = named(Claim::Lost).map(|s| { + crate::i18n::fmt( + tl!("Its {standard} identification couldn't be kept, so the file no longer says it conforms to {standard}."), + &[("standard", &s)], + ) + }); + if kept.is_none() && lost.is_none() { + return None; + } + let head = tl!("The document's XMP metadata held the redacted text and was removed.").to_string(); + Some([Some(head), kept, lost].into_iter().flatten().collect::>().join(" ")) +} + /// Edits built from the dialog's drafts. fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { if req.props_now @@ -1994,6 +2167,12 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.printer_marks_now { + app.add_printer_marks(); + } + if req.colours_now { + app.convert_colours(); + } if req.fix_hairlines_now { app.fix_hairlines(); } @@ -2019,7 +2198,14 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if modal_closed || req.close || replaces { // Only now: the number in Preferences can pass through 0 on its way to the one wanted. app.recent.truncate(app.recent_limit); - app.dialog = None; + // An answer may have opened another dialog this frame (saving a file that changed on + // disk asks about that): it stays. + if app.dialog == Some(dialog) { + app.dialog = None; + } + if dialog == Dialog::AttachmentDescription { + app.attachment_description_draft = None; + } app.props_draft = None; app.view_draft = None; app.custom_draft = None; diff --git a/crates/ui-egui/src/dialogs/convert_colours_ui.rs b/crates/ui-egui/src/dialogs/convert_colours_ui.rs new file mode 100644 index 0000000000..3c1ad198b0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/convert_colours_ui.rs @@ -0,0 +1,141 @@ +//! Print production ▸ Convert colours (Acrobat's Convert Colors): the target +//! space, what to convert, the pages. Convert counts first and says what changed. + +use pdfcraft_engine::{ColourTarget, ConvertColours, DocId, Edit}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColoursDraft { + pub settings: ConvertColours, + pub range: Range, + /// The document `range` was chosen for. + pub doc: Option, +} + +impl Default for ConvertColoursDraft { + fn default() -> Self { + Self { settings: ConvertColours::default(), range: Range::All, doc: None } + } +} + +impl ConvertColoursDraft { + /// A range chosen for another document (pages 4–5 of a long one) means nothing on this one. + pub fn for_document(&mut self, id: DocId) { + if self.doc != Some(id) { + self.range = Range::All; + self.doc = Some(id); + } + } + + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } +} + +/// The dialog: (convert, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let d = &mut app.convert_colours_draft; + d.for_document(id); + ui.label(egui::RichText::new(tl!("Convert colours")).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, tl!("Convert to")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Gray, tl!("Grayscale")); + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Rgb, tl!("RGB")); + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Cmyk, tl!("CMYK")); + }); + ui.label(egui::RichText::new(tl!("By the standard device formulas; profiles are not applied.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Options")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.preserve_black, tl!("Preserve black"))); + ui.label(egui::RichText::new(tl!("pure black stays 100% K or gray 0 instead of a mix")).small().color(t.text_muted)); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.images, tl!("Pictures too"))); + ui.label(egui::RichText::new(tl!("re-encoded in the target space; JPEGs stay JPEG")).small().color(t.text_muted)); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.annotations, tl!("Comments and form fields"))); + ui.label(egui::RichText::new(tl!("their appearances as well as the page")).small().color(t.text_muted)); + }); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + let from = ui.add(widgets::radio(on, tl!("From"))); + if from.clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))).labelled_by(from.id); + let to = ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))).labelled_by(to.id); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(count > 0, |ui| widgets::pill_button(ui, tl!("Convert"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Convert: count first (the edit refuses when nothing changes), then apply and say. + pub(crate) fn convert_colours(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let pages = self.convert_colours_draft.pages(self.views[i].current, count); + let settings = self.convert_colours_draft.settings.clone(); + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + self.notify_tr("Nothing on those pages needed converting"); + return; + } + if self.apply_edit(Edit::ConvertColours { pages, settings }) { + self.notify_fmt( + "Converted {c} colours and {i} pictures on {p} pages", + &[("c", &found.colours.to_string()), ("i", &found.images.to_string()), ("p", &found.pages.to_string())], + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_page_range_does_not_follow_the_user_to_another_document() { + let (a, b) = (DocId(1), DocId(2)); + let mut d = ConvertColoursDraft::default(); + d.for_document(a); + d.range = Range::Pages(4, 5); + d.for_document(a); + assert_eq!(d.range, Range::Pages(4, 5), "same document keeps its range"); + d.for_document(b); + assert_eq!(d.range, Range::All); + assert_eq!(d.pages(0, 1), vec![0]); + } +} diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index ec5479a372..f2358070b8 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -473,6 +473,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M MarkKind::HeaderFooter => crate::i18n::fmt(tl!("{verb} Header and Footer"), &[("verb", verb)]), MarkKind::Watermark => crate::i18n::fmt(tl!("{verb} Watermark"), &[("verb", verb)]), MarkKind::Background => crate::i18n::fmt(tl!("{verb} Background"), &[("verb", verb)]), + // Printer marks have a dialog of their own (printer_marks_ui); this one is never opened for them. + MarkKind::PrinterMarks => tl!("Add printer marks").to_string(), }; ui.label(egui::RichText::new(title).font(theme::semibold(18.0))); ui.add_space(8.0); @@ -607,6 +609,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M }); }); } + MarkKind::PrinterMarks => {} MarkKind::Background => { ui.horizontal(|ui| { widgets::radio_value(ui, &mut d.use_file, false, tl!("From colour")); @@ -718,6 +721,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } // A colour is the page's tint. MarkKind::Background => {} + MarkKind::PrinterMarks => {} }); ui.vertical(|ui| { ui.horizontal(|ui| { @@ -768,6 +772,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } } MarkKind::Background => !d.use_file || d.file.is_some(), + MarkKind::PrinterMarks => true, } && !d.range.pages(count).is_empty(); if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { @@ -803,7 +808,7 @@ fn undrawable(kind: MarkKind, d: &MarksDraft) -> Option { match kind { MarkKind::HeaderFooter => d.hf.first_undrawable(), MarkKind::Watermark if !d.use_file => d.wm.first_undrawable(), - MarkKind::Watermark | MarkKind::Background => None, + MarkKind::Watermark | MarkKind::Background | MarkKind::PrinterMarks => None, } } @@ -882,6 +887,7 @@ pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { Edit::AddWatermark { pages, settings: wm, replace: d.replace, file: mark_file(d) } } MarkKind::Background => Edit::AddBackground { pages, settings: d.bg.clone(), replace: d.replace, file: mark_file(d) }, + MarkKind::PrinterMarks => Edit::AddPrinterMarks { pages, settings: pdfcraft_engine::PrinterMarks::default(), replace: d.replace }, } } diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 830144277c..883d0b68a6 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -796,6 +796,9 @@ fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut /// Content for Card 5: Fill & Sign fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ctx.checkbox(ui, &mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"), t); + ui.add_enabled_ui(!app.flatten_fill_sign_on_save, |ui| { + ctx.checkbox(ui, &mut app.ask_to_lock_signatures, tl!("Otherwise, ask on the first save after a signature is placed"), t); + }); ctx.label_secondary(ui, tl!("Text, marks and signatures become part of the page. Other comments stay editable."), t); ui.add_space(8.0); date_format(ui, app, t, ctx); diff --git a/crates/ui-egui/src/dialogs/printer_marks_ui.rs b/crates/ui-egui/src/dialogs/printer_marks_ui.rs new file mode 100644 index 0000000000..5331cfa3a5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/printer_marks_ui.rs @@ -0,0 +1,152 @@ +//! Print production ▸ Add printer marks (Acrobat's Add Printer Marks): which +//! marks, the line weight, whether to widen the page to make room, and the pages. The marks are +//! a removable page mark (Remove printer marks takes them off; adding again replaces them). + +use pdfcraft_engine::{Edit, PrinterMarks}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarksDraft { + pub settings: PrinterMarks, + pub range: Range, + /// Widen the page when the marks would fall outside it (`settings.make_room`). + pub make_room: bool, + pub room: f64, +} + +impl Default for PrinterMarksDraft { + fn default() -> Self { + Self { settings: PrinterMarks::default(), range: Range::All, make_room: true, room: 36.0 } + } +} + +impl PrinterMarksDraft { + fn all(&self) -> bool { + let s = &self.settings; + s.trim && s.bleed && s.registration && s.color_bars && s.page_info + } + + fn set_all(&mut self, on: bool) { + let s = &mut self.settings; + s.trim = on; + s.bleed = on; + s.registration = on; + s.color_bars = on; + s.page_info = on; + } + + fn any(&self) -> bool { + let s = &self.settings; + s.trim || s.bleed || s.registration || s.color_bars || s.page_info + } + + /// The edit for `count` pages with `current` on screen, labelled with the file's name and + /// today's date. + pub fn edit(&self, current: usize, count: usize, label: &str, date: &str) -> Edit { + let pages = match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + }; + let settings = PrinterMarks { + make_room: self.make_room.then_some(self.room.max(0.0)), + label: label.to_string(), + date: date.to_string(), + ..self.settings.clone() + }; + Edit::AddPrinterMarks { pages, settings, replace: true } + } +} + +/// The dialog: (add, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let d = &mut app.printer_marks_draft; + ui.label(egui::RichText::new(tl!("Add printer marks")).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, tl!("Marks")); + let mut all = d.all(); + if ui.add(widgets::checkbox(&mut all, tl!("All marks"))).changed() { + d.set_all(all); + } + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.trim, tl!("Trim marks"))); + ui.add(widgets::checkbox(&mut d.settings.bleed, tl!("Bleed marks"))); + ui.add(widgets::checkbox(&mut d.settings.registration, tl!("Registration marks"))); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.color_bars, tl!("Colour bars"))); + ui.add(widgets::checkbox(&mut d.settings.page_info, tl!("Page information"))); + }); + ui.horizontal(|ui| { + let weight_label = ui.label(tl!("Line weight")); + let weights = [(0.25, "0.25 pt"), (0.5, "0.50 pt"), (1.0, "1.00 pt")]; + let shown = weights.iter().find(|(w, _)| (*w - d.settings.line_weight).abs() < 1e-6).map_or("0.25 pt", |(_, l)| l); + let combo = craft_ui::ComboBox::from_id_salt("marks-weight").selected_text(shown).width(100.0).show_ui(ui, |ui| { + for (w, l) in weights { + ui.selectable_value(&mut d.settings.line_weight, w, l); + } + }); + combo.response.labelled_by(weight_label.id); + }); + widgets::section_title(ui, tl!("Page")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.make_room, tl!("Widen the page to make room"))); + ui.add_enabled_ui(d.make_room, |ui| { + let room_label = ui.label(tl!("Room on each side")); + ui.add(egui::DragValue::new(&mut d.room).range(0.0..=PrinterMarks::MAX_ROOM).suffix(" pt")).labelled_by(room_label.id); + }); + }); + ui.label(egui::RichText::new(tl!("The trim box is kept where it is, so the printed page cuts the same.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + if ui.add(widgets::radio(on, tl!("From"))).clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))); + ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + let ready = d.any() && count > 0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Add"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Add: the marks on the chosen pages, labelled with the file's name and today's date. + pub(crate) fn add_printer_marks(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let (count, label) = (doc.info.pages.len(), doc.name.clone()); + let (y, m, d) = self.session.today(); + let date = format!("{y:04}-{m:02}-{d:02}"); + let edit = self.printer_marks_draft.edit(self.views[i].current, count, &label, &date); + if self.apply_edit(edit) { + self.notify_tr("Printer marks added"); + } + } +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f0532b4ac4..089b58ddeb 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -10,6 +10,7 @@ mod close; mod save; pub use close::CloseRequest; +pub use save::PendingLock; #[cfg(not(target_arch = "wasm32"))] pub use save::PendingOverwrite; #[cfg(target_arch = "wasm32")] @@ -245,12 +246,17 @@ impl PdfCraftApp { self.quick_tool = crate::QuickTool::Select; } // Text saved as typed but drawn with `?` (its font can't draw it), or pages whose - // own backdrop stays off: said, not silent. Before the scripts' output, so an alert - // they raise still shows. + // own backdrop stays off, or places applied redactions left holding the removed text + // (the Apply redactions dialog then says it after its count): said, not silent. + // Before the scripts' output, so an alert they raise still shows. if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() { self.notify(match &n { pdfcraft_engine::EditNotice::Undrawn(t) => crate::content_ui::undrawn_message(t), pdfcraft_engine::EditNotice::BackdropsLeftOff { pages } => crate::marks_ui::backdrops_left_off_message(pages), + pdfcraft_engine::EditNotice::RedactionElsewhere(e) => crate::dialogs::still_holding_note(e).unwrap_or_else(|| n.to_string()), + pdfcraft_engine::EditNotice::RedactionIdentification(id) => { + crate::dialogs::identification_note(id).unwrap_or_else(|| n.to_string()) + } }); } let out = self.session.take_js_output(id); @@ -544,6 +550,11 @@ impl PdfCraftApp { } } Some(crate::canvas::ViewAction::CameraLevel) => self.camera_level(i), + Some(crate::canvas::ViewAction::LockSignatures) => { + if let Some(id) = self.views.get(i).map(|v| v.id) { + self.lock_signatures_now(id); + } + } Some(crate::canvas::ViewAction::CameraNext(command)) => { self.leave_camera(i); self.execute(command); @@ -837,12 +848,22 @@ impl PdfCraftApp { fn apply_queued_edit(&mut self, i: usize) -> bool { let Some(edit) = self.views.get_mut(i).and_then(|v| v.pending_edit.take()) else { return true }; let signature_page = self.views.get_mut(i).and_then(|v| v.fill_signature_page.take()); + let doc_id = self.views.get(i).map(|v| v.id); let committed = self.views.get_mut(i).and_then(|v| v.forms.committed.take()); let typed = match (&edit, &committed) { (Edit::SetFieldValue { name, .. }, Some(draft)) => *name == draft.name, _ => false, }; if self.apply_edit(edit) { + if signature_page.is_some() + && let Some(id) = doc_id + { + self.placed_signatures.insert(id); + // A new signature shows the Lock now notice again, even if the last was dismissed. + if let Some(view) = self.views.get_mut(i) { + view.lock_offer_dismissed = false; + } + } // Tab can wrap to the same field (including another widget of that field). // Its new editor was opened before this edit ran, so it still holds the old // value. Refresh from the accepted form before Save can commit that stale draft. diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 88e15b8a9c..a43caf04ef 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -103,6 +103,9 @@ impl PdfCraftApp { view.redactions_undone_after_save = 0; view.saved_redactions_cut_off = false; } + // A signature placed since the last save is gone with the rest: the next save + // doesn't ask to lock it (#2149). + self.placed_signatures.remove(&id); self.notify_tr("Reverted to the last saved version"); } Err(e) => self.notify_error(e), diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index fe9e0f8b81..b27b953cfa 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -13,6 +13,14 @@ pub struct PendingOverwrite { after: Box, } +/// A save held back by [`Dialog::LockSignatures`](crate::Dialog::LockSignatures) (#2149). +pub struct PendingLock { + pub id: pdfcraft_engine::DocId, + target: SaveTarget, + /// What was to run once the document is written (close its tab, quit). + after: Box, +} + /// Where Save writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SaveTarget { @@ -48,6 +56,24 @@ impl PdfCraftApp { return false; } let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; + // A signature just placed with Fill & Sign is a removable comment unless it is flattened: + // ask once whether to lock it into the page (#2149). Only while a Fill & Sign mark is still + // there (undo may have taken it away), and never on a digitally signed document: locking + // changes page content in an update, which validators report as a change after signing. + let still_marked = self.session.get(id).is_some_and(|d| d.info.annotations.iter().any(|a| a.fill_sign)); + let signed = self.session.get(id).is_some_and(|d| d.is_signed()); + let answered = self.lock_answered.take() == Some(id); + if self.ask_to_lock_signatures + && !self.flatten_fill_sign_on_save + && self.placed_signatures.contains(&id) + && still_marked + && !signed + && !answered + { + self.pending_lock = Some(PendingLock { id, target, after: Box::new(after) }); + self.dialog = Some(crate::Dialog::LockSignatures); + return false; + } let Some(doc) = self.session.get(id) else { return false }; let (name, path) = (doc.name.clone(), doc.path.clone()); // After Apply redactions, Save asks where and proposes the name with `_Redacted`. @@ -118,7 +144,10 @@ impl PdfCraftApp { }; match delivered { Ok(()) => { - let _ = self.session.mark_saved(id, bytes, None); + // As the native save does: what was saved isn't asked about again (#2149). + if self.session.mark_saved(id, bytes, None).is_ok() { + self.placed_signatures.remove(&id); + } if let Some(doc) = self.session.get(id) && let Some(view) = self.views.get_mut(index) { @@ -184,6 +213,64 @@ impl PdfCraftApp { } } + /// The Lock signatures dialog answered (#2149): lock the document's Fill & Sign marks into + /// its pages first when `lock`, then save as was asked. With `remember`, the answer becomes + /// the preference (flatten on every save, or never ask again). Nothing changes when locking + /// fails: the document still counts as holding a placed signature, so the next save asks + /// again, and the answer isn't remembered. The placed-signature mark stays until the save + /// succeeds, so a save that fails or is cancelled asks again too. + pub(crate) fn answer_lock_signatures(&mut self, lock: bool, remember: bool) { + let Some(p) = self.pending_lock.take() else { return }; + if lock && !self.flatten_fill_sign_on_save && !self.flatten_fill_sign_in(p.id) { + return; + } + self.lock_answered = Some(p.id); + if remember { + if lock { + self.flatten_fill_sign_on_save = true; + } else { + self.ask_to_lock_signatures = false; + } + } + if let Some(index) = self.views.iter().position(|v| v.id == p.id) { + self.save_then(index, p.target, p.after); + } + } + + /// Lock now, on the notice shown after a signature is placed (#2149): lock document `id`'s + /// Fill & Sign marks into its pages, as the save-time question's Lock does but without + /// saving. Undoable. The next save doesn't ask, as no Fill & Sign mark is left to lock; the + /// placed-signature mark itself stays, so undoing the lock brings back both the notice and the + /// save-time question. Never on a digitally signed document. + pub(crate) fn lock_signatures_now(&mut self, id: pdfcraft_engine::DocId) { + if self.session.get(id).is_none_or(|d| d.is_signed()) { + return; + } + if self.flatten_fill_sign_in(id) { + self.notify_tr("Signature locked into the page."); + } + } + + /// Lock document `id`'s Fill & Sign marks into its pages (an undoable edit). Returns false, + /// having told the user why, when that fails. + fn flatten_fill_sign_in(&mut self, id: pdfcraft_engine::DocId) -> bool { + let before = self.session.get(id).map(|d| d.edit_generation()); + if let Err(e) = self.session.apply(id, pdfcraft_engine::Edit::FlattenFillSign) { + self.notify_error(e); + return false; + } + if before != self.session.get(id).map(|d| d.edit_generation()) + && let Some(doc) = self.session.get(id) + && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + { + view.document_changed(&doc.info); + // The marks left the comments: a selected one is gone, and its (page, index) could + // now name another comment. + view.comments.selected = None; + } + true + } + /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { @@ -223,6 +310,7 @@ impl PdfCraftApp { match self.session.mark_saved(id, bytes, Some(dest.to_string())) { Ok(()) => { self.forget_recovery(id); + self.placed_signatures.remove(&id); // The tab in use: `sync_views` brings any other tab of the document up to date. if let Some(doc) = self.session.get(id) && let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i)) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71937a6275..2ff2253d36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,10 +59,11 @@ mod home; mod hotbar; mod panels; pub mod panes; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; +pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; +pub mod viewer_prefs; mod widgets; use pdfcraft_engine::{DocId, Session}; @@ -110,11 +111,13 @@ use canvas::zoom_snap; use dialogs::appearance; pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; +use dialogs::convert_colours_ui; use dialogs::credits; use dialogs::hairlines_ui; use dialogs::marks_ui; use dialogs::pageboxes; use dialogs::palette; +use dialogs::printer_marks_ui; use dialogs::protect; use dialogs::standards_ui; use dialogs::word_count_ui; @@ -325,6 +328,19 @@ pub struct PdfCraftApp { /// The user closed the right panel, so opening a document leaves it closed instead of /// showing its comments or bookmarks (#1590). Remembered across restarts. pub right_panel_closed: bool, + /// A portfolio that just opened and the name-tree key of the attachment it presents first + /// (`/Collection /D`), opened by [`Self::present_portfolios`] on the next frame while the + /// portfolio is still the active tab. + portfolio_initial: Option<(DocId, String)>, + /// The document whose tab [`Self::present_portfolios`] last saw active. + portfolio_seen: Option, + /// Browser file reads still under way (drops, the web file picker): each file opens in front + /// when it arrives. + #[cfg(target_arch = "wasm32")] + web_reads: std::sync::Arc, + /// A portfolio's file list is showing in place of this panel (`None`: the panel was + /// closed), given back when its tab is left. + portfolio_panel: Option>, pub quick_tool: QuickTool, /// The tool to go back to when Space, held for a temporary Hand, is released. space_hand: Option, @@ -366,6 +382,19 @@ pub struct PdfCraftApp { ui_scale_applied: Option, /// Preferences: bake Fill & Sign marks into the page when saving. Off, so a normal save stays editable. pub flatten_fill_sign_on_save: bool, + /// Ask on the first save after Fill & Sign places a signature or initials whether to lock + /// them into the page (#2149). Off once the user says not to ask again. + pub ask_to_lock_signatures: bool, + /// Documents with a Fill & Sign signature placed since their last save, asked about on + /// saving (#2149). Not part of the recovery data: a recovered document isn't asked. + pub(crate) placed_signatures: std::collections::HashSet, + /// A save waiting behind [`Dialog::LockSignatures`], and its Don't ask again box. + pub(crate) pending_lock: Option, + /// The document whose lock question was just answered: the save that follows the answer + /// doesn't ask again. The placed-signature mark itself stays until that save succeeds, so a + /// save that fails or is cancelled asks again next time. + pub(crate) lock_answered: Option, + pub(crate) lock_remember: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, pub fill_tool: fill_sign::FillTool, @@ -389,6 +418,8 @@ pub struct PdfCraftApp { pub printer_source: Option, /// Where Properties… reads a printer's driver options: the spooler's unless a test stands in. pub options_source: Option, + /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). + pub spooler: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, @@ -522,6 +553,8 @@ pub struct PdfCraftApp { pub save_override: Option, /// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords). pub props_draft: Option<(DocId, [String; 4])>, + /// Attachment description being edited: (document, name-tree key, description). + pub attachment_description_draft: Option<(DocId, u64, String, String, String, bool)>, /// Document Properties ▸ Initial View (and reading options) being edited. pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, /// Document Properties ▸ Custom being edited. @@ -721,6 +754,8 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub printer_marks_draft: printer_marks_ui::PrinterMarksDraft, + pub convert_colours_draft: convert_colours_ui::ConvertColoursDraft, pub hairlines_draft: hairlines_ui::HairlinesDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, @@ -777,6 +812,9 @@ pub struct PdfCraftApp { /// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`): /// the person is asked first, as in Acrobat, and this is the document waiting for the answer. pub pending_full_screen: Option, + /// A document just opened asks for its window to fit its first page or to be centred + /// (`FitWindow`, `CenterWindow`): done once the page is laid out. + pub pending_window: Option, /// An absolute GoToR target waiting for approval, before any target-file I/O (#1364). pub pending_remote_link: Option, } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6987ba3b36..066a914e0d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -173,7 +173,7 @@ const DISPLAY: &[&str] = &[ const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; const PANES: &[&str] = &["view.side_panel", "view.tools_pane", "view.split"]; const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; -const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode", crate::viewer_prefs::TOGGLE]; const PAGES: &[&str] = &[ "page.organize", SEP, diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 349a837b3e..c215f2045f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -392,6 +392,20 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { attachment_action = Some((ai, false)); ui.close(); } + if let Some(key) = deletable + && ui.button(tl!("Edit description")).clicked() + { + app.attachment_description_draft = Some(( + id, + doc.edit_generation(), + key.clone(), + a.name.clone(), + a.description.clone().unwrap_or_default(), + a.has_description, + )); + app.dialog = Some(crate::Dialog::AttachmentDescription); + ui.close(); + } if let Some(key) = deletable && ui.button(tl!("Delete attachment")).clicked() { diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 98063e6ef4..24b46cb663 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -301,6 +301,9 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static if let Some(cmd) = crate::fill_sign::signature_entries(ui, app, t) { run = Some(cmd); } + // Where the signing happens, not only in Preferences, and once: not also in the Sign + // menu over the page, where it was easy to miss (#2149). + ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Lock into the page when saving"))); } }); if footer { diff --git a/crates/ui-egui/src/platform/file_watch.rs b/crates/ui-egui/src/platform/file_watch.rs index 49013d1b74..85ec4d0f5b 100644 --- a/crates/ui-egui/src/platform/file_watch.rs +++ b/crates/ui-egui/src/platform/file_watch.rs @@ -476,8 +476,10 @@ impl PdfCraftApp { let Some(id) = self.views.get(i).map(|v| v.id) else { return }; match self.session.reload(id, bytes) { Ok(()) => { - // Whatever was unsaved is gone, so there is nothing left to recover. + // Whatever was unsaved is gone, so there is nothing left to recover, and no + // signature placed since the last save is left to ask about locking (#2149). self.forget_recovery(id); + self.placed_signatures.remove(&id); if let (Some(view), Some(doc)) = (self.views.get_mut(i), self.session.get(id)) { view.document_replaced(&doc.info); } @@ -581,6 +583,22 @@ mod tests { app.session.get(app.views[0].id).unwrap() } + /// #2348 review (CodeRabbit): a reload from disk replaces the document, so a signature + /// placed since the last save is gone and the next save doesn't ask to lock it. A reload + /// that fails keeps the document, and the mark with it. + #[test] + fn reloading_from_disk_forgets_a_signature_just_placed() { + let (_dir, path) = TempDir::new("lock-reload", &fixture(1)); + let mut h = harness(&path); + h.run_steps(3); + let id = h.state().views[0].id; + h.state_mut().placed_signatures.insert(id); + h.state_mut().reload_view(0, Arc::new(b"not a pdf".to_vec())); + assert!(h.state().placed_signatures.contains(&id), "a failed reload keeps the mark"); + h.state_mut().reload_view(0, Arc::new(fixture(2))); + assert!(!h.state().placed_signatures.contains(&id), "the reloaded document has no placed signature"); + } + /// Association recovery changes ctime without replacing PDF contents. It must neither /// discard undo history nor make the next Save ask to overwrite our own metadata change. #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7be59bb59c..d4f5e09be4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,10 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Add printer marks. + PrinterMarks, + /// Print production ▸ Convert colours. + ConvertColours, /// Print production ▸ Fix hairlines. FixHairlines, /// Organize pages ▸ Page properties (the tab order of the selected pages). @@ -127,6 +131,8 @@ pub enum Dialog { Signature, /// Comment Properties. CommentProps, + /// Edit the description of an embedded document attachment. + AttachmentDescription, /// Replace Pages (after choosing the file). ReplacePages, /// Prepare a form ▸ Field Properties. @@ -144,6 +150,9 @@ pub enum Dialog { Revert, /// Save over a file that changed on disk since the document came from it (#2166). OverwriteChanged, + /// Saving a document with a signature just placed by Fill & Sign: lock it into the page + /// first? (#2149) + LockSignatures, /// Link Properties. LinkProps, /// Organize ▸ Extract (options), Rotate Pages. diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 2c956aeec4..436a9d3db6 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -16,6 +16,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform, SignTab}; use crate::page_cursor::Layer; +use crate::page_pointer::PagePointer; use crate::page_router::{Hit, Owner, PagePress}; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -122,6 +123,147 @@ fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } +/// Where a field's text sits on screen. It is laid out upright in `upright`, the field's screen +/// rectangle with its sides swapped when the text runs up or down the screen, and turned onto +/// the field about its centre. The field's `/MK /R` turns its content counterclockwise on the +/// page (ISO 32000-2 Table 192), as its page appearance draws it; the page's `/Rotate` and the +/// view's rotation turn the page clockwise. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TextFrame { + pub upright: Rect, + /// Clockwise on screen: 0, 90, 180 or 270 degrees. + pub turn: i64, +} + +impl TextFrame { + pub(crate) fn new(field: Rect, mk_r: i64, page_rotate: u16, view_rotation: u16) -> Self { + // Each term is reduced first, so the sum cannot overflow. + let turn = (i64::from(page_rotate % 360) + i64::from(view_rotation % 360) - mk_r.rem_euclid(360)).rem_euclid(360) / 90 * 90; + let upright = if turn % 180 == 90 { Rect::from_center_size(field.center(), vec2(field.height(), field.width())) } else { field }; + Self { upright, turn } + } + + fn of(xf: &PageXform, info: &DocInfo, page: usize, w: &FormWidget, field: Rect) -> Self { + Self::new(field, w.rotation, info.pages.get(page).map_or(0, |p| p.rotation), xf.rot) + } + + fn angle(self) -> f32 { + (self.turn as f32).to_radians() + } + + /// A point of the upright box, on screen. + pub(crate) fn on_screen(self, p: egui::Pos2) -> egui::Pos2 { + let c = self.upright.center(); + c + egui::emath::Rot2::from_angle(self.angle()) * (p - c) + } + + /// A screen point, in the upright box. + pub(crate) fn in_box(self, p: egui::Pos2) -> egui::Pos2 { + let c = self.upright.center(); + c + egui::emath::Rot2::from_angle(-self.angle()) * (p - c) + } + + /// Paint `galley` with its top-left corner at `pos` in the upright box. + fn paint_galley(self, painter: &egui::Painter, pos: egui::Pos2, galley: std::sync::Arc, color: Color32) { + painter.add(egui::epaint::TextShape::new(self.on_screen(pos), galley, color).with_angle(self.angle())); + } + + /// Where a field editor's upright text starts: inside its margins. + fn text_origin(self) -> egui::Pos2 { + self.upright.min + vec2(3.0, 1.0) + } + + /// A screen point `p` as a position in `galley`, which the editor drew at `pos`; a turned + /// field's text is laid out upright in the frame's box. + fn in_galley(self, galley: &egui::Galley, pos: egui::Pos2, p: egui::Pos2) -> egui::Vec2 { + if self.turn != 0 { self.in_box(p) - self.text_origin() + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } + } +} + +/// A field editor's laid-out text and where the editor drew it. +type Laid = (std::sync::Arc, egui::Pos2); + +/// How a turned field's editor lays its text out: at the upright box's width (`wrap`, for a +/// multi-line field), not the field's on screen. +fn turned_layouter( + font: f32, + wrap: Option, + password: bool, +) -> impl FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc { + move |ui, text, _| { + let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; + let font = egui::FontId::proportional(font); + let mut job = match wrap { + Some(wrap) => egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap), + None => egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK), + }; + job.keep_trailing_whitespace = true; + ui.fonts_mut(|f| f.layout_job(job)) + } +} + +/// Show a field's editor over `rect`. A turned field's editor takes the keys and the pointer +/// over the field but draws nothing: [`turned_caret_and_paint`] paints its text turned (#4296). +fn show_field_editor(ui: &mut egui::Ui, te: egui::TextEdit<'_>, rect: Rect, turned: bool) -> (egui::Response, Option) { + let mut laid_out = None; + let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { + if turned { + ui.set_opacity(0.0); + } + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }); + (r, laid_out) +} + +/// Unless the caret was just placed under the press that opened the editor (`caret_placed`, by +/// [`settle_text_editor`]), place it under the pointer in a turned field's text, and paint a +/// turned field's text, selection and caret as the page will show them. +fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: TextFrame, laid_out: Option<&Laid>, caret_placed: bool, font: f32) { + let turned = frame.turn != 0; + // The pointer as the editor sees it. The editor is an Area over the page, so a press on it + // is its own and the pages' pointer does not reach it: read for the editor's response, it + // holds the pointer from a press on the editor until the frame of its release, a touch's + // lift included. A dialog over the editor takes the presses before they reach it. + let ptr = PagePointer::read(ui, r, false); + if !caret_placed + && turned + && r.is_pointer_button_down_on() + && let Some(p) = ptr.pos + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The editor placed the caret as if its text were upright: place it under the pointer + // in the turned text instead, a press at the point and a drag from where the press + // left it. + let at = galley.cursor_from_pos(frame.in_galley(galley, *pos, p)); + let range = match st.cursor.char_range() { + Some(was) if ptr.pressed().is_none() => egui::text::CCursorRange::two(was.secondary, at), + _ => egui::text::CCursorRange::one(at), + }; + st.cursor.set_char_range(Some(range)); + st.store(ui.ctx(), r.id); + } + if turned && let Some((galley, _)) = laid_out { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); + let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); + let mut shown = galley.clone(); + if let Some(range) = &range { + egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); + } + let start = frame.text_origin() - vec2(galley.rect.left(), 0.0); + frame.paint_galley(&painter, start, shown, Color32::BLACK); + if let Some(range) = range { + let mut caret = galley.pos_from_cursor(range.primary); + caret.max.y = caret.max.y.max(caret.min.y + font); + let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); + painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); + } + } +} + fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } @@ -468,18 +610,20 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { } /// The text box of an editable combo box, over the field. What is typed becomes the value when -/// Enter, Tab or a click away commits it; a pick from the list still sets an option. +/// Enter, Tab or a click away commits it; a pick from the list still sets an option. A turned +/// field is typed into as a turned text field is: its text runs along the turn (#4296). struct ComboEditor<'a> { form: &'a [FormField], field: &'a FormField, widget: &'a FormWidget, rect: Rect, + frame: TextFrame, zoom: f32, index: usize, } fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>) { - let ComboEditor { form, field: f, widget: w, rect, zoom, index: widget } = editor; + let ComboEditor { form, field: f, widget: w, rect, frame, zoom, index: widget } = editor; let mut enter = false; let view_key = view.view_key(); egui::Area::new(egui::Id::unique(("form-editor", view_key))) @@ -494,15 +638,22 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_> let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom); // The box is pale yellow in every theme, as a text field's is (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); - let te = egui::TextEdit::singleline(&mut fx.text) - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + let turned = frame.turn != 0; + let mut layouter = turned_layouter(font, None, false); + let mut te = egui::TextEdit::singleline(&mut fx.text) + .desired_width((frame.upright.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .id(egui::Id::unique(("form-field", view_key, &f.name, widget))); + if turned { + te = te.layouter(&mut layouter); + } + let (r, laid_out) = show_field_editor(ui, te, rect, turned); // The form field's own logical name; not a visual/preordered label. - let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); + let r = r.accessible_name(f.name.clone()); + turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), false, font); if std::mem::take(&mut fx.request_focus) { r.request_focus(); // Entered with Tab: typing replaces the value. @@ -664,7 +815,7 @@ pub(crate) fn paint_page( painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside); } if view.forms.unbaked.contains(&(f.name.clone(), page)) { - paint_unbaked_widget(painter, xf, f, wi, w, r); + paint_unbaked_widget(painter, xf, TextFrame::of(xf, info, page, w, r), f, wi, w, r); } else if fillable(f) && hovered == Some((fi, wi)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } @@ -704,7 +855,10 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t let size = fixed.unwrap_or_else(|| { // The appearance's padding: 2 pt inside a 1 pt border. let pad = 3.0; - let (width, height) = ((w.rect[2] - w.rect[0]).abs() - 2.0 * pad, (w.rect[3] - w.rect[1]).abs() - 2.0 * pad); + let (across, up) = ((w.rect[2] - w.rect[0]).abs(), (w.rect[3] - w.rect[1]).abs()); + // A field turned a quarter lays its text out along its height, as its appearance does. + let (across, up) = if matches!(w.rotation, 90 | 270) { (up, across) } else { (across, up) }; + let (width, height) = (across - 2.0 * pad, up - 2.0 * pad); // Comb cells hold one character each, so only the height limits them, as on the page. if f.is_comb() { return pdfcraft_engine::field_comb_auto_text_size(height); @@ -740,7 +894,7 @@ pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) } } -fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, wi: usize, w: &FormWidget, r: Rect) { +fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, frame: TextFrame, f: &FormField, wi: usize, w: &FormWidget, r: Rect) { match f.kind { FormFieldKind::Text | FormFieldKind::Combo => { let text = f.value.first().map(String::as_str).unwrap_or(""); @@ -752,16 +906,18 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, let font_size = field_screen_font(painter, f, w, text, zoom); let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() }; let p = painter.with_clip_rect(r); + // Laid out upright in the frame's box, then turned onto the field (#4296). + let b = frame.upright; let (align, pos) = match f.quadding { - 1 => (egui::Align2::CENTER_TOP, egui::pos2(r.center().x, r.min.y + 1.0)), - 2 => (egui::Align2::RIGHT_TOP, egui::pos2(r.max.x - 3.0, r.min.y + 1.0)), - _ => (egui::Align2::LEFT_TOP, egui::pos2(r.min.x + 3.0, r.min.y + 1.0)), + 1 => (egui::Align2::CENTER_TOP, egui::pos2(b.center().x, b.min.y + 1.0)), + 2 => (egui::Align2::RIGHT_TOP, egui::pos2(b.max.x - 3.0, b.min.y + 1.0)), + _ => (egui::Align2::LEFT_TOP, egui::pos2(b.min.x + 3.0, b.min.y + 1.0)), }; // Multiline fields wrap at the box, as their page appearance does. // At the width the size was fitted to: the box less 3 pt each side, scaled. - let wrap = if f.has(field_flags::MULTILINE) { (r.width() - 6.0 * zoom).max(1.0) } else { f32::INFINITY }; + let wrap = if f.has(field_flags::MULTILINE) { (b.width() - 6.0 * zoom).max(1.0) } else { f32::INFINITY }; let galley = p.layout(display_text, egui::FontId::proportional(font_size), Color32::BLACK, wrap); - p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); + frame.paint_galley(&p, align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); } } FormFieldKind::List => { @@ -828,7 +984,8 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ } /// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the -/// caret where the press that opened the editor landed, and takes focus when asked. +/// caret where the press that opened the editor landed (in the upright text of a turned field, +/// through `frame`, #4296), and takes focus when asked. Returns whether it placed the caret. fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, @@ -836,7 +993,9 @@ fn settle_text_editor( primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, -) { + frame: TextFrame, +) -> bool { + let mut pressed = false; if let Some((tb, before)) = fitted && fx.text != before { @@ -855,12 +1014,13 @@ fn settle_text_editor( && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + let at = galley.cursor_from_pos(frame.in_galley(&galley, pos, press)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); if primary_down { ui.ctx().set_dragged_id(r.id); } + pressed = true; } if fx.request_focus { r.request_focus(); @@ -873,6 +1033,7 @@ fn settle_text_editor( st.store(ui.ctx(), r.id); } } + pressed } /// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab @@ -1016,33 +1177,41 @@ pub(crate) fn overlay( ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). let before = text_box.map(|_| fx.text.clone()); + // A turned field (/MK /R, or a turned page or view) is typed into where it is: + // the editor takes the keys and the pointer over the field, but draws nothing, + // and its text is painted turned below, as the page will show it (#4296). + let frame = TextFrame::of(&xf, info, page, w, rect); + let turned = frame.turn != 0; + let upright = frame.upright; + let password = f.has(field_flags::PASSWORD); + // Wraps (inside the 3 px side margins) at the width the size was fitted to: + // the box less 3 pt each side, scaled. + let wrap = (upright.width() - 6.0 * zoom).max(1.0); + let mut layouter = turned_layouter(font, multiline.then_some(wrap), password); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te - // Wraps (inside the 3 px side margins) at the width the size was fitted to: - // the box less 3 pt each side, scaled. - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .desired_width((upright.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .password(f.has(field_flags::PASSWORD)) + .password(password) .id(egui::Id::unique(("form-field", view.pane.key, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } if multiline { - te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); + te = te.desired_rows(((upright.height() / (font * 1.3)).floor() as usize).max(1)); } - let mut laid_out = None; - let r = ui - .add_sized(rect.size(), |ui: &mut egui::Ui| { - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }) - // The form field's own logical name; not a visual/preordered label. - .accessible_name(f.name.clone()); - settle_text_editor(ui, fx, &r, primary_down, laid_out, text_box.zip(before)); + if turned { + // Laid out at the upright box's width, not the field's on screen. + te = te.layouter(&mut layouter); + } + let (r, laid_out) = show_field_editor(ui, te, rect, turned); + // The form field's own logical name; not a visual/preordered label. + let r = r.accessible_name(f.name.clone()); + let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), frame); + turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), caret_placed, font); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -1080,7 +1249,11 @@ pub(crate) fn overlay( // 32000-2 Table 233, Edit); the keys are the text box's then, and the list takes // the pointer. if editable_combo(f) { - combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget }); + combo_editor( + ctx, + view, + ComboEditor { form, field: f, widget: w, rect, frame: TextFrame::of(&xf, info, page, w, rect), zoom, index: focus.widget }, + ); } let mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default(); let list = DropDownList { view: view.view_key(), field: f, widget: w, rect, zoom, keys: !editable_combo(f) }; @@ -1398,6 +1571,7 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: #[cfg(test)] mod tests { use super::*; + use egui::pos2; use std::cell::Cell; /// A draft as the editor opens it on a field holding `value`. @@ -1455,4 +1629,42 @@ mod tests { assert_eq!(bounded_line_width(10.0, measure), 30.0, "below it, at the size itself"); assert_eq!(largest.get(), MAX_SCREEN_FONT); } + + fn near(a: egui::Pos2, b: egui::Pos2) -> bool { + (a - b).length() < 1e-3 + } + + /// #4296: a tall field turned 90 degrees counterclockwise lays its text out in a box as wide + /// as the field is tall, and that box's top-left corner lands on the field's bottom-left, so + /// the text runs up the screen. + #[test] + fn a_quarter_turned_field_runs_its_text_up_the_screen() { + let field = Rect::from_min_max(pos2(100.0, 100.0), pos2(120.0, 260.0)); + let frame = TextFrame::new(field, 90, 0, 0); + assert_eq!(frame.turn, 270); + assert_eq!(frame.upright.size(), egui::vec2(160.0, 20.0)); + assert!(near(frame.on_screen(frame.upright.left_top()), field.left_bottom()), "{:?}", frame.on_screen(frame.upright.left_top())); + assert!(near(frame.on_screen(frame.upright.right_top()), field.left_top())); + for p in [pos2(105.0, 110.0), pos2(119.0, 250.0)] { + assert!(near(frame.on_screen(frame.in_box(p)), p)); + } + // 270 runs down the screen, from the top-right corner. + let down = TextFrame::new(field, 270, 0, 0); + assert_eq!(down.turn, 90); + assert!(near(down.on_screen(down.upright.left_top()), field.right_top())); + } + + /// The page's /Rotate and the view's rotation turn the page clockwise, against /MK /R: a field + /// turned 90 on a page shown a quarter clockwise reads upright, in its own rectangle. + #[test] + fn page_and_view_rotation_turn_against_the_field() { + let field = Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 20.0)); + assert_eq!(TextFrame::new(field, 90, 90, 0), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 90, 0, 90), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 0, 0, 0), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 180, 270, 270).turn, 0); + assert_eq!(TextFrame::new(field, 0, 180, 0).turn, 180); + // Out-of-range input stays a quarter turn. + assert_eq!(TextFrame::new(field, i64::MIN, u16::MAX, u16::MAX).turn % 90, 0); + } } diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index fd74420707..120452adf8 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -161,17 +161,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.add_space(8.0); ui.horizontal(|ui| { let l = ui.label(tl!("Rotation:")); - let before = d.rotation; + // The field keeps its place and size: the rotation turns its content (#4296). craft_ui::ComboBox::from_id_salt("field-rotation") .selected_text(format!("{}°", d.rotation)) .show_ui(ui, |ui| { for r in [0, 90, 180, 270] { - if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) { - // Keep the same center. set_props skips its own swap when the rect changed too. - let [x, y, w, h] = d.position; - let (cx, cy) = (x + w / 2.0, y + h / 2.0); - d.position = [cx - h / 2.0, cy - w / 2.0, h, w]; - } + ui.selectable_value(&mut d.rotation, r, format!("{r}°")); } }) .response diff --git a/crates/ui-egui/src/viewer_prefs.rs b/crates/ui-egui/src/viewer_prefs.rs new file mode 100644 index 0000000000..8b6f023b6d --- /dev/null +++ b/crates/ui-egui/src/viewer_prefs.rs @@ -0,0 +1,374 @@ +//! Viewer preferences that change the window and its interface when a document opens +//! (ISO 32000-2 §12.2, Table 147): `HideToolbar`, `HideMenubar` and `HideWindowUI` hide parts of +//! the interface for that document's tab, and `FitWindow` and `CenterWindow` size and place the +//! window around its first page. +//! +//! The hidden interface is the tab's own and is never saved: another tab shows the usual +//! interface, and the command [`TOGGLE`] (F8) brings it back, so a document can't leave the user +//! without the toolbar, menu or panels. + +use egui::{Pos2, Rect, Vec2, ViewportCommand, vec2}; + +/// The command that shows the interface a document hid, and hides it again. +pub const TOGGLE: &str = "view.toggle_interface"; + +/// Which parts of the interface are hidden in a document's tab. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HiddenInterface { + /// The mode bar's tools: the mode tabs, Save, Print, Properties and the command search, and + /// the floating quick-action bar (`HideToolbar`). + pub toolbar: bool, + /// The menu bar (`HideMenubar`). + pub menubar: bool, + /// The right rail with its page navigation, the side panels and the document's scroll bars, + /// "leaving only the document's contents displayed" (`HideWindowUI`). + pub window_ui: bool, +} + +impl HiddenInterface { + pub const NONE: Self = Self { toolbar: false, menubar: false, window_ui: false }; + pub const ALL: Self = Self { toolbar: true, menubar: true, window_ui: true }; + + /// What a document's viewer preferences ask to hide. + pub fn asked_by(v: &pdfcraft_engine::InitialView) -> Self { + Self { toolbar: v.hide_toolbar, menubar: v.hide_menubar, window_ui: v.hide_window_ui } + } + + pub fn any(self) -> bool { + self.toolbar || self.menubar || self.window_ui + } + + /// The mode bar has nothing left to show: the tools are hidden, and so are the menus, by the + /// document or because the native macOS menu bar (`native_menu`) has them instead. + pub fn mode_bar(self, native_menu: bool) -> bool { + self.toolbar && (self.menubar || native_menu) + } + + /// For the control channel's `ui.state`. + pub fn to_json(self) -> serde_json::Value { + serde_json::json!({ "toolbar": self.toolbar, "menubar": self.menubar, "window_ui": self.window_ui }) + } +} + +/// A tab's interface: what its document asked to hide, and what is hidden now. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TabInterface { + pub asked: HiddenInterface, + pub hidden: HiddenInterface, +} + +impl TabInterface { + /// The interface a document opens with: what it asks to hide, unless the user's preference + /// is to open every document their own way. + pub fn opening(asked: HiddenInterface, ignore_document: bool) -> Self { + if ignore_document { Self::default() } else { Self { asked, hidden: asked } } + } + + /// [`TOGGLE`]: show everything that is hidden; with nothing hidden, hide what the document + /// asked to hide, or everything when it asked for nothing. Returns whether it is now hidden. + pub fn toggle(&mut self) -> bool { + self.hidden = if self.hidden.any() { + HiddenInterface::NONE + } else if self.asked.any() { + self.asked + } else { + HiddenInterface::ALL + }; + self.hidden.any() + } +} + +/// `FitWindow` and `CenterWindow`, waiting for the document's first frame (the size of its page +/// on screen is known only once it is laid out). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PendingWindow { + pub doc: pdfcraft_engine::DocId, + pub fit: bool, + pub center: bool, + /// Frames left to wait for the document to be laid out before giving up. + pub frames_left: u8, +} + +/// What the window looks like now, as the windowing layer reports it (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowNow { + /// The window's content area. + pub inner: Vec2, + /// The window with its frame, where the windowing layer knows it. + pub outer: Option, + pub monitor: Option, + /// The document area inside the window. + pub document_area: Vec2, + pub maximized: bool, + pub fullscreen: bool, +} + +/// The first displayed page (or spread) as the view lays it out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PageNow { + /// Its width and height on screen at 100%, in points. + pub size: Vec2, + /// The space the view keeps around it (gutters, margins, the gap in a spread), in points. + pub margins: Vec2, + /// The view's set zoom (1.0 = 100%); `None` when the view fits the page to the window. + pub zoom: Option, +} + +/// Smallest window the fit makes, so a tiny page doesn't leave a sliver of a window. +const MIN_INNER: Vec2 = vec2(480.0, 360.0); +/// Share of the monitor the fitted window may take, leaving room for the desktop's own bars. +const MONITOR_SHARE: f32 = 0.9; + +/// The viewport commands for `FitWindow` / `CenterWindow`. None for a maximized or full-screen +/// window (resizing it would fight the user's choice), or when the sizes are unusable. +/// +/// `FitWindow` keeps the document area's height and makes the window as wide as the page needs +/// at that height when the view fits the page to the window, and makes the document area the +/// page's size on screen when the view has a set zoom; either way within the monitor. +/// `CenterWindow` centres the window on the monitor when the window is on the monitor at the +/// desktop's origin (the windowing layer reports the monitor's size but not where it is). +pub fn window_commands(now: WindowNow, page: PageNow, fit: bool, center: bool) -> Vec { + let mut out = Vec::new(); + if now.maximized || now.fullscreen || !usable(now.inner) || !usable(now.document_area) { + return out; + } + let chrome = (now.inner - now.document_area).max(Vec2::ZERO); + let mut inner = now.inner; + let zoom_ok = page.zoom.is_none_or(|z| z.is_finite() && z > 0.0); + if fit && zoom_ok && usable(page.size) && page.margins.x.is_finite() && page.margins.y.is_finite() { + let area = match page.zoom { + // Fitted to the window: keep the document area's height, and make the area as wide + // as the page is at the zoom that height gives it. + None => { + let zoom = ((now.document_area.y - page.margins.y) / page.size.y).max(0.0); + vec2(page.size.x * zoom + page.margins.x, now.document_area.y) + } + Some(zoom) => page.size * zoom + page.margins, + }; + let mut target = area + chrome; + if let Some(monitor) = now.monitor.filter(|m| usable(*m)) { + target = target.min(monitor * MONITOR_SHARE); + } + target = target.max(MIN_INNER).round(); + if usable(target) && (target - now.inner).length() >= 1.0 { + out.push(ViewportCommand::InnerSize(target)); + inner = target; + } + } + if center + && let (Some(outer), Some(monitor)) = (now.outer, now.monitor.filter(|m| usable(*m))) + && Rect::from_min_size(Pos2::ZERO, monitor).contains(outer.min) + { + // The frame around the content area stays the size it is. + let frame = (outer.size() - now.inner).max(Vec2::ZERO); + let size = inner + frame; + let at = ((monitor - size) / 2.0).max(Vec2::ZERO).round(); + if at.x.is_finite() && at.y.is_finite() && (at - outer.min.to_vec2()).length() >= 1.0 { + out.push(ViewportCommand::OuterPosition(at.to_pos2())); + } + } + out +} + +fn usable(v: Vec2) -> bool { + v.x.is_finite() && v.y.is_finite() && v.x >= 1.0 && v.y >= 1.0 +} + +impl crate::PdfCraftApp { + /// The window and interface preferences of the document opening in tab `index` + /// ([`Self::apply_initial_view`] has applied its navigation, layout, zoom and page). + /// + /// - `HideToolbar`, `HideMenubar`, `HideWindowUI` hide those parts of the interface in its tab, + /// unless Always use my default zoom and page layout is on, with a notice of the key that + /// brings them back. + /// - A document that shows one page or one spread at a time (`/PageLayout /SinglePage`, + /// `/TwoPageLeft`, `/TwoPageRight`) and names no zoom opens at fit page instead of the + /// default zoom. ISO 32000-2 doesn't say which zoom such a layout opens at. + /// - `FitWindow`, `CenterWindow` wait for the page to be laid out + /// ([`Self::window_preferences_frame`]), and only for the window's only document: a + /// document opened in a tab beside others doesn't resize or move their window. + pub(crate) fn apply_viewer_preferences(&mut self, index: usize, v: &pdfcraft_engine::InitialView, activate: bool) { + use pdfcraft_engine::{InitialLayout as L, Magnification as M}; + let own_view = self.always_default_view; + let only_document = self.views.len() == 1 && !self.combine_tab.open; + let Some(view) = self.views.get_mut(index) else { return }; + view.interface = TabInterface::opening(HiddenInterface::asked_by(v), own_view); + if !own_view && v.magnification == M::Default && matches!(v.layout, L::SinglePage | L::TwoUp | L::TwoUpCoverPage) { + view.fit = crate::canvas::Fit::Page; + } + let (doc, hidden) = (view.id, view.interface.hidden.any()); + if activate && hidden { + self.notify_interface_hidden(); + } + // A browser page can't resize or move its window. + if !cfg!(target_arch = "wasm32") && activate && only_document && !self.full_screen && (v.fit_window || v.center_window) { + self.pending_window = Some(PendingWindow { doc, fit: v.fit_window, center: v.center_window, frames_left: 30 }); + } + } + + /// The parts of the interface hidden in the active tab. + pub fn hidden_interface(&self) -> HiddenInterface { + self.active.and_then(|i| self.views.get(i)).map_or(HiddenInterface::NONE, |v| v.interface.hidden) + } + + /// [`TOGGLE`] in the active tab. + pub(crate) fn toggle_interface(&mut self) { + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + if view.interface.toggle() { + self.notify_interface_hidden(); + } + } + + fn notify_interface_hidden(&mut self) { + let key = pdfcraft_engine::commands::command(TOGGLE).and_then(|c| c.shortcut).map(|s| s.label(cfg!(target_os = "macos"))).unwrap_or_default(); + self.notify_fmt("This document hides the toolbars and panels. Press {key} to show them.", &[("key", &key)]); + } + + /// `FitWindow` and `CenterWindow` for the document just opened, once its first page is laid + /// out. Dropped when another tab shows first, when another tab has opened behind it (it is no + /// longer the window's only document), or after a few frames without a layout. + pub(crate) fn window_preferences_frame(&mut self, ctx: &egui::Context) { + let Some(mut pending) = self.pending_window.take() else { return }; + let Some((index, id)) = self.active_ids() else { return }; + if id != pending.doc || self.views.len() != 1 || self.combine_tab.open { + return; + } + let Some(view) = self.views.get(index) else { return }; + let area = view.viewport_rect(); + let page = self.session.get(id).and_then(|d| view.first_page_now(&d.info)); + let Some(page) = page.filter(|_| area.is_positive()) else { + if let Some(left) = pending.frames_left.checked_sub(1) { + pending.frames_left = left; + self.pending_window = Some(pending); + ctx.request_repaint(); + } + return; + }; + let content = ctx.content_rect().size(); + let now = ctx.input(|i| { + let vp = i.viewport(); + WindowNow { + inner: vp.inner_rect.map_or(content, |r| r.size()), + outer: vp.outer_rect, + monitor: vp.monitor_size, + document_area: area.size(), + maximized: vp.maximized.unwrap_or(false), + fullscreen: vp.fullscreen.unwrap_or(false), + } + }); + for command in window_commands(now, page, pending.fit, pending.center) { + ctx.send_viewport_cmd(command); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn now() -> WindowNow { + WindowNow { + inner: vec2(1400.0, 900.0), + outer: Some(Rect::from_min_size(Pos2::new(50.0, 40.0), vec2(1400.0, 930.0))), + monitor: Some(vec2(1920.0, 1080.0)), + document_area: vec2(1300.0, 800.0), + maximized: false, + fullscreen: false, + } + } + + const FITTED: PageNow = PageNow { size: vec2(200.0, 300.0), margins: vec2(140.0, 56.0), zoom: None }; + + #[test] + fn a_fitted_page_gets_a_window_of_its_shape() { + // An 800-point-high area fits the 300-point page at 248%: 496 points wide plus margins, + // and the 100 points of interface beside the document area. + let c = window_commands(now(), FITTED, true, false); + assert_eq!(c, vec![ViewportCommand::InnerSize(vec2(736.0, 900.0))]); + } + + #[test] + fn a_set_zoom_gets_the_page_size_within_the_monitor() { + let page = PageNow { size: vec2(400.0, 500.0), margins: vec2(100.0, 100.0), zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(vec2(600.0, 700.0))]); + // Too tall for the monitor: the window stops at the share of the monitor it may take. + let page = PageNow { size: vec2(1000.0, 2000.0), margins: Vec2::ZERO, zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(vec2(1100.0, 972.0))]); + // A tiny page doesn't make a sliver of a window. + let page = PageNow { size: vec2(10.0, 10.0), margins: Vec2::ZERO, zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(MIN_INNER)]); + } + + #[test] + fn a_maximized_or_full_screen_window_is_left_alone() { + assert!(window_commands(WindowNow { maximized: true, ..now() }, FITTED, true, true).is_empty()); + assert!(window_commands(WindowNow { fullscreen: true, ..now() }, FITTED, true, true).is_empty()); + // Nothing asked, nothing done. + assert!(window_commands(now(), FITTED, false, false).is_empty()); + } + + #[test] + fn centring_uses_the_new_size_and_the_frame() { + let c = window_commands(now(), FITTED, true, true); + // 736 wide and 900 + 30 of frame high, on 1920 × 1080. + assert_eq!(c.get(1), Some(&ViewportCommand::OuterPosition(Pos2::new(592.0, 75.0)))); + // Centre alone keeps the size. + let c = window_commands(now(), FITTED, false, true); + assert_eq!(c, vec![ViewportCommand::OuterPosition(Pos2::new(260.0, 75.0))]); + // A window on another monitor (outside the one at the origin) isn't moved. + let away = WindowNow { outer: Some(Rect::from_min_size(Pos2::new(2000.0, 40.0), vec2(1400.0, 930.0))), ..now() }; + assert!(window_commands(away, FITTED, false, true).is_empty()); + // Nor is one whose monitor or frame is unknown. + assert!(window_commands(WindowNow { monitor: None, ..now() }, FITTED, false, true).is_empty()); + assert!(window_commands(WindowNow { outer: None, ..now() }, FITTED, false, true).is_empty()); + } + + #[test] + fn unusable_sizes_do_nothing() { + for bad in [Vec2::ZERO, vec2(f32::NAN, 10.0), vec2(f32::INFINITY, 10.0), vec2(-5.0, 10.0)] { + let page = PageNow { size: bad, ..FITTED }; + assert!(window_commands(now(), page, true, false).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { inner: bad, ..now() }, FITTED, true, true).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { document_area: bad, ..now() }, FITTED, true, true).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { monitor: Some(bad), ..now() }, FITTED, false, true).is_empty(), "{bad:?}"); + } + for zoom in [f32::NAN, 0.0, -1.0, f32::INFINITY] { + assert!(window_commands(now(), PageNow { zoom: Some(zoom), ..FITTED }, true, false).is_empty(), "{zoom}"); + } + let page = PageNow { margins: vec2(f32::NAN, 0.0), ..FITTED }; + assert!(window_commands(now(), page, true, false).is_empty()); + } + + #[test] + fn toggling_restores_and_rehides() { + let asked = HiddenInterface { toolbar: true, ..HiddenInterface::NONE }; + let mut tab = TabInterface::opening(asked, false); + assert_eq!(tab.hidden, asked); + assert!(!tab.toggle()); + assert_eq!(tab.hidden, HiddenInterface::NONE); + assert!(tab.toggle()); + assert_eq!(tab.hidden, asked); + // The user's preference to open documents their own way hides nothing. + let mut own = TabInterface::opening(asked, true); + assert_eq!(own.hidden, HiddenInterface::NONE); + // A tab whose document asked for nothing hides everything, and shows it again. + assert!(own.toggle()); + assert_eq!(own.hidden, HiddenInterface::ALL); + assert!(!own.toggle()); + } + + #[test] + fn the_mode_bar_goes_when_nothing_is_left_on_it() { + let toolbar = HiddenInterface { toolbar: true, ..HiddenInterface::NONE }; + // The in-window menus are still there. + assert!(!toolbar.mode_bar(false)); + // #4378 review: on macOS the native menu bar has the menus, so hiding the tools leaves an + // empty bar, which goes too. + assert!(toolbar.mode_bar(true)); + let both = HiddenInterface { menubar: true, ..toolbar }; + assert!(both.mode_bar(false) && both.mode_bar(true)); + // Menus hidden, tools shown: the bar has the tools. + let menubar = HiddenInterface { menubar: true, ..HiddenInterface::NONE }; + assert!(!menubar.mode_bar(false) && !menubar.mode_bar(true)); + } +} diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 6d46e5dfca..3df48f777e 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -250,6 +250,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.discard_alternate_images, tl!("Discard alternate images"))); ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); + ui.add(widgets::checkbox(&mut s.discard_search_index, tl!("Discard embedded search index"))); ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); ui.add(widgets::checkbox(&mut s.discard_form_actions, tl!("Discard all form submission, import and reset actions"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); @@ -260,6 +261,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo discard_box(ui, &mut d.discard, Hidden::Comments, "Discard all comments, forms and multimedia"); discard_box(ui, &mut d.discard, Hidden::Metadata, "Discard document information and metadata"); discard_box(ui, &mut d.discard, Hidden::Attachments, "Discard all object data (file attachments)"); + ui.add(widgets::checkbox(&mut s.discard_external_refs, tl!("Discard external cross-references"))); discard_box(ui, &mut d.discard, Hidden::PrivateData, "Discard private data of other applications"); discard_box(ui, &mut d.discard, Hidden::HiddenText, "Discard hidden text"); } diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 7ea7a18602..ffed67f50d 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -35,6 +35,10 @@ pub type PrinterSource = std::sync::Arc Vec + Send + /// UI language) unless a test stands in. Called off the GUI thread. pub type OptionsSource = std::sync::Arc Vec + Send + Sync>; +/// What takes a print job in place of the system spooler: a test's stand-in, which gets the +/// print-ready PDF and the job and answers as the spooler would. Called off the GUI thread. +pub type Spooler = std::sync::Arc Result + Send + Sync>; + /// The system spooler's printers, as the desktop app's [`PrinterSource`]. pub fn system_printers() -> PrinterSource { std::sync::Arc::new(spool::printers) @@ -424,7 +428,16 @@ impl PdfCraftApp { } } } - Some(printer) => self.send_to_printer(printer, bytes, self.print_draft.job(&name)), + Some(printer) => { + let mut job = self.print_draft.job(&name); + // CUPS is told which way the sheets run (#2713), unless a driver choice already + // says; Windows' print ticket already is. + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + if let Ok(sheets) = print::layout(&sizes, &settings) { + print::add_cups_orientation(&mut job.options, &sheets); + } + self.send_to_printer(printer, bytes, job) + } None => self.save_print_pdf(&name, bytes), } } @@ -437,6 +450,7 @@ impl PdfCraftApp { fn send_to_printer(&mut self, printer: String, bytes: Vec, job: spool::Job) -> bool { let (tx, rx) = std::sync::mpsc::channel(); let ctx = self.ctx.clone(); + let spooler = self.spooler.clone(); std::thread::spawn(move || { let repaint = || { if let Some(ctx) = &ctx { @@ -444,11 +458,15 @@ impl PdfCraftApp { } }; // The receiver may be gone (the app quit): nothing to report to then. - let result = spool::submit_with_progress(&bytes, &job, &mut |sent, sheets| { - let _ = tx.send(JobNews::Sent(sent, sheets)); - repaint(); - }); - let _ = tx.send(JobNews::Done(result.map_err(|e| e.to_string()))); + let result = match &spooler { + Some(submit) => submit(&bytes, &job), + None => spool::submit_with_progress(&bytes, &job, &mut |sent, sheets| { + let _ = tx.send(JobNews::Sent(sent, sheets)); + repaint(); + }) + .map_err(|e| e.to_string()), + }; + let _ = tx.send(JobNews::Done(result)); repaint(); }); self.print_tasks.jobs.push(PrintJob { printer, sheets: None, news: rx }); diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index c52b2892e0..5fdc7cfda4 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -22,7 +22,7 @@ fn attachments_pdf() -> Vec { [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", INNER.len()).as_bytes(), INNER, b"\nendstream"].concat(), b"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 4 0 R >> >>".to_vec(), b"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_vec(), - b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 6 0 R >> >>".to_vec(), + b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /Desc (Original) /EF << /F 6 0 R >> >>".to_vec(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -44,10 +44,14 @@ fn attachments_pdf() -> Vec { /// The app with [`attachments_pdf`] open and the Attachments panel showing, at 60 frames a /// second so two clicks a frame apart make a double-click. fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| { + harness_with_pdf(attachments_pdf()) +} + +fn harness_with_pdf(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("doc.pdf", None, attachments_pdf()).unwrap(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); app.set_option("panel", "attachments").unwrap(); app }); @@ -55,6 +59,40 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +#[test] +fn submitting_blank_removes_an_existing_whitespace_description() { + let mut pdf = attachments_pdf(); + let desc = pdf.windows(b"(Original)".len()).position(|window| window == b"(Original)").unwrap(); + pdf[desc..desc + b"(Original)".len()].copy_from_slice(b"( )"); + let mut h = harness_with_pdf(pdf); + let doc_id = h.state().views[0].id; + assert!(h.state().session.get(doc_id).unwrap().info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().has_description); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let doc = h.state().session.get(doc_id).unwrap(); + let attachment = doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap(); + assert!(!attachment.has_description); + assert!(doc.dirty); +} + +#[test] +fn submitting_blank_leaves_an_absent_description_untouched() { + let mut h = harness(); + right_click(&mut h, "inner.pdf"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let attachment = doc.info.attachments.iter().find(|a| a.name == "inner.pdf").unwrap(); + assert!(!attachment.has_description); + assert!(!doc.dirty); + assert_eq!(doc.can_undo(), None); +} + /// A folder of its own for one test's saved file, removed afterwards. struct Dir(std::path::PathBuf); @@ -151,6 +189,58 @@ fn the_right_click_menu_opens_saves_and_deletes() { assert!(doc.can_undo().is_some_and(|l| l.starts_with("Delete attachment")), "as one undoable edit"); } +#[test] +fn the_description_can_be_edited_from_the_row_menu() { + let mut h = harness(); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + let description = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Description"); + description.click(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + description.type_text("Edited in the panel"); + h.get_by_label("OK").click(); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + let attachment = doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap(); + assert_eq!(attachment.description.as_deref(), Some("Edited in the panel")); + assert!(doc.can_undo().is_some_and(|label| label == "Edit attachment description")); +} + +#[test] +fn keeping_the_attachment_description_does_not_create_an_edit() { + let mut h = harness(); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + assert_eq!(doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().description.as_deref(), Some("Original")); + assert!(!doc.dirty, "confirming the unchanged description does not dirty the document"); + assert_eq!(doc.can_undo(), None, "confirming the unchanged description does not add undo history"); +} + +#[test] +fn editing_the_document_while_description_dialog_is_open_discards_its_stale_draft() { + let mut h = harness(); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + assert!( + h.state_mut() + .apply_edit(pdfcraft_engine::Edit::EditAttachmentDescription { key: "notes.txt".into(), description: Some("Changed elsewhere".into()) }) + ); + h.get_by_label("OK").click(); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + assert_eq!(doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().description.as_deref(), Some("Changed elsewhere")); + assert_eq!(doc.can_undo(), Some("Edit attachment description")); +} + #[test] fn the_row_buttons_still_take_their_own_clicks() { let dir = Dir::new("buttons"); diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index dd6e7178b0..2dceb7a59a 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -100,6 +100,10 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + if spec.id == "prepress.marks.remove" { + use pdfcraft_engine::{Edit, PrinterMarks}; + app.apply_edit(Edit::AddPrinterMarks { pages: vec![0], settings: PrinterMarks::default(), replace: false }); + } // Copying comments needs a selected comment; pasting needs copied ones. if matches!(spec.id, "comment.copy" | "comment.paste") { let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 40a2da9b44..bb11d83e0c 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -9589,6 +9589,68 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Add printer marks: the dialog adds removable marks around +/// the trim box, widening the page to make room; Remove printer marks takes them off. +#[test] +fn printer_marks_are_added_from_the_dialog_and_removed() { + let mut h = harness(2, |_| {}); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().info.pages[0].width; + assert!(h.state_mut().execute("prepress.marks")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::PrinterMarks)); + h.get_by_label("All marks"); + h.get_by_label("Add").click(); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Add printer marks")); + assert!(doc.marks().contains(&pdfcraft_engine::MarkKind::PrinterMarks)); + // No trim box: the whole page is the trim box, so the page grew 36 pt a side for the marks. + assert!((doc.info.pages[0].width - (before + 72.0)).abs() < 0.5, "{} → {}", before, doc.info.pages[0].width); + assert_eq!(h.state().toast.clone().unwrap().0, "Printer marks added"); + assert!(h.state_mut().execute("prepress.marks.remove")); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert!(!doc.marks().contains(&pdfcraft_engine::MarkKind::PrinterMarks)); + assert_eq!(doc.can_undo(), Some("Remove printer marks")); +} + +/// Print production ▸ Convert colours: the dialog converts the page's colours +/// and says how many; with nothing to convert it says so and changes nothing. +#[test] +fn convert_colours_from_the_dialog() { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let cs = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate( + pdfcraft_cos::Dict::new(), + b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S", + ))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("colours.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + assert!(h.state_mut().execute("prepress.convert_colors")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::ConvertColours)); + h.get_by_label("Grayscale"); + // The mode bar has a "Convert" too: the dialog's button is the last one. + h.query_all_by_label("Convert").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Converted 2 colours and 0 pictures on 1 pages"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Convert colours")); + assert!(h.state_mut().execute("prepress.convert_colors")); + h.run_steps(2); + h.query_all_by_label("Convert").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Nothing on those pages needed converting"); +} + /// Print production ▸ Fix hairlines: the dialog widens zero-width and too-thin /// strokes and says how many; with nothing to fix it says so and changes nothing. #[test] diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 2807f1e2f0..da69bb37c3 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -4,7 +4,7 @@ use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig}; -use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, SaveTarget}; const FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj @@ -2135,6 +2135,361 @@ fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() { std::fs::remove_file(photo).unwrap(); } +/// #2149: the first save after Fill & Sign places a signature asks whether to lock it into the +/// page. Lock and save flattens it; Keep editable keeps it a comment; Don't ask again becomes +/// the preference; the Fill & Sign panel has the switch too, once, and the Sign menu no longer +/// does (review on #2348). +#[test] +fn the_first_save_after_placing_a_signature_asks_to_lock_it() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-signature-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + let signed = |h: &Harness<'static, PdfCraftApp>| { + h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count() + }; + assert_eq!(signed(&h), 1); + // Keep editable: saved, still a comment, and not asked again for this placement. + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Lock your signature into the page?"); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "saved"); + assert_eq!(signed(&h), 1, "still a removable comment"); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "no new signature: no question"); + // A new signature asks again; Lock and save with Don't ask again flattens and remembers. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 200.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(2); + h.get_by_label("Don't ask again").click(); + h.run_steps(1); + h.get_by_label("Lock and save").click(); + h.run_steps(3); + assert_eq!(signed(&h), 0, "locked into the page"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); + assert!(h.state().flatten_fill_sign_on_save, "remembered as the preference"); + // The switch is in the panel's Sign yourself section, once; the Sign menu over the page, + // where it was easy to miss, no longer has it. + h.state_mut().flatten_fill_sign_on_save = false; + // With the signature tool on, the quick tool button opens the Sign menu. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + h.get_by_label("Fill & Sign: Sign").click(); + h.run_steps(2); + h.get_by_label("Use signature"); + assert!(h.query_by_label("Lock into the page when saving").is_none(), "not in the Sign menu"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.query_by_label("Lock into the page when saving").is_none(), "the panel is closed"); + h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("fill_sign"); + h.state_mut().left_open = true; + h.run_steps(3); + h.get_by_label("Use signature"); + assert_eq!(h.query_all_by_label("Lock into the page when saving").count(), 1, "once, in the panel"); + h.get_by_label("Lock into the page when saving").click(); + h.run_steps(2); + assert!(h.state().flatten_fill_sign_on_save); + let _ = std::fs::remove_file(&out); +} + +/// #2348 review (CodeRabbit): answering the lock question doesn't forget the signature; only a +/// save that succeeds does. When the save after the answer fails, the next save asks again. +#[test] +fn a_save_that_fails_after_the_lock_question_asks_again() { + let bad = std::env::temp_dir().join(format!("pdfcraft-no-such-dir-{}", std::process::id())).join("out.pdf"); + let good = std::env::temp_dir().join(format!("pdfcraft-lock-retry-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(bad.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "the save failed"); + // The save after the answer doesn't ask twice in a row, but the failed one left the signature + // unsaved: the next save asks again. + h.state_mut().save_override = Some(good.to_string_lossy().into_owned()); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks again"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "saved this time"); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "saved: no question"); + let _ = std::fs::remove_file(&good); +} + +/// #2348 review (CodeRabbit): File ▸ Revert discards a signature just placed, so the save after it +/// doesn't ask to lock one, even when the saved file holds an earlier Fill & Sign signature. +#[test] +fn reverting_forgets_a_signature_just_placed() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-revert-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + // An earlier signature, saved as a comment: the file keeps a Fill & Sign mark. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + // A new signature, then Revert: the new one is gone, the saved one stays. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 200.0); + h.state_mut().revert_active(); + h.run_steps(3); + let marks = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count(); + assert_eq!(marks, 1, "the saved signature is back, the new one is gone"); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "no signature was placed since the last save: no question"); + let _ = std::fs::remove_file(&out); +} + +/// #2348 review (benandez): right after a signature is placed, a notice offers to lock it into +/// the page now. Lock now flattens it (undoable) without saving, so the save that follows doesn't +/// ask; undoing the lock brings back both the notice and the question. Dismiss hides the notice +/// until the next signature. A digitally signed document, or one whose saves lock anyway, gets no +/// notice. +#[test] +fn placing_a_signature_offers_to_lock_it_now() { + const NOTICE: &str = "Signature added. Others can move it until it's locked."; + let out = std::env::temp_dir().join(format!("pdfcraft-lock-now-{}.pdf", std::process::id())); + let place = |h: &mut Harness<'static, PdfCraftApp>, y: f32| { + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(h, 40.0, y); + h.run_steps(2); + }; + let marks = |h: &Harness<'static, PdfCraftApp>| { + h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count() + }; + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.query_by_label(NOTICE).is_none(), "nothing placed yet"); + place(&mut h, 300.0); + h.get_by_label(NOTICE); + h.get_by_label("Lock now").click(); + h.run_steps(3); + assert_eq!(marks(&h), 0, "locked into the page"); + assert!(h.query_by_label(NOTICE).is_none(), "nothing left to lock"); + assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "locked, not saved"); + // Undo brings the movable signature back, with the notice and the save-time question. + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label(NOTICE); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks again after undoing the lock"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(2); + // Locked again, the save doesn't ask. + h.get_by_label("Lock now").click(); + h.run_steps(3); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "already locked: no question"); + // Dismiss hides it until the next signature. + place(&mut h, 200.0); + h.get_by_label(NOTICE); + h.get_by_label("Dismiss").click(); + h.run_steps(2); + assert!(h.query_by_label(NOTICE).is_none(), "dismissed"); + place(&mut h, 100.0); + h.get_by_label(NOTICE); + // Once saved, a signature kept as a comment is no longer just placed: no notice. + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(marks(&h) > 0, "still a comment"); + assert!(h.query_by_label(NOTICE).is_none(), "saved: nothing just placed"); + place(&mut h, 50.0); + h.get_by_label(NOTICE); + // Saves that lock anyway need no offer. + h.state_mut().flatten_fill_sign_on_save = true; + h.run_steps(2); + assert!(h.query_by_label(NOTICE).is_none(), "saving locks it anyway"); + let _ = std::fs::remove_file(&out); + + // Digitally signed: no notice, as locking would change the document after signing. + let mut doc = { + let mut h = harness(); + let app = h.state_mut(); + let id = app.views[0].id; + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..Default::default() }; + app.session.sign(id, &digital_id, opts).expect("signs") + }; + let mut h = harness_bytes(&std::mem::take(&mut doc)); + place(&mut h, 300.0); + assert_eq!(marks(&h), 1); + assert!(h.query_by_label(NOTICE).is_none(), "a signed document isn't offered the lock"); +} + +/// #2348 review: locking takes the placed signature out of the comments, so its selection goes +/// too; a stale (page, index) would point at nothing, or at another comment. +#[test] +fn locking_a_signature_clears_the_comment_selection() { + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + h.run_steps(2); + assert!(h.state().views[0].comments.selected.is_some(), "the placed signature is selected"); + h.get_by_label("Lock now").click(); + h.run_steps(3); + assert!(h.state().views[0].comments.selected.is_none(), "nothing left to select"); +} + +/// #2348 review: when locking the signature fails (here the document's security allows comments +/// but not changes), nothing is lost: the next save asks again, and "Don't ask again" isn't +/// remembered. +#[test] +fn a_lock_that_fails_keeps_the_question() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-fails-{}.pdf", std::process::id())); + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(FIXTURE.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + // Everything but bit 4, modifying the document. + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let mut h = harness_bytes(&pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + let signed = |h: &Harness<'static, PdfCraftApp>| { + h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count() + }; + assert_eq!(signed(&h), 1, "comments are allowed"); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Don't ask again").click(); + h.run_steps(1); + h.get_by_label("Lock and save").click(); + h.run_steps(3); + assert_eq!(signed(&h), 1, "changes aren't allowed: still a comment"); + assert!(!h.state().flatten_fill_sign_on_save, "the failed answer isn't remembered"); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "the next save asks again"); + h.run_steps(2); + h.get_by_label("Lock your signature into the page?"); + let _ = std::fs::remove_file(&out); +} + +/// #2348 review: the lock question stays out of the way where it must. Answering it on a file +/// that changed on disk leaves the changed-on-disk question showing (it was closed with the lock +/// question, so the save silently didn't happen); a signature placed and then undone asks +/// nothing; and a digitally signed document isn't asked, since locking would change it after +/// signing. +#[test] +fn the_lock_question_is_skipped_or_kept_where_it_must_be() { + let place = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(h, 40.0, 300.0); + }; + // A file that changed on disk. + let path = std::env::temp_dir().join(format!("pdfcraft-lock-changed-{}.pdf", std::process::id())); + std::fs::write(&path, FIXTURE).unwrap(); + let mut h = harness_bytes(FIXTURE); + h.state_mut().close_tab(0); + h.state_mut().open_path(&path.to_string_lossy()); + for _ in 0..100 { + if !h.state().views.is_empty() { + break; + } + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(3); + place(&mut h); + std::fs::write(&path, [FIXTURE, b"\n% rewritten elsewhere\n".as_slice()].concat()).unwrap(); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::OverwriteChanged), "the changed-on-disk question shows"); + let _ = std::fs::remove_file(&path); + + // Placed, then undone: nothing to lock, so the save just saves. + let out = std::env::temp_dir().join(format!("pdfcraft-lock-undone-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + place(&mut h); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(2); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "no question after the signature was undone"); + let _ = std::fs::remove_file(&out); + + // Digitally signed: never asked. + let mut doc = { + let mut h = harness(); + let app = h.state_mut(); + let id = app.views[0].id; + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..Default::default() }; + app.session.sign(id, &digital_id, opts).expect("signs") + }; + let mut h = harness_bytes(&std::mem::take(&mut doc)); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + place(&mut h); + let id = h.state().views[0].id; + assert!(h.state().session.get(id).unwrap().is_signed()); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "a signed document isn't asked"); + let _ = std::fs::remove_file(&out); +} + +/// #2149: Cancel saves nothing, and Keep editable with Don't ask again turns the question off. +#[test] +fn cancelling_the_lock_question_saves_nothing() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-cancel-{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&out); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(!out.exists() && h.state().session.get(h.state().views[0].id).unwrap().dirty, "nothing saved"); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asked again"); + h.run_steps(2); + h.get_by_label("Don't ask again").click(); + h.run_steps(1); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(out.exists() && !h.state().ask_to_lock_signatures); + let restored = { + let mut a = PdfCraftApp::new(); + a.restore(&h.state().persist()); + a.ask_to_lock_signatures + }; + assert!(!restored, "kept with the settings"); + let _ = std::fs::remove_file(&out); +} + /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 5a45b4f3e5..ff302aa3c4 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -215,6 +215,34 @@ fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); } +/// #4296: a field turned a quarter (`/MK /R` 90) is typed into where it is, with its text running +/// up the field as the page draws it: a click near the field's top puts the caret at the end of +/// the text, where the turned text ends, not at its start. +#[test] +fn a_turned_field_places_the_caret_along_its_turned_text() { + let mut h = harness(); + // Tall and narrow, with room up it for the text. + let props = pdfcraft_engine::FieldProps { rect: Some((0, [240.0, 20.0, 260.0, 170.0])), font_size: Some(10.0), ..Default::default() }; + set_city(&mut h, props); + set_city(&mut h, pdfcraft_engine::FieldProps { rotation: Some((0, 90)), ..Default::default() }); + click_field(&mut h, "city", 0); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(2); + let field = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "city").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0)); + h.event(egui::Event::Text("X".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "city"), ["ParisX"]); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); @@ -1804,6 +1832,45 @@ fn an_editable_combo_box_takes_a_typed_value() { let _ = std::fs::remove_dir_all(&dir); } +/// #4296: an editable combo box turned a quarter (`/MK /R` 90, ISO 32000-2 Table 192) is typed +/// into as a turned text field is: a click near the field's top puts the caret at the end of the +/// text, where the text running up the field ends, not at its start. +#[test] +fn a_turned_editable_combo_box_places_the_caret_along_its_turned_text() { + let pdf = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [240 20 260 170] /MK << /R 90 >> /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("turned-combo.pdf", None, pdf.clone()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + click_field(&mut h, "fruit", 0); + assert_eq!(focused(&h), Some(("fruit".into(), 0))); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(2); + let field = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "fruit").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0)); + h.event(egui::Event::Text("X".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["ParisX"]); +} + /// A combo box without the Edit flag is a drop-down list only: typing changes nothing. #[test] fn a_combo_box_without_edit_takes_no_typing() { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 1d848fc8e6..42126e316e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod pdf_links; mod pickers; mod pinned_tools; mod pointer; +mod portfolio; mod preferences_layout; mod prepare; mod print; @@ -75,6 +76,7 @@ mod reload; mod scan_clean; mod script_noop; mod scroll_prefetch; +mod search_position; mod side_panels; mod signing; mod split_view; @@ -91,4 +93,5 @@ mod ui; mod updates; mod view; mod viewer; +mod viewer_prefs; mod xfa; diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs new file mode 100644 index 0000000000..3bb9b16d59 --- /dev/null +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -0,0 +1,277 @@ +//! A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6) opens on its documents, not on the cover +//! sheet its pages are (#1652): the document `/D` names opens in a tab of its own, and the +//! portfolio's tab shows its file list even with the right panel closed. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::{PdfCraftApp, RightPanel}; + +/// A one-page PDF. +const PAGE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A one-page PDF with a comment on its page. +const NOTED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /Contents (Sign here) /T (HR) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// [`PAGE`] encrypted, opening with the password `pw`. +fn locked() -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(PAGE.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "pw", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +/// A one-page portfolio of `files` (name, bytes), with `catalog` (its `/Collection` and any other +/// entries) in its catalog. +fn portfolio(catalog: &str, files: &[(&str, &[u8])]) -> Vec { + let names: String = files.iter().enumerate().map(|(i, (name, _))| format!("({name}) {} 0 R ", 5 + 2 * i)).collect(); + let mut objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} /Names << /EmbeddedFiles << /Names [{names}] >> >> >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(), + ]; + for (i, (name, data)) in files.iter().enumerate() { + objs.push([format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat()); + objs.push(format!("<< /Type /Filespec /F ({name}) /UF ({name}) /EF << /F {} 0 R >> >>", 4 + 2 * i).into_bytes()); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// The app with `pdf` opened as `portfolio.pdf`, the reader keeping the right panel closed or not. +fn opened(pdf: Vec, panel_closed: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.right_panel_closed = panel_closed; + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app + }); + h.run_steps(4); + h +} + +/// The open tabs' names, and the active one's. +fn tabs(h: &Harness<'static, PdfCraftApp>) -> (Vec, Option) { + let s = h.state(); + let name = |i: usize| s.views.get(i).and_then(|v| s.session.get(v.id)).map(|d| d.name.clone()); + ((0..s.views.len()).filter_map(name).collect(), s.active.and_then(name)) +} + +/// Make tab `index` the active one. +fn select(h: &mut Harness<'static, PdfCraftApp>, index: usize) { + h.state_mut().active = Some(index); + h.run_steps(2); +} + +/// The initial document opens in front, and the portfolio's tab lists its files whatever the +/// reader's panel preference, giving the panel back when left. +#[test] +fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed() { + let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /T >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); + // The document `/D` names is presented, in a tab of its own, with the reader's own panels. + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "form.pdf".into()], Some("form.pdf".into()))); + assert_eq!(h.state().right, None); + // The portfolio's tab shows its files, without changing the reader's preference. + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + assert!(h.state().right_panel_closed); + // Leaving it gives back the closed panel; coming back shows the files again. + select(&mut h, 1); + assert_eq!(h.state().right, None); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + // The reader can still close the list on the portfolio's tab. + h.state_mut().choose_right_panel(None); + h.run_steps(2); + assert_eq!(h.state().right, None); + // A panel the reader had open comes back once they leave the portfolio's tab. + select(&mut h, 1); + h.state_mut().choose_right_panel(Some(RightPanel::Bookmarks)); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + select(&mut h, 1); + assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); +} + +/// The initial document opens on its own panels, not on the portfolio's file list. +#[test] +fn the_initial_document_opens_without_the_portfolios_file_list() { + // The portfolio asks for the Attachments panel too (`/PageMode /UseAttachments`), and the + // reader keeps the right panel open: the document it presents opens with the panels any other + // would, here on its comments, not with the portfolio's file list. + let mut h = opened(portfolio("/PageMode /UseAttachments /Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", NOTED)]), false); + assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf")); + assert_eq!(h.state().right, Some(RightPanel::Comments)); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + +/// A file opened together with a portfolio, after it, stays in front. +#[test] +fn a_file_opened_after_the_portfolio_keeps_the_front() { + // Two files opened at once, the portfolio first: the second stays in front, and the + // portfolio's initial document waits for the reader, whose list shows on the portfolio's tab. + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.right_panel_closed = true; + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + app + }); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "other.pdf".into()], Some("other.pdf".into()))); + assert_eq!(h.state().right, None); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + +/// A password asked for a file opened together with the portfolio stays asked: the initial +/// document waits for the answer, then gives way to that file, or opens once the prompt is +/// cancelled. +#[test] +fn a_password_asked_for_a_file_opened_with_the_portfolio_stays_asked() { + for answer in [Some("pw".to_string()), None] { + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state().password_prompt.is_some(), "the prompt for locked.pdf is still up"); + assert_eq!(tabs(&h).0, ["portfolio.pdf"]); + let opened = if answer.is_some() { "locked.pdf" } else { "form.pdf" }; + // The answered file opens in the background worker. + h.state_mut().submit_password(answer); + crate::support::wait_until(&mut h, "a second tab", |app| app.views.len() == 2); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), opened.into()], Some(opened.into()))); + } +} + +/// An attachment deleted while the initial document waits (here for a password asked for a file +/// opened with the portfolio) doesn't change which document opens. +#[test] +fn a_deleted_attachment_doesnt_change_which_document_opens() { + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeleteAttachment { key: "intro.pdf".into() })); + h.state_mut().submit_password(None); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "form.pdf".into()], Some("form.pdf".into()))); +} + +/// Leaving the portfolio's tab in Read mode: Read mode gives back the panel the file list +/// replaced, not the list, on the tab that is active when it ends. +#[test] +fn read_mode_gives_back_the_panel_the_file_list_replaced() { + let mut h = opened(portfolio("/Collection << /View /D >>", &[("form.pdf", PAGE)]), true); + h.state_mut().open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + h.run_steps(2); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(2); + assert_eq!(h.state().right, None); + select(&mut h, 1); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(2); + assert_eq!(h.state().right, None, "the panel was closed before the list showed"); +} + +/// The initial document is a PDF by its bytes, not its name. +#[test] +fn an_initial_pdf_named_without_pdf_opens() { + let h = opened(portfolio("/Collection << /D (Form) >>", &[("Form", PAGE)]), true); + let (names, active) = tabs(&h); + assert_eq!(names.len(), 2, "{names:?}"); + assert_ne!(active.as_deref(), Some("portfolio.pdf")); +} + +/// A panel the reader picks in Read mode on the portfolio's tab stays when they switch tabs. +#[test] +fn a_panel_picked_in_read_mode_stays_when_the_tab_changes() { + let mut h = opened(portfolio("/Collection << /View /D >>", &[("form.pdf", PAGE)]), true); + h.state_mut().open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + h.run_steps(2); + select(&mut h, 0); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(2); + h.state_mut().choose_right_panel(Some(RightPanel::Attachments)); + select(&mut h, 1); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + +/// `/View /H` presents the initial document but leaves the file list to the reader. +#[test] +fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { + let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /H >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); + assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf")); + select(&mut h, 0); + assert_eq!(h.state().right, None, "/View /H: the list shows when the reader asks for it"); +} + +/// Without a PDF to present, the portfolio stays in front with its files listed. +#[test] +fn a_portfolio_without_an_initial_document_shows_its_files_over_the_cover() { + // Without `/D` the cover is the initial document; a `/D` naming a file that isn't a PDF + // opens nothing either. + for collection in ["/Collection << /View /D >>", "/Collection << /D (notes.txt) >>"] { + let h = opened(portfolio(collection, &[("notes.txt", b"hello"), ("form.pdf", PAGE)]), true); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into()], Some("portfolio.pdf".into())), "{collection}"); + assert_eq!(h.state().right, Some(RightPanel::Attachments), "{collection}"); + } +} + +/// A portfolio nested in the initial document doesn't open a chain of tabs. +#[test] +fn a_portfolio_inside_the_initial_document_waits_for_the_reader() { + // The initial document is itself a portfolio presenting a third file: opening the outer + // one opens one tab, not a chain of them. + let inner = portfolio("/Collection << /D (deep.pdf) >>", &[("deep.pdf", PAGE)]); + let h = opened(portfolio("/Collection << /D (inner.pdf) >>", &[("inner.pdf", &inner)]), true); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "inner.pdf".into()], Some("inner.pdf".into()))); + // Its own tab still shows its files. + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 50845e064a..3d9c8e968f 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -450,17 +450,21 @@ fn position_tab_rotates_a_field() { assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit"); h.get_by_label("Position").click(); h.run_steps(2); - h.get_by_label("Rotation:"); - h.state_mut().field_props.as_mut().unwrap().rotation = 90; - assert!(h.state().field_props.as_ref().unwrap().props().is_some()); + // Picked from the list, as a person does. + h.get_by_label("Rotation:").click(); + h.run_steps(2); + h.get_by_label("90°").click(); + h.run_steps(2); + let draft = h.state().field_props.as_ref().unwrap().clone(); + assert_eq!(draft.rotation, 90); + let props = draft.props().expect("a new rotation is an edit"); + assert!(props.rect.is_none(), "picking a rotation moved the field: {:?}", props.rect); h.get_by_label("OK").click(); h.run_steps(3); let turned = widget(&h); assert_eq!(turned.rotation, 90); - let [x0, y0, x1, y1] = before.rect; - let (cx, cy, w, ht) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, x1 - x0, y1 - y0); - let expect = [cx - ht / 2.0, cy - w / 2.0, cx + ht / 2.0, cy + w / 2.0]; - assert!(turned.rect.iter().zip(expect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {expect:?}", turned.rect); + // The field stays where it was placed; only its content turns (#4296). + assert!(turned.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} moved from {:?}", turned.rect, before.rect); h.state_mut().execute("edit.undo"); h.run_steps(3); let back = widget(&h); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 3e0fec7a6e..0eae20289a 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -264,6 +264,67 @@ fn a_failed_save_as_pdf_keeps_the_dialog_open() { assert!(toast.contains("Could not save"), "the user is told why: {toast:?}"); } +/// #2713, #3739: Print to a CUPS queue tells it when every sheet runs landscape, so a +/// landscape sheet (an A4 landscape page, or a 6 × 4 label, laid out on Letter) is turned onto +/// the portrait paper instead of printed unrotated at 100% and cut off at the paper's edge. A +/// driver choice for the orientation stands and isn't sent twice. +#[test] +fn printing_landscape_sheets_tells_cups_to_turn_them() { + use pdfcraft_engine::print::spool; + use std::sync::{Arc, Mutex}; + let s = pdfcraft_engine::Session::new(); + let bytes = s.create_blank(842.0, 595.0, 2).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("landscape.pdf", None, bytes.to_vec()).unwrap(); + app + }); + h.run_steps(4); + let jobs: Arc>> = Arc::default(); + let sent = Arc::clone(&jobs); + h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &spool::Job| { + sent.lock().unwrap().push(job.clone()); + Ok(String::new()) + })); + // Print to the queue; `choose` sets the driver's choices once the dialog has its printer. + let print = |h: &mut Harness<'static, PdfCraftApp>, choose: &dyn Fn(&mut pdfcraft_ui_egui::PrintDraft)| -> spool::Job { + let before = jobs.lock().unwrap().len(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.printer = Some("Test_Queue".into()); + h.run_steps(2); + choose(&mut h.state_mut().print_draft); + h.run_steps(1); + h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click(); + for _ in 0..400 { + h.run_steps(1); + if jobs.lock().unwrap().len() > before { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + jobs.lock().unwrap().last().cloned().expect("the job reached the spooler") + }; + let orientation = |job: &spool::Job| job.options.iter().filter(|(k, _)| k == "orientation-requested").map(|(_, v)| v.clone()).collect::>(); + // Fit on Letter with Auto orientation lays both pages out on landscape sheets. + let job = print(&mut h, &|_| {}); + assert_eq!(orientation(&job), if cfg!(windows) { vec![] } else { vec!["4".to_string()] }, "{:?}", job.options); + // A driver choice for the orientation goes instead, once. + let job = print(&mut h, &|d| { + let choice = spool::PrinterOption { + key: "orientation-requested".into(), + label: "Orientation".into(), + group: "General".into(), + choices: vec![("4".into(), "Landscape".into()), ("5".into(), "Reverse landscape".into())], + default: "4".into(), + }; + d.driver_options = Some(("Test_Queue".into(), vec![choice])); + d.driver_choices.insert("orientation-requested".into(), "5".into()); + }); + assert_eq!(orientation(&job), ["5"], "{:?}", job.options); +} + /// Properties… shows the printer driver's own options (CUPS) and sends only those changed. #[test] fn print_dialog_shows_the_printer_driver_options() { diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index ccb3a267ef..780b978a2c 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -676,3 +676,116 @@ fn removing_hidden_information_and_sanitizing() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Sanitize document")); } + +/// A one-page PDF showing "Codename BLUEFIN report", with bookmarks "Bluefin plans" and +/// "Annual figures". +fn bookmarked_pdf() -> Vec { + let content = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Outlines /First 7 0 R /Last 8 0 R /Count 2 >>".to_string(), + "<< /Title (Bluefin plans) /Parent 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] >>".to_string(), + "<< /Title (Annual figures) /Parent 6 0 R /Prev 7 0 R /Dest [3 0 R /Fit] >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2539 review: applying redactions says when the removed text is still in a place it leaves +/// as it is (here a bookmark title), in the same notification as the count of marks applied. +#[test] +fn applying_says_where_the_removed_text_is_still_held() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("bluefin.pdf", None, bookmarked_pdf()).unwrap(); + app + }); + crate::support::settle(&mut h); + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + // Redact pages ▸ current page, then Redact all ▸ Apply. + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label("Redact all").click(); + h.run_steps(3); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!( + toast, + "Applied 1 redaction mark. Save to remove the content from the file. The redacted text also appears in 1 bookmark title, which redaction doesn't change. Remove hidden information removes them." + ); +} + +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A, says that the identification was written +/// back, in the same notification as the count of marks applied. +#[test] +fn applying_says_the_pdfa_identification_was_written_back() { + let xmp = r#"Bluefin report"#; + let content = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pdfa.pdf", None, bytes).unwrap(); + app + }); + crate::support::settle(&mut h); + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label("Redact all").click(); + h.run_steps(3); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!( + toast, + "Applied 1 redaction mark. Save to remove the content from the file. The document's XMP metadata held the redacted text and was removed. Its PDF/A identification was written back on its own." + ); +} diff --git a/crates/ui-egui/tests/it/search_position.rs b/crates/ui-egui/tests/it/search_position.rs new file mode 100644 index 0000000000..9215d997e5 --- /dev/null +++ b/crates/ui-egui/tests/it/search_position.rs @@ -0,0 +1,348 @@ +//! Find goes to where the match is (#3530): after zooming in or out, every step to a match (⌘G, +//! ⇧⌘G, a click in the Search panel's results) leaves the highlighted match on screen, near the +//! middle of the window, on pages larger and smaller than the window, and also when a zoom and a +//! step land in the same frame. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +/// The word searched for, placed (x, y in PDF user space) on each page. +const NEEDLE: &str = "needle"; + +/// A page size, or a point on a page, in points. +type Size = (u32, u32); +type Point = (u32, u32); + +/// Page sizes (points) and where on each page the needle is: the corners, the middle and the +/// edges of large pages, as well as a small page that fits the window. +fn pages() -> Vec<(Size, Vec)> { + vec![ + ((612, 792), vec![(60, 720), (480, 60)]), + ((1700, 2400), vec![(40, 2340), (1560, 2340), (820, 1200), (40, 40), (1560, 40)]), + ((2400, 1200), vec![(2300, 1140), (60, 600), (1200, 60)]), + ((612, 792), vec![(300, 400)]), + ] +} + +/// A PDF of [`pages`]: lines of filler text down every page, and the needles. +fn fixture() -> Vec { + let pages = pages(); + let n = pages.len(); + let font = 3 + 2 * n; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))]; + for (i, ((w, h), needles)) in pages.iter().enumerate() { + let mut body = String::from("BT /F1 9 Tf 11 TL"); + // Filler text a needle search must step over; it never contains the needle. + body.push_str(&format!(" 20 {} Td", h.saturating_sub(100))); + for line in 0..(h.saturating_sub(200) / 11) { + body.push_str(&format!(" (filler line {line} of the quick brown fox jumps over the lazy dog) '")); + } + body.push_str(" ET"); + for (x, y) in needles { + body.push_str(&format!(" BT /F1 14 Tf {x} {y} Td ({NEEDLE}) Tj ET")); + } + objs.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents {} 0 R /Resources << /Font << /F1 {font} 0 R >> >> >>", + 4 + 2 * i + )); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn needles() -> usize { + pages().iter().map(|(_, n)| n.len()).sum() +} + +/// The app with the fixture open and a search for the needle that has found every match. +fn harness() -> Harness<'static, PdfCraftApp> { + harness_dt(1.0 / 4.0) +} + +/// [`harness`] with frames `dt` seconds apart. +fn harness_dt(dt: f32) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(dt).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("search.pdf", None, fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(4); + settle(&mut h); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = NEEDLE.into(); + h.state_mut().views[0].rerun_find(); + // Find runs in the background: wait by a deadline, not a frame count. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline && h.state().views[0].find.as_ref().unwrap().matches.len() < needles() { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), needles(), "every needle is found"); + settle(&mut h); + h +} + +/// Run frames until rendering has finished and the layout holds still. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// Fail unless the current match is drawn inside the window, and, where the page lets the view +/// scroll that far, near its middle (#3530 asks for the middle, as Acrobat does). +fn assert_match_shown(h: &Harness<'static, PdfCraftApp>, what: &str) { + let v = &h.state().views[0]; + let f = v.find.as_ref().unwrap(); + let i = f.current.unwrap_or_else(|| panic!("{what}: no current match")); + let (page, range) = f.matches[i].clone(); + let window = v.viewport_rect(); + let at = |g: usize| v.glyph_screen_pos(page, g).unwrap_or_else(|| panic!("{what}: match {i} (page {}) is not on screen", page + 1)); + let (first, last) = (at(range.start), at(range.end - 1)); + // At the highest zooms one word is wider than the window: then its middle is what shows. + let ends = if last.x - first.x < window.width() * 0.8 { vec![first, last] } else { vec![first.lerp(last, 0.5)] }; + for p in ends { + assert!(window.contains(p), "{what}: match {i} (page {}) at {p:?} is outside the window {window:?} (zoom {})", page + 1, v.zoom); + } + let Some(page_rect) = v.page_screen_rect(page) else { return }; + let centre = first.lerp(last, 0.5); + // Glyph centres, not the match's box: their middle is off the box's by up to half a glyph. + let slack = 4.0 + 0.25 * (last.x - first.x).abs(); + // Off the middle only as far as the content's ends stop the scroll. + let room_x = (page_rect.left() < window.left() - 1.0 || centre.x > window.center().x) + && (page_rect.right() > window.right() + 1.0 || centre.x < window.center().x); + let room_y = (page_rect.top() < window.top() - 1.0 || centre.y > window.center().y) + && (page_rect.bottom() > window.bottom() + 1.0 || centre.y < window.center().y); + if room_x { + assert!((centre.x - window.center().x).abs() < slack, "{what}: match {i} at x {} is not centred in {window:?} (zoom {})", centre.x, v.zoom); + } + if room_y { + assert!((centre.y - window.center().y).abs() < slack, "{what}: match {i} at y {} is not centred in {window:?} (zoom {})", centre.y, v.zoom); + } +} + +/// Step through every match forward and back at each zoom, as ⌘G and ⇧⌘G do. +#[test] +fn every_match_is_shown_after_zooming() { + let mut h = harness(); + let n = needles(); + for zoom in [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0] { + h.state_mut().views[0].set_zoom(zoom); + settle(&mut h); + for forward in [true, false] { + for _ in 0..n { + h.state_mut().views[0].find_step(forward); + settle(&mut h); + assert_match_shown(&h, &format!("zoom {zoom}, step {}", if forward { "forward" } else { "back" })); + } + } + } +} + +/// A click on a result in the Search panel goes to it at any zoom. +#[test] +fn search_panel_results_are_shown_after_zooming() { + let mut h = harness(); + for zoom in [0.33, 3.0, 12.0] { + h.state_mut().views[0].set_zoom(zoom); + settle(&mut h); + for i in (0..needles()).rev() { + h.state_mut().views[0].go_to_match(i); + settle(&mut h); + assert_match_shown(&h, &format!("zoom {zoom}, result {i}")); + } + } +} + +/// Zooms and steps mixed in a fixed pseudo-random order, as a reader zooms in and out between +/// searches; some steps land in the same frame as a zoom, before the zoom has been laid out. +#[test] +fn zooming_and_stepping_in_any_order_shows_the_match() { + let mut h = harness(); + let mut seed: u32 = 0x5eed_3530; + let mut next = |m: u32| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) % m + }; + for round in 0..80 { + let same_frame = next(3) == 0; + match next(4) { + 0 => h.state_mut().views[0].zoom_step(true), + 1 => h.state_mut().views[0].zoom_step(false), + 2 => { + let z = [0.2, 0.75, 1.5, 6.0, 10.0][next(5) as usize]; + h.state_mut().views[0].set_zoom(z); + } + _ => {} + } + if !same_frame { + settle(&mut h); + } + if next(2) == 0 { + let i = next(needles() as u32) as usize; + h.state_mut().views[0].go_to_match(i); + } else { + let forward = next(2) == 0; + h.state_mut().views[0].find_step(forward); + } + settle(&mut h); + assert_match_shown(&h, &format!("round {round}{}", if same_frame { " (zoom in the same frame)" } else { "" })); + } +} + +/// ⌘/Ctrl with the wheel zooms over several frames (egui eases a notch over about a quarter of a +/// second). A step to a match while that zoom is still easing goes to the match, and the rest of +/// the zoom keeps it where it went instead of holding the point under the pointer still. +#[test] +fn a_step_during_a_wheel_zoom_keeps_the_match_on_screen() { + let mut h = harness(); + h.state_mut().set_option("animation_effects", "on").unwrap(); + h.state_mut().views[0].set_zoom(3.0); + settle(&mut h); + let n = needles(); + for (i, dy) in (0..n).zip([60.0, -60.0].into_iter().cycle()) { + // The pointer well away from the middle, where the zoom would otherwise be anchored. + let w = h.state().views[0].viewport_rect(); + h.hover_at(w.min + egui::vec2(80.0, 80.0)); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(2); + let before = h.state().views[0].zoom; + h.state_mut().views[0].find_step(true); + // Let the zoom finish easing, then the rendering. + h.run_steps(60); + settle(&mut h); + assert!((h.state().views[0].zoom - before).abs() > 1e-3, "step {i}: the zoom was still easing when the step came"); + assert_match_shown(&h, &format!("step {i} during a wheel zoom ({dy})")); + } +} + +/// CodeRabbit on #4139: egui spreads a ⌘/Ctrl-wheel notch over about a quarter of a second. A +/// step to a match while that zoom is easing, the pointer then leaving the page view for a few +/// frames and coming back before the zoom has ended: the rest of the zoom still keeps the match in +/// the middle, rather than anchoring on the pointer once it is back. Frames a 60th of a second +/// apart, as on screen; the default quarter-second frames end the zoom before the pointer leaves. +#[test] +fn a_step_during_a_wheel_zoom_keeps_the_match_when_the_pointer_leaves_and_returns() { + for (dy, out) in [(120.0, 2), (240.0, 4), (480.0, 3)] { + let mut h = harness_dt(1.0 / 60.0); + h.state_mut().set_option("animation_effects", "on").unwrap(); + h.state_mut().views[0].set_zoom(3.0); + settle(&mut h); + let w = h.state().views[0].viewport_rect(); + // The pointer well away from the middle, where the zoom would otherwise be anchored. + let corner = w.min + egui::vec2(80.0, 80.0); + h.hover_at(corner); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(2); + h.state_mut().views[0].find_step(true); + h.run_steps(1); + let during = h.state().views[0].zoom; + // Off the page view (over the toolbar above it) while the zoom eases, then back. + h.hover_at(egui::pos2(corner.x, (w.min.y - 20.0).max(1.0))); + h.run_steps(out); + h.hover_at(corner); + h.run_steps(30); + settle(&mut h); + assert!((h.state().views[0].zoom - during).abs() > 1e-3, "{dy}: the zoom was still easing when the pointer left"); + assert_match_shown(&h, &format!("pointer out for {out} frames during a wheel zoom ({dy})")); + } +} + +/// Single page and two-up, scrolling or a page (spread) at a time: each step shows its match. +#[test] +fn every_page_display_shows_the_match_after_zooming() { + use pdfcraft_ui_egui::canvas::PageLayout; + let mut h = harness(); + for (layout, continuous) in [(PageLayout::Single, false), (PageLayout::TwoUp, true), (PageLayout::TwoUp, false)] { + h.state_mut().views[0].set_display(layout, continuous); + for zoom in [0.5, 4.0] { + h.state_mut().views[0].set_zoom(zoom); + settle(&mut h); + for _ in 0..needles() { + h.state_mut().views[0].find_step(true); + settle(&mut h); + assert_match_shown(&h, &format!("{layout:?}, continuous {continuous}, zoom {zoom}")); + } + } + } +} + +/// FredApps on #4139: the match a find step went to is let go as soon as the reader moves the +/// view by hand. After a step, a scroll by the wheel and then a ⌘-wheel zoom: the zoom keeps the +/// point under the pointer, and the view does not jump back to the old match. +#[test] +fn a_scroll_by_hand_after_a_step_lets_the_match_go() { + let mut h = harness(); + h.state_mut().views[0].set_zoom(3.0); + settle(&mut h); + h.state_mut().views[0].find_step(true); + settle(&mut h); + assert_match_shown(&h, "after the step"); + let w = h.state().views[0].viewport_rect(); + let pointer = w.min + egui::vec2(80.0, 80.0); + h.hover_at(pointer); + h.run_steps(1); + // A plain wheel scroll: the reader moves on. + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(4); + settle(&mut h); + // Then a ⌘-wheel zoom with the pointer in the corner. + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 60.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(20); + settle(&mut h); + let v = &h.state().views[0]; + let f = v.find.as_ref().unwrap(); + let (page, range) = f.matches[f.current.unwrap()].clone(); + let centre = v.glyph_screen_pos(page, range.start).map(|a| a.lerp(v.glyph_screen_pos(page, range.end - 1).unwrap_or(a), 0.5)); + assert!( + centre.is_none_or(|c| c.distance(v.viewport_rect().center()) > 100.0), + "the zoom went back to the old match at {centre:?} (window centre {:?})", + v.viewport_rect().center() + ); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index de90573ea1..fa099ed379 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2631,6 +2631,550 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails"); } +/// Three 300×400 pt pages; the second comes in with a one-second Wipe and stays one second +/// (`/Trans`, `/Dur`), the others have neither. +const PRESENTATION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Wipe /D 1 >> /Dur 1 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The presentation document in full screen, frames a tenth of a second apart, every raster in. +fn presenting(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + presenting_pdf(PRESENTATION, options) +} + +/// [`presenting`] with another document. +fn presenting_pdf(pdf: &'static [u8], options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.1).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("slides.pdf", None, pdf.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("animation_effects", "on").unwrap(); + for (k, v) in options { + app.set_option(k, v).unwrap(); + } + app + }); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(4); + assert!(h.state().full_screen); + settle(&mut h); + h +} + +/// Run frames until the renderer has nothing left to deliver (bounded, in real time: the +/// renderer's threads do not follow the harness clock). +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..300 { + h.run_steps(1); + if !h.state().render_pending() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +/// Run frames until `done` holds (bounded), returning the number of frames it took. +fn frames_until(h: &mut Harness<'static, PdfCraftApp>, done: impl Fn(&PdfCraftApp) -> bool) -> usize { + for n in 0..300 { + if done(h.state()) { + return n; + } + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("never happened"); +} + +/// #379: in full screen, a page's /Trans plays when it comes into view (from the page before, +/// once the new one has rendered), and its /Dur turns to the next page once it is up; the last +/// page, with no display time, stays. +#[test] +fn full_screen_plays_a_page_transition_and_honours_the_display_time() { + let mut h = presenting(&[]); + assert!(!h.state().views[0].presenting_a_transition(), "page 1 just shows"); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + // The Wipe is painted, not only timed: once page 2 has rendered, a frame shows a strip of it as + // tall as the page and only part of its width. + let wipe_strip = |h: &Harness<'static, PdfCraftApp>| { + h.state().views[0].page_screen_rect(1).is_some_and(|page| { + h.output().shapes.iter().any(|clipped| { + let egui::Shape::Mesh(mesh) = &clipped.shape else { return false }; + let b = mesh.calc_bounds(); + mesh.texture_id != egui::TextureId::default() + && (b.min.x - page.min.x).abs() < 1.0 + && (b.min.y - page.min.y).abs() < 1.0 + && (b.max.y - page.max.y).abs() < 1.0 + && b.width() > 1.0 + && b.width() < page.width() - 1.0 + }) + }) + }; + let mut steps = 0; + while !wipe_strip(&h) && steps < 300 { + h.run_steps(1); + steps += 1; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(wipe_strip(&h), "the Wipe paints part of page 2 ({steps} frames)"); + // Counted from the first painted strip, when page 2's raster is in and the Wipe is under way: + // the frames spent waiting for the raster don't count toward its second. + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames >= 8, "the Wipe takes its second ({frames} frames of 0.1 s after the first strip)"); + assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); + h.run_steps(5); + assert_eq!(h.state().views[0].current, 1, "half of its display time"); + h.run_steps(7); + assert_eq!(h.state().views[0].current, 2, "its one-second display time is up"); + assert!(!h.state().views[0].presenting_a_transition(), "page 3 has no transition"); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 2, "the last page stays"); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(!h.state().views[0].presenting_a_transition()); +} + +/// The page changes at once where a transition can't or shouldn't play: with the Animation +/// effects preference off, and with two-page spreads. +#[test] +fn full_screen_turns_at_once_without_animations_or_in_spreads() { + for options in [&[("animation_effects", "off")][..], &[("layout", "two-up")][..]] { + let mut h = presenting(options); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(!h.state().views[0].presenting_a_transition(), "{options:?}"); + } +} + +/// In full screen the pointer hides after three still seconds, and shows again when it moves. +#[test] +fn full_screen_hides_a_still_pointer() { + let mut h = presenting(&[]); + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "just moved"); + h.run_steps(32); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); + h.hover_at(centre + egui::vec2(40.0, 0.0)); + h.run_steps(1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "moved again"); +} + +/// In full screen a right-click goes back a page, as a left click goes forward (*Adobe Acrobat +/// Help*, 2019, "Full Screen preferences", p. 69: "Left Click To Go Forward One Page; Right Click +/// To Go Back One Page"), and no context menu opens. +#[test] +fn full_screen_right_click_goes_back_a_page() { + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + h.key_press(Key::ArrowRight); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1); + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: centre, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(6); + assert_eq!(h.state().views[0].current, 0, "a right-click goes back"); + assert!(!egui::Popup::is_any_open(&h.ctx), "no context menu in full screen"); +} + +/// In full screen Enter goes forward a page and Shift+Enter back (*Adobe Acrobat Help*, 2019, +/// "Read a document in Full Screen mode", p. 66: "To go to the previous page, press Shift+Enter"). +#[test] +fn full_screen_shift_enter_goes_back_a_page() { + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + h.key_press(Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1, "Enter goes forward"); + h.key_press_modifiers(Modifiers::SHIFT, Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 0, "Shift+Enter goes back"); +} + +/// A document replaced while full screen plays a transition (a reload) starts afresh: none of the +/// old document's transition, page or timer carries over. +#[test] +fn full_screen_starts_afresh_when_the_document_is_replaced() { + let mut h = presenting(&[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + { + let app = h.state_mut(); + let info = app.session.get(app.views[0].id).unwrap().info.clone(); + app.views[0].document_replaced(&info); + } + assert!(!h.state().views[0].presenting_a_transition(), "the old transition is dropped"); +} + +/// A still pointer hides over a link too: the page's own cursor (the hand over a link) is chosen +/// first, and full screen hides the pointer after it. +#[test] +fn full_screen_hides_a_still_pointer_over_a_link() { + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + // Page 1's link covers x 50–250, y 300–350 of the 300×400 pt page (from its bottom). + let r = rect(&h, 0).expect("page 1 on screen"); + let on_link = egui::pos2(r.left() + r.width() * 150.0 / 300.0, r.top() + r.height() * 75.0 / 400.0); + h.hover_at(on_link); + h.run_steps(1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "just moved, over the link"); + h.run_steps(16); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); +} + +/// A text field on page 1 of three. +const FIELD_PAGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Full screen keeps the pointer active "so that you can click links and open notes" (*Adobe +/// Acrobat Help*, 2019, p. 65): a click on a form field opens it for typing rather than turning +/// the page, and a click beside it still turns. +#[test] +fn full_screen_click_on_a_field_fills_it_and_turns_no_page() { + let mut h = harness(&[]); + h.state_mut().open_bytes("field.pdf", None, FIELD_PAGES.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + // Until page 1's raster is in, full screen veils it and a click turns the page; on a slow + // machine that takes more frames than the six above. + for _ in 0..600 { + if h.state().views[n].presentation_settled() { + break; + } + h.run_steps(1); + } + assert!(h.state().views[n].presentation_settled(), "page 1 is shown"); + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(8); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the field is open for typing"); + click_with(&mut h, egui::pos2(r.center().x, r.top() + 20.0), Modifiers::NONE); + h.run_steps(4); + assert_eq!(h.state().views[n].current, 1, "a click beside the field turns the page"); +} + +/// Shift+Ctrl+Page Down or Down Arrow goes to the last page, and Shift+Ctrl+Page Up or Up Arrow +/// to the first, as Home and End do (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", +/// p. 83), in full screen and out of it. +#[test] +fn shift_ctrl_page_keys_and_arrows_go_to_the_first_and_last_page() { + for full_screen in [false, true] { + let mut h = harness(&[("layout", "single")]); + if full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + let both = Modifiers::COMMAND | Modifiers::SHIFT; + for (k, want) in [(Key::PageDown, 2), (Key::PageUp, 0), (Key::ArrowDown, 2), (Key::ArrowUp, 0)] { + h.key_press_modifiers(both, k); + h.run_steps(6); + assert_eq!(h.state().views[0].current, want, "{k:?}, full screen {full_screen}"); + } + } +} + +/// Two pages, the second with a text field and a five-second Wipe. +const FIELD_BEHIND_TRANSITION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R] /Trans << /S /Wipe /D 5 >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 4 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// #5316 review: while full screen turns to a page (its transition playing, or its raster on the +/// way), the page isn't the one on screen yet, so a click there opens none of its fields; once +/// the page is in, the same click does. +#[test] +fn full_screen_takes_no_page_input_until_the_page_is_on_screen() { + let mut h = presenting_pdf(FIELD_BEHIND_TRANSITION, &[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().views[0].presenting_a_transition(), "the Wipe is still playing"); + assert!(!h.ctx.egui_wants_keyboard_input(), "the field behind the transition isn't opened"); + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + h.run_steps(1); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(4); + assert!(h.ctx.egui_wants_keyboard_input(), "once page 2 is in, the field opens"); +} + +/// [`FIELD_BEHIND_TRANSITION`] with a Wipe of 0.3 s, shorter than a click may be held. +const FIELD_BEHIND_SHORT_TRANSITION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R] /Trans << /S /Wipe /D 0.3 >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 4 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// #5316 review: a press made while full screen turns to a page belongs to nothing on it, through +/// its release: let go on the field just after the transition ends, within a click's time, it +/// still opens nothing. +#[test] +fn full_screen_press_made_during_a_transition_opens_nothing_when_released() { + let mut h = presenting_pdf(FIELD_BEHIND_SHORT_TRANSITION, &[]); + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + h.hover_at(on_field); + h.run_steps(1); + // Page 2 renders ahead: once nothing is left to render, its Wipe starts with the key. + settle(&mut h); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + h.event(egui::Event::PointerButton { pos: on_field, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "pressed while the Wipe plays"); + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames <= 4, "the Wipe ends within a click's time ({frames} frames of 0.1 s)"); + h.event(egui::Event::PointerButton { pos: on_field, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(!h.ctx.egui_wants_keyboard_input(), "released on the field once page 2 is in: nothing opens"); +} + +/// In full screen a double-click on a note opens its pop-up, and turns no page: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). +#[test] +fn full_screen_double_click_on_a_note_opens_it_and_turns_no_page() { + const NOTE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 124 124] /Contents (Hello) /T (A) /F 4 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness_stepping(1.0 / 60.0, &[]); + h.state_mut().open_bytes("note.pdf", None, NOTE.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + // Until the page's raster is in: before it, full screen takes no press on the page. + settle(&mut h); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let p = egui::pos2(r.left() + r.width() * 112.0 / 300.0, r.top() + r.height() * (400.0 - 112.0) / 400.0); + h.hover_at(p); + h.run_steps(2); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); + } + // Past the double-click time, when a turn would have come. + h.run_steps(30); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + // A double-click on a note opens its pop-up window, as in Acrobat (comment.popups). + let note = + h.state().session.get(h.state().views[n].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Text").cloned().expect("the note"); + assert_eq!(note.popup.map(|w| w.open), Some(true), "the note's pop-up is open"); +} + +/// With Animation effects following the system and the system asking for no animations (Windows' +/// Animation effects off, or another system's Reduce motion), full screen changes pages at once +/// and still keeps the page's display time; with the system not asking, the same transition plays. +#[test] +fn full_screen_cuts_transitions_when_the_system_asks_for_no_animations() { + for reduced in [true, false] { + let mut h = presenting(&[("animation_effects", "system")]); + h.state_mut().motion.reduced = reduced; + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().views[0].presenting_a_transition(), !reduced, "system asks for no animations: {reduced}"); + if reduced { + settle(&mut h); + let frames = frames_until(&mut h, |a| a.views[0].current == 2); + assert!(frames <= 20, "page 2's one-second display time still turns it ({frames} frames of 0.1 s)"); + } + } +} + +/// Escape leaves full screen while a transition plays and while a display time counts, and the +/// display time stops with it: the page reached stays. +#[test] +fn escape_leaves_full_screen_mid_transition_and_stops_the_display_time() { + for mid_transition in [true, false] { + let mut h = presenting(&[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + if !mid_transition { + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 1, "page 2's display time is counting"); + } + h.key_press(Key::Escape); + h.run_steps(1); + assert!(!h.state().full_screen, "Escape leaves full screen (mid-transition: {mid_transition})"); + assert!(!h.state().views[0].presenting_a_transition()); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 1, "no page turns by itself after leaving (mid-transition: {mid_transition})"); + } +} + +/// While a page's display time counts, the keys still turn pages at once (ISO 32000-2 §12.4.4.1, +/// NOTE 1: "The user can advance the page manually before the specified time has expired"): +/// → goes on before the time is up, and ← back to a page with no display time, which then stays. +#[test] +fn full_screen_keys_take_over_from_a_display_time() { + let mut h = presenting(&[("animation_effects", "off")]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1, "page 2, with its one-second display time"); + h.key_press(Key::ArrowLeft); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 0, "← goes back before the time is up"); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 0, "page 1 has no display time: it stays"); + h.key_press(Key::ArrowRight); + h.run_steps(2); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 2, "→ goes on before page 2's time is up"); +} + +/// Every way full screen moves: forward (→, Space, Enter, Page Down, a click), back (←, Backspace, +/// Shift+Enter, Page Up, a right-click, a Shift-click), first (Home, Shift+Ctrl+Page Up), last (End, +/// Shift+Ctrl+Page Down) and out (Escape, Ctrl+L). +#[test] +fn full_screen_every_key_and_click_goes_where_it_says() { + #[derive(Clone, Copy, Debug)] + enum Input { + Key(Key, Modifiers), + Click(egui::PointerButton, Modifiers), + } + use Input::{Click, Key as K}; + use egui::PointerButton::{Primary, Secondary}; + let both = Modifiers::COMMAND | Modifiers::SHIFT; + // (input, page before, page after); a page past the end stays where it is. + let moves = [ + (K(Key::ArrowRight, Modifiers::NONE), 0, 1), + (K(Key::Space, Modifiers::NONE), 0, 1), + (K(Key::Enter, Modifiers::NONE), 0, 1), + (K(Key::PageDown, Modifiers::NONE), 0, 1), + (Click(Primary, Modifiers::NONE), 0, 1), + (K(Key::ArrowLeft, Modifiers::NONE), 1, 0), + (K(Key::Backspace, Modifiers::NONE), 1, 0), + (K(Key::Enter, Modifiers::SHIFT), 1, 0), + (K(Key::PageUp, Modifiers::NONE), 1, 0), + (Click(Secondary, Modifiers::NONE), 1, 0), + (Click(Primary, Modifiers::SHIFT), 1, 0), + (K(Key::Home, Modifiers::NONE), 2, 0), + (K(Key::PageUp, both), 2, 0), + (K(Key::End, Modifiers::NONE), 0, 2), + (K(Key::PageDown, both), 0, 2), + ]; + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + for (input, from, to) in moves { + h.state_mut().views[0].go_to_page(from); + h.run_steps(4); + assert_eq!(h.state().views[0].current, from, "{input:?}: on page {}", from + 1); + match input { + K(key, modifiers) => { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + } + Click(button, modifiers) => { + // Clear of page 1's link, which a click would follow instead. + let r = rect(&h, from).expect("the page is on screen"); + let at = egui::pos2(r.center().x, r.bottom() - 20.0); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers }); + } + h.run_steps(6); + } + } + assert_eq!(h.state().views[0].current, to, "{input:?} from page {}", from + 1); + assert!(h.state().full_screen, "{input:?} stays in full screen"); + } + for (key, modifiers) in [(Key::Escape, Modifiers::NONE), (Key::L, Modifiers::COMMAND)] { + if !h.state().full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + assert!(h.state().full_screen); + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + assert!(!h.state().full_screen, "{key:?} leaves full screen"); + } +} + +/// A transition style the spec doesn't name (here `/Spiral`, with a five-second `/D`) is the +/// default, Replace (ISO 32000-2 Table 164, `S`: "Default value: R"): the page changes at once, and +/// its display time still turns it. +#[test] +fn full_screen_cuts_an_unknown_transition_style_and_keeps_its_display_time() { + const SPIRAL: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Spiral /D 5 >> /Dur 1 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = presenting_pdf(SPIRAL, &[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(!h.state().views[0].presenting_a_transition(), "no effect for an unknown style"); + settle(&mut h); + let frames = frames_until(&mut h, |a| a.views[0].current == 2); + assert!(frames <= 20, "its one-second display time turns it ({frames} frames of 0.1 s)"); +} + /// A one-page PDF whose content inflates past the renderer's per-stream limit (#623): a window /// of whitespace the interpreter can never complete, so the page is cut and says so. fn cut_content_pdf() -> Vec { diff --git a/crates/ui-egui/tests/it/viewer_prefs.rs b/crates/ui-egui/tests/it/viewer_prefs.rs new file mode 100644 index 0000000000..781e461668 --- /dev/null +++ b/crates/ui-egui/tests/it/viewer_prefs.rs @@ -0,0 +1,335 @@ +//! Viewer preferences applied when a document opens (ISO 32000-2 §12.2, Table 147): hiding the +//! toolbars, menu and window controls in its tab, with F8 to bring them back; fitting and +//! centring the window; and the zoom a one-page-at-a-time layout opens at. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::canvas::Fit; +use pdfcraft_ui_egui::viewer_prefs::HiddenInterface; + +/// Two 200 × 300 pages; `catalog` is added to the catalog dictionary. +fn fixture(catalog: &str) -> Vec { + let objs = [ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const HIDE_ALL: &str = "/ViewerPreferences << /HideToolbar true /HideMenubar true /HideWindowUI true >>"; + +/// A plain document in the first tab and `files` after it, the last one active. +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static, files: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + setup(&mut app); + for (i, catalog) in files.iter().enumerate() { + app.open_bytes(&format!("{i}.pdf"), None, fixture(catalog)).unwrap(); + } + app + }); + settle(&mut h); + h +} + +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..3000 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); +} + +/// Which of the toolbar (the command search), the menu (the menu bar's File menu) and the window +/// controls (the rail's fit button) are on screen. +fn shown(h: &Harness<'static, PdfCraftApp>) -> (bool, bool, bool) { + ( + h.query_by_label("Find tools and commands").is_some(), + h.query_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some("File")).is_some(), + h.query_by_label("Toggle fit page / fit width").is_some(), + ) +} + +#[test] +fn a_document_that_hides_the_interface_opens_without_it_and_f8_brings_it_back() { + let mut h = harness(|_| {}, &["", HIDE_ALL]); + assert_eq!(shown(&h), (false, false, false), "the toolbar, menu and rail are hidden"); + assert_eq!(h.state().hidden_interface(), HiddenInterface::ALL); + // The quick-action bar is a toolbar too. + assert!(h.query_by_label_contains("Hand (H)").is_none()); + // The tab strip stays: the tabs and Home are still there. + h.get_by_label("Home"); + + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true), "F8 shows them"); + h.get_by_label_contains("Hand (H)"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false), "and hides them again"); + + // Another tab, whose document asks for nothing, has the usual interface. + h.state_mut().active = Some(0); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + h.state_mut().active = Some(1); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false)); + + // The command (palette, menus, automation) does what F8 does. + assert!(h.state_mut().execute("view.toggle_interface")); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); +} + +/// Reloading a file changed on disk keeps the tab's interface: hidden stays hidden, and F8 still +/// hides only what the document asked for. +#[test] +fn a_reloaded_document_keeps_its_interface() { + let mut h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true >>"]); + let reload = |h: &mut Harness<'static, PdfCraftApp>| { + let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone(); + h.state_mut().views[0].document_replaced(&info); + h.run_steps(3); + }; + reload(&mut h); + assert_eq!(shown(&h), (false, true, true), "still hidden after the reload"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + reload(&mut h); + assert_eq!(shown(&h), (true, true, true), "still shown after the reload"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, true, true), "F8 hides only what the document asked for"); +} + +#[test] +fn each_preference_hides_its_own_part() { + let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].viewport_rect().top(); + let mut h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true >>"]); + assert_eq!(shown(&h), (false, true, true), "HideToolbar"); + // Shown and hidden again, F8 hides only what the document asked for. + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, true, true)); + let h = harness(|_| {}, &["/ViewerPreferences << /HideMenubar true >>"]); + assert_eq!(shown(&h), (true, false, true), "HideMenubar"); + let h = harness(|_| {}, &["/ViewerPreferences << /HideWindowUI true >>"]); + assert_eq!(shown(&h), (true, true, false), "HideWindowUI"); + assert!(h.state().hidden_interface().window_ui); + // False is the default, and is nothing to hide. + let h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar false /HideMenubar false /HideWindowUI false >>"]); + assert_eq!(shown(&h), (true, true, true)); + let with_bar = top(&h); + // With both the tools and the menu hidden, the mode bar goes and the document moves up. + let h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true /HideMenubar true >>"]); + assert_eq!(shown(&h), (false, false, true)); + assert!(top(&h) < with_bar - 40.0, "{} against {with_bar}", top(&h)); +} + +#[test] +fn hiding_the_window_controls_leaves_the_document_the_whole_width() { + // The document also asks for the page thumbnails panel, which is hidden with the rest. + let mut h = harness(|_| {}, &["/PageMode /UseThumbs /ViewerPreferences << /HideWindowUI true >>"]); + let width = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].viewport_rect().width(); + assert!(width(&h) > 1390.0, "{}", width(&h)); + h.key_press(egui::Key::F8); + settle(&mut h); + // The rail and the Pages panel are back beside the document. + assert!(width(&h) < 1400.0 - 48.0 - 200.0, "{}", width(&h)); +} + +#[test] +fn the_preference_to_ignore_document_views_keeps_the_interface() { + let h = harness(|app| app.always_default_view = true, &[HIDE_ALL]); + assert_eq!(shown(&h), (true, true, true)); + assert_eq!(h.state().hidden_interface(), HiddenInterface::NONE); +} + +#[test] +fn f8_hides_the_interface_of_any_document_and_shows_it_again() { + let mut h = harness(|_| {}, &[""]); + assert_eq!(shown(&h), (true, true, true)); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false)); + h.get_by_label_contains("Press F8 to show them"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); +} + +#[test] +fn hiding_the_interface_is_never_saved() { + let h = harness(|_| {}, &[HIDE_ALL]); + let settings = h.state().persist(); + assert!(!settings.contains("interface"), "{settings}"); +} + +/// The fit a document with `catalog` opens at, with the user's defaults (fit width). +fn opening_fit(catalog: &str, own_view: bool) -> Fit { + let mut app = PdfCraftApp::new(); + app.always_default_view = own_view; + app.open_bytes("x.pdf", None, fixture(catalog)).unwrap(); + app.views[0].fit +} + +#[test] +fn one_page_at_a_time_opens_at_fit_page() { + let default = opening_fit("", false); + assert_eq!(default, Fit::Width, "the default zoom"); + for layout in ["SinglePage", "TwoPageLeft", "TwoPageRight"] { + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), false), Fit::Page, "{layout}"); + // The user's preference keeps their zoom. + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), true), default, "{layout}"); + } + // Scrolling layouts keep the default zoom. + for layout in ["OneColumn", "TwoColumnLeft", "TwoColumnRight"] { + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), false), default, "{layout}"); + } + // A zoom the document names wins. + assert_eq!(opening_fit("/PageLayout /SinglePage /OpenAction [3 0 R /FitH 300]", false), Fit::Width); + let mut app = PdfCraftApp::new(); + app.open_bytes("x.pdf", None, fixture("/PageLayout /SinglePage /OpenAction [3 0 R /XYZ 0 300 2]")).unwrap(); + assert_eq!((app.views[0].fit, app.views[0].zoom), (Fit::None, 2.0)); +} + +/// Review on #4378: where the reader left off wins over the fit page a one-page-at-a-time +/// layout opens at. +#[test] +fn a_remembered_zoom_wins_over_the_layouts_fit_page() { + let path = std::env::temp_dir().join(format!("pdfcraft-viewer-prefs-last-view-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let mut app = PdfCraftApp::new(); + let json_path = serde_json::to_string(&path).unwrap(); + app.restore(&format!(r#"{{"last_views": [{{"path": {json_path}, "page": 1, "zoom": "62.5%", "display": "single"}}]}}"#)); + assert!(app.restore_last_view); + app.open_bytes("x.pdf", Some(path), fixture("/PageLayout /SinglePage")).unwrap(); + let v = &app.views[0]; + assert_eq!((v.fit, v.zoom, v.current), (Fit::None, 0.625, 1)); +} + +/// The last `InnerSize` and `OuterPosition` asked for over `frames` frames. +fn window_commands(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> (Option, Option) { + let (mut size, mut at) = (None, None); + for _ in 0..frames { + h.run_steps(1); + for c in &h.output().viewport_output[&egui::ViewportId::ROOT].commands { + match c { + egui::ViewportCommand::InnerSize(s) => size = Some(*s), + egui::ViewportCommand::OuterPosition(p) => at = Some(*p), + _ => {} + } + } + } + (size, at) +} + +fn on_monitor(h: &mut Harness<'static, PdfCraftApp>) { + let vp = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + vp.monitor_size = Some(egui::vec2(1920.0, 1080.0)); + vp.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 70.0), egui::vec2(1400.0, 900.0))); + vp.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), egui::vec2(1400.0, 930.0))); +} + +/// A harness whose window is on a 1920 × 1080 monitor, with one document opened in the first +/// frames. +fn windowed(files: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + on_monitor(&mut h); + h.run_steps(2); + for (i, catalog) in files.iter().enumerate() { + h.state_mut().open_bytes(&format!("{i}.pdf"), None, fixture(catalog)).unwrap(); + } + h +} + +#[test] +fn fit_window_sizes_the_window_to_the_first_page() { + let mut h = windowed(&["/ViewerPreferences << /FitWindow true >> /OpenAction [3 0 R /Fit]"]); + let (size, at) = window_commands(&mut h, 12); + let size = size.expect("the window is resized"); + assert_eq!(at, None, "not moved"); + // A 2:3 portrait page fitted to the window's height makes a narrower window. + assert!(size.x < 1000.0 && (size.y - 900.0).abs() < 1.0, "{size:?}"); + // Asked once. + assert_eq!(window_commands(&mut h, 6), (None, None)); + // The page now fills the document area's height and nearly its width. + let v = &h.state().views[0]; + let (page, area) = (v.page_screen_rect(0).expect("shown"), v.viewport_rect()); + assert!(area.width() - page.width() < 160.0, "page {page:?} in {area:?}"); +} + +#[test] +fn center_window_centres_the_window_on_the_monitor() { + let mut h = windowed(&["/ViewerPreferences << /CenterWindow true >>"]); + // 1400 × 930 on 1920 × 1080. + assert_eq!(window_commands(&mut h, 12), (None, Some(egui::pos2(260.0, 75.0)))); + let mut h = windowed(&["/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + let (size, at) = window_commands(&mut h, 12); + let (size, at) = (size.expect("resized"), at.expect("moved")); + assert!(((1920.0 - size.x) / 2.0 - at.x).abs() <= 0.5, "{size:?} at {at:?}"); +} + +#[test] +fn a_maximized_window_or_a_second_tab_is_left_alone() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| PdfCraftApp::new()); + on_monitor(&mut h); + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); + h.run_steps(2); + h.state_mut().open_bytes("0.pdf", None, fixture("/ViewerPreferences << /FitWindow true /CenterWindow true >>")).unwrap(); + assert_eq!(window_commands(&mut h, 12), (None, None), "maximized"); + + let mut h = windowed(&["", "/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + assert_eq!(window_commands(&mut h, 12), (None, None), "beside another document"); + + // A tab opened behind the document before its page is laid out (a late startup file) leaves + // the window alone too, though the document stays active. + let mut h = windowed(&["/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + h.state_mut().open_bytes("1.pdf", None, fixture("")).unwrap(); + h.state_mut().active = Some(0); + assert_eq!(window_commands(&mut h, 12), (None, None), "a tab opened behind it"); +} + +#[test] +fn the_user_is_told_which_key_brings_the_interface_back() { + let mut h = windowed(&[HIDE_ALL]); + h.run_steps(3); + h.get_by_label_contains("This document hides the toolbars and panels. Press F8 to show them."); + // Not when nothing is hidden. + let mut h = windowed(&["/ViewerPreferences << /HideToolbar false /HideMenubar false /HideWindowUI false >>"]); + h.run_steps(3); + assert!(h.query_by_label_contains("Press F8").is_none()); + // Nor when the user opens documents their own way. + let mut h = windowed(&[]); + h.state_mut().always_default_view = true; + h.state_mut().open_bytes("0.pdf", None, fixture(HIDE_ALL)).unwrap(); + h.run_steps(3); + assert!(h.query_by_label_contains("Press F8").is_none()); +} diff --git a/docs/gaps.md b/docs/gaps.md index 0fe476d087..1895b55574 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -364,6 +364,8 @@ update the numbers. ### 18. Print production tools absent - **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager, + colour conversion, fix hairlines, flattener preview, colour management settings; + print as image; PostScript output. Printer marks are in (Print production ▸ Add printer marks…). colour conversion, printer marks, flattener preview, colour management settings; print as image; PostScript output. Fix hairlines is in (Print production ▸ Fix hairlines…). - **Evidence:** 17 print-production features planned (L area). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ed8365a73..126496547d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -137,7 +137,7 @@ workflows, exchange edge cases and everything outside the mainstream slice are e | Print | 8% | 55% | Good on macOS and Linux; Windows prints since #847 (#756), not yet re-scored | | Highlight, sticky notes, text comments | 12% | 85% | All markup types, replies, properties | | Fill in forms | 10% | 85% | Every AcroForm field type, Acrobat's formatting and calculations | -| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save | +| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save (opt-in, and asked on the first save after signing) | | Rotate, delete, reorder and extract pages | 8% | 85% | Organize grid, drag reorder, thumbnails | | Combine files | 6% | 85% | Combine tab with bookmarks per file | | Fix a typo in existing text | 5% | 40% | Latin paragraphs only; font may be substituted | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 20144d6cf5..f580b84c2a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-29 · **Last updated:** 2026-10-29 · **Change:** minor (full screen plays page transitions and display times, hides a still pointer, and a right-click or Shift+Enter goes back a page #379) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -33,7 +33,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | | Split window, spreadsheet split | yes | split right or down (⌘\\, tab menu, a tab dropped on the page), one document or two; no four-pane spreadsheet split | split shipped; spreadsheet split planned | -| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned | +| Read mode, full screen | yes | yes; full screen is one fitted page or spread at a time on black, and background clicks and keys turn pages while clicks on links, comments, fields and other page objects do not (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences planned | | Light, dark, system theme | yes | yes | shipped | | Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped | | Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned | @@ -86,6 +86,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Full screen plays page transitions (/Trans) and display times (/Dur), hides a still pointer, and a right-click or Shift+Enter goes back a page (#379) | | 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | | 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e7b3bc3126..642268ef87 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1118,8 +1118,8 @@ milestone = "M3" status = "shipped" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_keeps_two_page_view_as_spreads"] -notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. Missing: the cursor is not hidden, no transitions, auto-advance or loop (view.full-screen-presentation), and no full-screen preferences such as the background colour" +evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_keeps_two_page_view_as_spreads", "crates/ui-egui/tests/it/view.rs::full_screen_right_click_goes_back_a_page", "crates/ui-egui/tests/it/view.rs::full_screen_shift_enter_goes_back_a_page", "crates/ui-egui/tests/it/view.rs::full_screen_hides_a_still_pointer"] +notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, a right-click (no context menu in full screen), Shift+Enter, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. The pointer hides after three still seconds. Transitions and display times: view.full-screen-presentation. Missing: loop after the last page, and full-screen preferences such as the background colour" [[feature]] id = "view.full-screen-presentation" @@ -1130,8 +1130,8 @@ milestone = "M12" status = "partial" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"] -notes = "Click to advance (and shift-click back) ships with view.full-screen; auto-advance every N seconds, loop after the last page, page transitions and the full-screen preferences are not done" +evidence = ["crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_plays_a_page_transition_and_honours_the_display_time", "crates/ui-egui/tests/it/view.rs::full_screen_turns_at_once_without_animations_or_in_spreads", "crates/ui-egui/tests/it/view.rs::full_screen_hides_a_still_pointer"] +notes = "Click to advance (and shift-click back) ships with view.full-screen. A page's /Trans plays when it comes into view (ISO 32000-2 \u00a712.4.4, Table 164; single-page view, no view rotation; the outgoing page is held until the incoming one has rendered; the Animation effects preference turns them off), a page's /Dur advances to the next page once it is up (not past the last), and the pointer hides after three still seconds. Not done: full-screen preferences that override the document (advance every N seconds, loop after the last page, a default transition, ignoring the document's transitions, the pointer setting), and transitions between two-page spreads" [[feature]] id = "view.reflow" @@ -1430,8 +1430,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages"] -notes = "" +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/search_position.rs::every_match_is_shown_after_zooming", "crates/ui-egui/tests/it/search_position.rs::zooming_and_stepping_in_any_order_shows_the_match", "crates/ui-egui/tests/it/search_position.rs::a_step_during_a_wheel_zoom_keeps_the_match_on_screen", "crates/ui-egui/tests/it/search_position.rs::every_page_display_shows_the_match_after_zooming"] +notes = "Each step (⌘G, ⇧⌘G, Enter, a Search panel result) centres the match in the window on both axes at any zoom, view rotation and page display, as far as the content's ends allow (#3530); a zoom still easing on, or one in the same frame, anchors on the match instead of the pointer. A word wider than the window shows its middle." [[feature]] id = "view.find-options" @@ -3275,7 +3275,7 @@ status = "partial" commands = [] tools = ["doc_info", "page_transition_set"] evidence = ["crates/render/src/inspect/tests.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] -notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" +notes = "Read, set and played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Full screen plays them and advances on /Dur (view.full-screen-presentation). Not yet: the Page Transitions dialog" [[feature]] id = "organize.page-tab-order" @@ -3487,11 +3487,11 @@ title = "Edit attachment descriptions" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["attachment_edit"] +evidence = ["crates/organize/src/tests.rs::attachment_description_changes_only_the_file_spec_and_can_be_cleared", "crates/automation/tests/it/automation.rs::attachment_descriptions_can_be_edited_cleared_undone_and_saved", "crates/ui-egui/tests/it/attachment_row.rs::the_description_can_be_edited_from_the_row_menu"] +notes = "The document-level attachment row menu edits only the Filespec /Desc; an empty description clears it. The embedded stream and other file-spec keys stay intact. Undo/redo and the headless attachment_edit tool use the same edit. Page paper-clip comments are not affected." [[feature]] id = "organize.associated-files" @@ -3511,11 +3511,11 @@ title = "View PDF Portfolios as a file list" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info"] +evidence = ["crates/render/src/inspect/portfolio_tests.rs::a_portfolio_presents_the_attachment_its_d_names", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_without_an_initial_document_shows_its_files_over_the_cover", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_inside_the_initial_document_waits_for_the_reader", "crates/automation/tests/it/portfolio_info.rs::doc_info_names_the_document_a_portfolio_presents_first"] +notes = "A document with a /Collection (ISO 32000-2 §7.11.6) opens the PDF its /D names in a tab of its own, and the portfolio's tab shows its files in the Attachments panel even with the right panel closed (not with /View /H, which keeps the list for the reader to open), each PDF opening in a tab from there; doc_info reports the portfolio, its initial document and view. Not yet: the files in a details or tile view of their own with the /Schema columns, /Sort and thumbnails, /Folders, previewing a file inside the portfolio's window, and saving edits back into the portfolio" [[feature]] id = "organize.portfolio-extract" @@ -3584,10 +3584,10 @@ area = "D" tier = "P1" milestone = "M4" status = "shipped" -commands = ["file.properties"] +commands = ["file.properties", "view.toggle_interface"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/ui-egui/tests/it/viewer.rs::initial_view_magnification_offers_25_and_100_percent", "crates/organize/src/tests.rs::choosing_100_percent_keeps_an_actual_size_opening_destination", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/ui-egui/tests/it/viewer.rs::initial_view_magnification_offers_25_and_100_percent", "crates/organize/src/tests.rs::choosing_100_percent_keeps_an_actual_size_opening_destination", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds", "crates/ui-egui/tests/it/viewer_prefs.rs::a_document_that_hides_the_interface_opens_without_it_and_f8_brings_it_back", "crates/ui-egui/tests/it/viewer_prefs.rs::one_page_at_a_time_opens_at_fit_page", "crates/ui-egui/tests/it/viewer_prefs.rs::fit_window_sizes_the_window_to_the_first_page"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen. HideToolbar, HideMenubar and HideWindowUI are applied in the document's tab only (the mode bar's tools and quick-action bar, the menu bar, the rail, side panels and scroll bars; the tab strip stays), never saved, skipped with Always use my default zoom and page layout, and F8 (view.toggle_interface) brings them back. FitWindow and CenterWindow apply to the window's only document when the window is neither maximized nor full screen; centring needs the window on the monitor at the desktop origin, and Wayland and the browser ignore both. SinglePage, TwoPageLeft and TwoPageRight without a named zoom open at fit page" [[feature]] id = "organize.custom-properties" @@ -4698,8 +4698,8 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] -notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/forms/src/tests.rs::a_tall_field_turned_a_quarter_keeps_its_place_and_runs_its_text_up_it", "crates/ui-egui/tests/it/forms.rs::a_turned_field_places_the_caret_along_its_turned_text", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] +notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270: the field keeps its rectangle and its text runs along the turned direction, also while it is typed into; double-click word selection in a turned field is not mapped); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" [[feature]] id = "form.rich-text-fields" @@ -5543,8 +5543,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_save", "doc_flatten"] -evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"] -notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable. Other comments and form fields stay. Flatten comments still flattens every comment." +evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on", "crates/ui-egui/tests/it/fill_sign.rs::placing_a_signature_offers_to_lock_it_now", "crates/ui-egui/tests/it/fill_sign.rs::the_first_save_after_placing_a_signature_asks_to_lock_it", "crates/ui-egui/tests/it/fill_sign.rs::cancelling_the_lock_question_saves_nothing", "crates/ui-egui/tests/it/fill_sign.rs::a_lock_that_fails_keeps_the_question", "crates/ui-egui/tests/it/fill_sign.rs::the_lock_question_is_skipped_or_kept_where_it_must_be"] +notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable; right after a signature or initials is placed, a notice offers Lock now (undoable, no save), and the first save after one is placed asks whether to lock it into the page (Lock and save / Keep editable, with Don't ask again); the Fill & Sign panel's Sign yourself section carries the same switch (#2149). Not on a digitally signed document. Other comments and form fields stay. Flatten comments still flattens every comment." [[feature]] id = "form.autofill-profile" @@ -5919,8 +5919,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] -notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short", "crates/redact/src/tests/metadata_fail_closed.rs::an_xmp_stream_whose_filter_data_is_corrupt_goes", "crates/redact/src/tests/elsewhere.rs::places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept", "crates/engine/src/tests.rs::applied_redactions_report_a_bookmark_that_still_holds_the_removed_text", "crates/automation/tests/it/automation.rs::redact_apply_reports_places_that_still_hold_the_removed_text", "crates/ui-egui/tests/it/redact.rs::applying_says_where_the_removed_text_is_still_held", "crates/redact/src/tests/metadata_matching.rs::a_heading_tracked_with_tj_numbers_matches_the_metadata", "crates/redact/src/tests/metadata_matching.rs::unicode_forms_of_the_removed_text_match", "crates/redact/src/tests/metadata_matching.rs::a_utf16le_string_is_searched_as_utf16le", "crates/redact/src/tests/identification.rs::the_identification_of_a_removed_packet_is_written_back", "crates/automation/tests/it/automation.rs::redact_apply_reports_the_identification_written_back", "crates/ui-egui/tests/it/redact.rs::applying_says_the_pdfa_identification_was_written_back"] +notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words, each also compared by letters and digits alone (so spacing, hyphens and TJ tracking don't hide it), after NFKC and lower case, with FF FE strings read as UTF-16LE; fail-closed: all of it goes when the removed text can't be known in full (too much to collect or search for, a glyph whose text isn't known, a form removed unread), and an XMP packet goes when it can't be decoded strictly, is over 16 MiB or isn't UTF-8 or UTF-16). When the document's XMP goes, its PDF/A and PDF/UA identification is written back in a packet holding only that, value by value when each is a valid code that doesn't hold the removed text, else reported lost (Report::identification, the notification, redact_apply's identification); /Info entries that remain aren't mirrored into that packet. Bookmark titles, attachments (names, file names, descriptions), the comments that remain and other objects' XMP are not changed: applying counts those that still hold the removed text, and the notification and redact_apply's still_holding say where (Remove hidden information removes them); when the removed text couldn't be searched for, every such place holding any text is counted. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" @@ -7642,11 +7642,11 @@ title = "Discard embedded search index" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::search_indexes_in_the_catalog_go_and_other_application_data_stays", "crates/optimize/tests/optimize.rs::an_indirect_piece_info_is_left_for_its_other_users", "crates/automation/tests/it/automation.rs::optimizing_discards_an_embedded_search_index_through_tools"] +notes = "PDF Optimizer ▸ Discard Objects, off by default. The public help page does not say where the index is stored; PdfCraft's rule: an entry of the catalog's /PieceInfo whose application name contains \"index\" in any letter case (such as IBM-ODIndexes) is a search index and is removed, with /PieceInfo itself when nothing else is left. An indirect /PieceInfo is copied into the catalog without those entries and the original object is left for its other users. The full save leaves out objects only the removed entries reach, cycles included; objects something else also uses stay. Page /PieceInfo is not changed. An index stored under an application name that does not contain \"index\" is kept." [[feature]] id = "optimize.discard-print-settings" @@ -7750,11 +7750,11 @@ title = "Discard external cross-references" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay", "crates/optimize/tests/optimize.rs::an_action_to_another_document_hands_its_place_to_the_actions_after_it", "crates/optimize/tests/optimize.rs::a_chain_deeper_than_the_limit_loses_its_tail_and_one_without_a_remote_action_stays", "crates/optimize/tests/optimize.rs::a_long_chain_of_indirect_actions_stays_indirect_and_reads_back_after_saving", "crates/optimize/tests/optimize.rs::shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document", "crates/optimize/tests/optimize.rs::a_shared_chain_without_remote_actions_is_looked_at_once", "crates/optimize/tests/optimize.rs::a_link_named_through_an_indirect_subtype_is_discarded_and_a_form_field_keeps_its_action", "crates/optimize/tests/optimize.rs::thread_actions_into_another_file_and_data_imports_are_discarded_and_threads_here_stay", "crates/optimize/tests/optimize.rs::an_outline_over_the_limit_stops_the_optimization", "crates/optimize/tests/optimize.rs::bookmarks_stored_inside_other_bookmarks_lose_their_actions_too", "crates/optimize/tests/optimize.rs::a_removed_link_kept_by_the_structure_tree_reads_back_without_its_action", "crates/automation/tests/it/automation.rs::optimizing_discards_links_to_other_documents_through_tools"] +notes = "PDF Optimizer ▸ Discard User Data, off by default. Takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with an /F, out of the /A of link annotations (/Subtype /Link, stored directly or as an indirect name) and bookmarks, and the actions chained after one through /Next take its place in order. A link left with no action and no /Dest is removed from /Annots, and when the link is an indirect object its object is rewritten without the action for its other users (the structure tree, for example); a bookmark keeps its title and children. Bookmarks stored directly inside another bookmark or the outline dictionary are changed in place; an outline of more than 100,000 items, or with direct items nested more than 512 levels deep, stops the optimization with an error. Links within the document (/Dest, GoTo) and web links (URI) stay. An indirect action that changes is written as a new indirect object, except the first action left at the head of an /A, which is copied into the /A directly when other actions follow it; the old one stays for its other users. Past 512 nested levels (actions, /Next arrays and references), the rest of a chain is kept when it opens no other document and removed when it does. An /A with more than 100,000 values to check, or one that opens another document and needs more than 100,000 values to rewrite (actions shared so that their paths multiply), stops the optimization with an error. Not changed: /AA triggers, widget and other annotation actions, the catalog /OpenAction, and JavaScript." [[feature]] id = "optimize.discard-hidden-layers" @@ -8321,8 +8321,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent"] -notes = "auto portrait/landscape" +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page", "crates/print/src/tests.rs::a_job_that_names_its_orientation_keeps_it", "crates/automation/src/printing.rs::printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them", "crates/ui-egui/tests/it/print.rs::printing_landscape_sheets_tells_cups_to_turn_them"] +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option was checked against Apple's filters (cupsfilter on an AirPrint PPD, no paper printed), and #3068's author printed one landscape page with it on a physical printer on macOS; the tests check the job both submission paths send, not a spooler's output, and physical output is otherwise unvalidated." [[feature]] id = "print.comments-and-forms" @@ -8522,11 +8522,11 @@ title = "Convert colours" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.convert_colors"] +tools = ["doc_convert_colours"] +evidence = ["crates/edit/src/tests.rs::colours_are_converted_to_gray_rgb_and_cmyk", "crates/engine/src/tests.rs::converting_colours_recolours_pictures", "crates/ui-egui/tests/it/editing.rs::convert_colours_from_the_dialog", "crates/automation/tests/it/automation.rs::convert_colours_through_tools"] +notes = "Print production ▸ Convert colours…: Grayscale, RGB or CMYK by the standard device formulas (no ICC profile applied): every colour operator in the pages' content and the Form XObjects it draws is rewritten (device spaces, ICCBased by component count, CalRGB/CalGray, Lab, Indexed, Separation/DeviceN through a Type 2 tint), black kept 100% K / gray 0 on request, 8-bit gray/RGB pictures re-encoded (JPEG stays JPEG, CMYK written lossless, soft masks kept) and the ones left alone counted, comments' and fields' appearances optionally; all, current or a range of pages; one undo step; tool doc_convert_colours, which also lists the PDF/A level and output intents the file may no longer meet (the dialog does not say so yet). Still missing against Acrobat: conversion through ICC profiles and rendering intents (it is no prepress separation and uses no output intent), keeping spot colours, sampled and PostScript tint transforms (a gray of the tint's darkness), per-object conversion rules, inline images, and indexed, CMYK and masked pictures or decode arrays." [[feature]] id = "print.ink-manager" @@ -8558,11 +8558,11 @@ title = "Add printer marks (crop, bleed, registration, colour bars)" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.marks", "prepress.marks.remove"] +tools = ["doc_printer_marks", "doc_remove_marks"] +evidence = ["crates/edit/src/tests.rs::printer_marks_are_drawn_outside_the_trim_box_and_removable", "crates/ui-egui/tests/it/editing.rs::printer_marks_are_added_from_the_dialog_and_removed", "crates/automation/tests/it/automation.rs::printer_marks_through_tools"] +notes = "Print production ▸ Add printer marks…: trim marks at the trim box's corners, dashed bleed marks at the bleed box, registration targets centred on each side, a C/M/Y/K + 75/50/25 % K colour bar and a page-information line (file name, page n of N, date), at 0.25/0.5/1 pt, outside the trim box as a removable page mark (Remove printer marks; adding again replaces), in registration colour (the /All Separation) except the process-ink colour bar; the page is widened by 36 pt a side when the marks would fall outside it, the trim box kept; all, current or a range of pages; tool doc_printer_marks and doc_remove_marks kind printer_marks. Still missing against Acrobat: the Japanese-style and Eastern-style mark variants, and the choice of marks per page box (always the trim box)." [[feature]] id = "print.fix-hairlines"