mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
Merge pull request #6714 from storytold/merge/ui-print-optimize2
Land print production, viewer, attachment and optimizer changes (#1918, #1998, #2539, #2348, #2701, #3806, #4139, #4378, #4401, #4763, #5259, #5508, #5316, #4434, #4440, #6092, #6098)
This commit is contained in:
138 files changed
+11433
-371
No files matched your search
Generated
+2
@@ -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]]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -57,7 +57,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
|
||||
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 };
|
||||
|
||||
@@ -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<Finding> {
|
||||
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<Object>)> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<String> = 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)?,
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
@@ -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<bool> { 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 }
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
// 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
|
||||
|
||||
@@ -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<spool::Duplex>, paper: usize) -> Result<spool::Job> {
|
||||
/// 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<spool::Duplex>, paper: usize, sheets: &[print::Sheet]) -> Result<spool::Job> {
|
||||
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<spool::Duple
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
title,
|
||||
options: options.into_iter().chain(print::media_option(paper)).collect(),
|
||||
options: {
|
||||
let mut options: Vec<(String, String)> = 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<Value> {
|
||||
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<String> = 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<Value> {
|
||||
@@ -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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -171,11 +171,23 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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<ToolDef> {
|
||||
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<ToolDef> {
|
||||
"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<ToolDef> {
|
||||
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<ToolDef> {
|
||||
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 (`<name>_page_<n>.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<ToolDef> {
|
||||
.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<ToolDef> {
|
||||
"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<ToolDef> {
|
||||
"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"],
|
||||
|
||||
@@ -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<String> = 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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/" pdfaid:part="2" pdfaid:conformance="B" pdfuaid:part="1"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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<u8> {
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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<Space>,
|
||||
hival: usize,
|
||||
table: Vec<u8>,
|
||||
},
|
||||
/// 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<Space>,
|
||||
exp: Option<(Vec<f64>, Vec<f64>, 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<f64> {
|
||||
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<f64> = (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<f64> = 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],
|
||||
}
|
||||
}
|
||||
@@ -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<Stream> + '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<f64> {
|
||||
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<Space>,
|
||||
stroke: Option<Space>,
|
||||
stack: Vec<(Option<Space>, Option<Space>)>,
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>>, usize) {
|
||||
let mut ops: Vec<Op> = parse(data).ops;
|
||||
let mut count = 0usize;
|
||||
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
|
||||
let State { fill, stroke, stack } = state;
|
||||
let numbers = |op: &Op| -> Vec<f64> { 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<Stream> {
|
||||
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<ObjRef, ObjRef>,
|
||||
shared: HashSet<ObjRef>,
|
||||
}
|
||||
|
||||
/// 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<ObjRef> {
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut todo: Vec<Object> = Vec::new();
|
||||
let xobjects_of = |res: Option<&Object>, todo: &mut Vec<Object>| {
|
||||
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<Dict> {
|
||||
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<Converted, EditError> {
|
||||
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<Option<Vec<u8>>> = 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<ObjRef> = 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<ObjRef> = 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)
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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 <tint>]`: 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<f64> = 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<Vec<usize>, 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<f64> = 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)])
|
||||
}
|
||||
@@ -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<f64> = 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::<Vec<_>>().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.
|
||||
|
||||
@@ -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::<Vec<_>>().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<ObjRef> {
|
||||
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<Stream> {
|
||||
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}");
|
||||
}
|
||||
@@ -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::<Vec<_>>().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<Stream> {
|
||||
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 <FF0000>] >>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");
|
||||
}
|
||||
@@ -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::<Vec<_>>().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<ObjRef> {
|
||||
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<Stream> {
|
||||
None
|
||||
}
|
||||
|
||||
/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green).
|
||||
fn page_with(streams: &[&str]) -> Document {
|
||||
let refs: Vec<String> = (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 <FF000000FF00>] >> >> /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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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<Vec<f64>> {
|
||||
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]));
|
||||
}
|
||||
@@ -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<Vec<f64>> {
|
||||
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));
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -70,8 +70,9 @@ pub struct Document {
|
||||
/// (also in `info.warnings`, kept there when the document is re-read).
|
||||
pub xfa_warnings: Vec<String>,
|
||||
/// 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<EditNotice>,
|
||||
}
|
||||
|
||||
@@ -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<usize> },
|
||||
/// 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<String> {
|
||||
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<String> = [
|
||||
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<pdfcraft_redact::Elsewhere> {
|
||||
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<pdfcraft_redact::Identification> {
|
||||
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).
|
||||
|
||||
@@ -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<InitialView>),
|
||||
/// 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<usize>,
|
||||
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<usize>,
|
||||
settings: pdfcraft_edit::ConvertColours,
|
||||
},
|
||||
/// Change or clear only `/Desc` on a document-level attachment's file specification.
|
||||
EditAttachmentDescription {
|
||||
key: String,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// 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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<pdfcraft_cos::ObjRef>,
|
||||
/// What the edit's applied redactions left as it was that still holds the removed text.
|
||||
pub(crate) redaction_elsewhere: Option<pdfcraft_redact::Elsewhere>,
|
||||
/// 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<pdfcraft_edit::text::forms::Preparation>,
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+108
-13
@@ -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<String> = 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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
|
||||
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<String> = 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<u8> {
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+16
-12
@@ -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<String>) -> Option<String> {
|
||||
@@ -623,20 +626,21 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> 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<f64> = 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::<f64>() / 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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -118,8 +118,9 @@ pub struct FieldProps {
|
||||
/// Options tab: the mark of a check box or radio button (every widget).
|
||||
pub check_style: Option<CheckStyle>,
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<S
|
||||
}
|
||||
}
|
||||
// Validated before anything is written, so a refused rotation leaves the document unchanged.
|
||||
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
|
||||
let mut rotated_rect: Option<Option<[f64; 4]>> = 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<S
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
|
||||
let only = apply_late_widget_props(doc, &f, props, &new_da)?;
|
||||
scroll_list(doc, f.obj, list_picked)?;
|
||||
redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?;
|
||||
if props.calculate.is_some() {
|
||||
@@ -531,13 +506,19 @@ fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec<usize
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_late_widget_props(
|
||||
doc: &mut Document,
|
||||
f: &Field,
|
||||
props: &FieldProps,
|
||||
new_da: &str,
|
||||
rotated_rect: Option<Option<[f64; 4]>>,
|
||||
) -> Result<Option<usize>, 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<Option<usize>, 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");
|
||||
|
||||
+21
-13
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Object>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Walk {
|
||||
/// References on the current chain, to end a chain that comes back to itself.
|
||||
path: Vec<ObjRef>,
|
||||
/// Indirect objects from which an action to another document can be reached.
|
||||
dirty: HashSet<ObjRef>,
|
||||
/// 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<Object> {
|
||||
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<ObjRef>) -> 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<ObjRef, Vec<ObjRef>> = 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<Object>) {
|
||||
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<Option<Option<Object>>, 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<ObjRef>,
|
||||
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<bool, OptimizeError> {
|
||||
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<usize, OptimizeError> {
|
||||
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))
|
||||
}
|
||||
@@ -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<Stream> {
|
||||
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<u8> = 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::*;
|
||||
|
||||
+97
-22
@@ -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<usize> {
|
||||
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<usize, OptimizeError> {
|
||||
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<usize, OptimizeError> {
|
||||
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::*;
|
||||
|
||||
@@ -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>) -> 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<Object>, dest: Option<Object>) -> 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<String> {
|
||||
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<Object>, bookmarks: &[Object]) -> (Document, ObjRef, Vec<ObjRef>) {
|
||||
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<ObjRef> = 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<Dict> {
|
||||
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<Vec<String>> = 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<Vec<String>> = 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<Vec<String>> = 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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<_>>(), 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<u8> {
|
||||
(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);
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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<u8>, Object)> {
|
||||
let Some(tree) = doc
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u8>, 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<u8>, 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<u8>, 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<String> = 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<String> =
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Option<std::path::PathBuf>> = 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<usize> = 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<Printer> {
|
||||
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<String, crate::PrintError> {
|
||||
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<String, crate::PrintError> {
|
||||
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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, crate::PrintError>| 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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
+55
-1
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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<ObjRef> = 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
|
||||
}
|
||||
@@ -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#"<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">"#,
|
||||
r#"<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
|
||||
r#"<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema><pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI><pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>"#,
|
||||
r#"<pdfaSchema:property><rdf:Seq><rdf:li rdf:parseType="Resource">"#,
|
||||
r#"<pdfaProperty:name>part</pdfaProperty:name><pdfaProperty:valueType>Integer</pdfaProperty:valueType><pdfaProperty:category>internal</pdfaProperty:category><pdfaProperty:description>The part of ISO 14289 the file conforms to</pdfaProperty:description>"#,
|
||||
r#"</rdf:li></rdf:Seq></pdfaSchema:property></rdf:li></rdf:Bag></pdfaExtension:schemas></rdf:Description>"#,
|
||||
);
|
||||
|
||||
/// 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<Stream>) {
|
||||
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<String>, valid: fn(&str) -> bool| -> Option<String> {
|
||||
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">"#);
|
||||
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!("<pdfaid:{name}>{v}</pdfaid:{name}>"));
|
||||
}
|
||||
}
|
||||
body.push_str("</rdf:Description>");
|
||||
} 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!("<pdfuaid:rev>{r}</pdfuaid:rev>")).unwrap_or_default();
|
||||
body.push_str(&format!(r#"<rdf:Description rdf:about="" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/"><pdfuaid:part>{p}</pdfuaid:part>{rev}</rdf:Description>"#));
|
||||
if extension {
|
||||
body.push_str(PDFUA_EXTENSION);
|
||||
}
|
||||
}
|
||||
None => id.pdfua = Claim::Lost,
|
||||
}
|
||||
}
|
||||
if body.is_empty() {
|
||||
return (id, None);
|
||||
}
|
||||
let packet = format!(
|
||||
"<?xpacket begin=\"\u{FEFF}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">{body}</rdf:RDF></x:xmpmeta>\n<?xpacket end=\"w\"?>"
|
||||
);
|
||||
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<String>,
|
||||
conformance: Option<String>,
|
||||
amd: Option<String>,
|
||||
rev: Option<String>,
|
||||
}
|
||||
|
||||
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<Found> {
|
||||
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)
|
||||
}
|
||||
@@ -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<ObjRef, bool>,
|
||||
/// 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<String>,
|
||||
/// 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);
|
||||
|
||||
@@ -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<ObjRef>,
|
||||
/// 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<Object>,
|
||||
widget_owner: Vec<(ObjRef, ObjRef)>,
|
||||
removed_everywhere: HashSet<ObjRef>,
|
||||
/// The text of the removed glyphs, for the metadata check at the end.
|
||||
removed_text: Vec<String>,
|
||||
/// 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
|
||||
|
||||
@@ -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<String> {
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
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<Vec<String>> {
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
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<usize>);
|
||||
|
||||
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<char>| 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<String> {
|
||||
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 = [("<![CDATA[", "]]>"), ("<!--", "-->"), ("<?", "?>")].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 == "<?" && text.split_whitespace().next() == Some("xpacket")) {
|
||||
push(text.to_string());
|
||||
}
|
||||
rest = body.get(end.saturating_add(stop.len())..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
// Attribute values inside the tag, up to its closing `>` (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 `<?xpacket` with the mark inside the attribute, by the zero
|
||||
/// byte of its first character. `None` for anything else or any invalid sequence, so text that
|
||||
/// can't be read faithfully is never searched lossily (the caller removes it).
|
||||
pub(crate) fn decode_xmp(bytes: &[u8]) -> Option<String> {
|
||||
let utf16 = |body: &[u8], be: bool| -> Option<String> {
|
||||
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::<Result<String, _>>().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<Vec<String>> {
|
||||
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<String> {
|
||||
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<String>, Vec<String>, Vec<Run>),
|
||||
}
|
||||
|
||||
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<Vec<String>> = 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<Run> = 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<Scrubbed, pdfcraft_cos::CosError> {
|
||||
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<Scrubbed, pdfcraft_cos::CosError> {
|
||||
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<Vec<u8>> =
|
||||
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<ObjRef> {
|
||||
let mut holders: Vec<ObjRef> = doc.root().into_iter().collect();
|
||||
holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj));
|
||||
holders
|
||||
}
|
||||
@@ -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<Vec<(ObjRef, bool, Object)>, 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 {
|
||||
|
||||
@@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{body}</rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
};
|
||||
let objs: Vec<Vec<u8>> = 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#"<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" pdf:Keywords="tuna, BlueFin"/>"#),
|
||||
// 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#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description>"#),
|
||||
];
|
||||
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<u8>> = 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<u8>> = 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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{xmp_body}</rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#
|
||||
);
|
||||
let objs: Vec<Vec<u8>> = 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<String> {
|
||||
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#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin</dc:title></rdf:Description>"#;
|
||||
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
|
||||
/// `<?xpacket end="w"?>`.
|
||||
#[test]
|
||||
fn xmp_values_inside_the_redacted_text_go_as_whole_words() {
|
||||
let creator = r#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>J. Li</dc:creator></rdf:Description>"#;
|
||||
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#"<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" pdf:Trapped="True"/>"#;
|
||||
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#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description>"#;
|
||||
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<u8>> = 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::<usize>();
|
||||
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("<a>Résumé & AA �</a>"), ["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("<a>BLUEFIN BLUEFIN �</a>"), ["BLUEFIN BLUEFIN �"]);
|
||||
let bare = "&".repeat(200_000);
|
||||
let started = std::time::Instant::now();
|
||||
let values = metadata::xml_values(&format!("<a>{bare}</a>"));
|
||||
assert_eq!(values.concat().len(), bare.len());
|
||||
let zeros = format!("&#{}", "0".repeat(10)).repeat(20_000);
|
||||
assert_eq!(metadata::xml_values(&format!("<a>{zeros}</a>")).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<u8>> = 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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{body}</rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
};
|
||||
let objs: Vec<Vec<u8>> = 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#"<rdf:Description rdf:about="" xmlns:acme="https://example.org/acme/"><acme:Agent>James Bond</acme:Agent></rdf:Description>"#),
|
||||
// 8: an attribute of a custom schema.
|
||||
xmp(r#"<rdf:Description rdf:about="" xmlns:acme="https://example.org/acme/" acme:code="James Bond"/>"#),
|
||||
// 9: a third packet, on no holder yet; swapped onto the page below to show a clean one stays.
|
||||
xmp(r#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description>"#),
|
||||
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<Vec<u8>> =
|
||||
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><?audit James Bond?><?xpacketAudit SECRET?><x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns="http://example.org/" xmlns:y = "adobe:ns:y/" b = "spaced"><dc:title a="x & y"><!-- hidden --><rdf:li>Résumé <![CDATA[<raw>]]></rdf:li></dc:title></x:xmpmeta>"#,
|
||||
);
|
||||
// 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é ", "<raw>"]);
|
||||
}
|
||||
|
||||
/// 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 `<?xpacket begin="…"`); anything that isn't valid is not read.
|
||||
#[test]
|
||||
fn xmp_packets_decode_or_fail_closed() {
|
||||
let text = "<?xpacket begin=\"\u{feff}\"?><a>Bond</a>";
|
||||
let utf16 = |be: bool| text.encode_utf16().flat_map(|u| if be { u.to_be_bytes() } else { u.to_le_bytes() }).collect::<Vec<u8>>();
|
||||
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"<a>Bond \xE9</a>"), 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;
|
||||
|
||||
@@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:description>Photo for Bluefin</dc:description></rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
const XMP_OTHER: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
/// 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<u8>> = 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 (<body><p>BLUEFIN</p></body>) /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<Object> {
|
||||
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:?}");
|
||||
}
|
||||
@@ -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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#;
|
||||
const DOC_XMP_TAIL: &str = r#"</rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
|
||||
|
||||
/// One page showing "Codename BLUEFIN report", whose catalog XMP is `xmp` (raw bytes).
|
||||
fn page(xmp: &[u8]) -> Document {
|
||||
let objs: Vec<Vec<u8>> = 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<String>) {
|
||||
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/><rdf:Description rdf:about="" xmlns:ua="http://www.aiim.org/pdfua/ns/id/"><ua:part>1</ua:part></rdf:Description><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title><dc:creator>Ada Lovelace</dc:creator></rdf:Description>"#;
|
||||
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#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin report</dc:title></rdf:Description>"#;
|
||||
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="Bluefin"/>"#;
|
||||
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#"<rdf:Description rdf:about="" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/" pdfuaid:part="1" dc:title="Bluefin caf\xE9"/>"#,
|
||||
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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="4" pdfaid:rev="2020"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description>"#;
|
||||
let objs: Vec<Vec<u8>> = 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("<pdfaid:part>4</pdfaid:part>") && !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#"<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="U"/><rdf:Description rdf:about="" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/" pdfuaid:part="1"/><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Schema notes</dc:title></rdf:Description>"#;
|
||||
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("<pdfaid:conformance>U</pdfaid:conformance>"), "{text}");
|
||||
}
|
||||
@@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
/// 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<u8>) -> Document {
|
||||
let objs: Vec<Vec<u8>> = 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<u8> {
|
||||
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<u8>) {
|
||||
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("</x:xmpmeta>", &format!("{}</x:xmpmeta>", " ".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<u8> = 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<u8>> = 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);
|
||||
}
|
||||
@@ -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#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>{title}</dc:title></rdf:Description>"#)
|
||||
}
|
||||
|
||||
/// 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!("<FEFF{}>", s.encode_utf16().map(|u| format!("{u:04X}")).collect::<String>())
|
||||
}
|
||||
|
||||
/// 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!("<FFFE{}>", s.encode_utf16().map(|u| format!("{:02X}{:02X}", u & 0xFF, u >> 8)).collect::<String>())
|
||||
}
|
||||
|
||||
/// 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<u8>> = 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() });
|
||||
}
|
||||
@@ -115,6 +115,37 @@ pub(super) fn run(doc: &mut Document) -> (Result<Report, RedactError>, 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<Report, RedactError>, 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"<x:xmpmeta><dc:title>Quarterly & SECRETVALUE</dc:title></x:xmpmeta>");
|
||||
let packet: &[u8] = b"<x:xmpmeta><dc:title>Quarterly & SECRETVALUE</dc:title></x:xmpmeta>";
|
||||
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"<x:xmpmeta><dc:title>Quarterly & SECRETVALUE</dc:title></x:xmpmeta>");
|
||||
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"<x:xmpmeta><dc:title>SECRETVALUE</dc:title></x:xmpmeta>");
|
||||
let packet: &[u8] = b"<x:xmpmeta><dc:title>SECRETVALUE</dc:title></x:xmpmeta>";
|
||||
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"<x:xmpmeta>SECRETVALUE</x:xmpmeta>");
|
||||
let packet: &[u8] = b"<x:xmpmeta>SECRETVALUE</x:xmpmeta>";
|
||||
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);
|
||||
|
||||
@@ -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<LayerRow>,
|
||||
pub fonts: Vec<FontInfo>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
/// The document is a PDF portfolio (`/Collection`): its documents are `attachments`.
|
||||
pub portfolio: Option<Portfolio>,
|
||||
/// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order.
|
||||
pub transitions: Vec<PageTransition>,
|
||||
/// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination,
|
||||
@@ -95,5 +97,7 @@ pub struct DocInfo {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod portfolio_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -796,6 +796,8 @@ pub enum LayerItem {
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Whether the file specification contained `/Desc`, including an empty value.
|
||||
pub has_description: bool,
|
||||
pub size: Option<usize>,
|
||||
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<usize>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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<u8> {
|
||||
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 [<FEFF0061002E007400780074> 5 0 R <FEFF0062002E007000640066> 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<Portfolio> {
|
||||
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 <FEFF0062002E007000640066> /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);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<DocId, String> {
|
||||
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<RightPanel>) {
|
||||
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<usize> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<std::sync::atomic::AtomicUsize>);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl WebRead {
|
||||
/// Count a read starting in `reads`.
|
||||
pub(super) fn start(reads: &std::sync::Arc<std::sync::atomic::AtomicUsize>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+117
-19
@@ -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<SavedDisplay>,
|
||||
/// Full screen's transitions, display times and pointer (see [`presenter`]).
|
||||
pub(crate) presenter: presenter::Presenter,
|
||||
pages: HashMap<usize, PageTex>,
|
||||
/// Pages the renderer could not draw, with the reason (never re-requested).
|
||||
errors: HashMap<usize, String>,
|
||||
@@ -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<crate::fill_sign::TypeBox>,
|
||||
/// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement.
|
||||
pub(crate) fill_signature_page: Option<usize>,
|
||||
/// 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<crate::fill_sign::SavedSig>,
|
||||
key_scroll: f32,
|
||||
key_side: f32,
|
||||
@@ -1392,6 +1419,9 @@ struct PageOut {
|
||||
page_action: Option<PageMenuAction>,
|
||||
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<String>,
|
||||
/// 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<Notice>) {
|
||||
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<bool>, link_followed: bool) {
|
||||
fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option<bool>, 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<b
|
||||
view.present_turn = Some((now + delay, !back));
|
||||
}
|
||||
if let Some((due, forward)) = view.present_turn {
|
||||
if link_followed || view.selection.is_some() || view.presentation.is_none() {
|
||||
if clicked_object || view.selection.is_some() || view.presentation.is_none() {
|
||||
view.present_turn = None;
|
||||
} else if now >= due {
|
||||
view.present_turn = None;
|
||||
@@ -2884,11 +2951,25 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<bool> {
|
||||
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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Claim>; LAYERS.len()], tool: QuickTool) -> Option<Claim> {
|
||||
LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool)))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Turn>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
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<Pos2>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<pdfcraft_render::PageTransition>, 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<pdfcraft_render::TransitionStyle>, duration: f32, display: Option<f32>) -> 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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<crate::viewer_prefs::PageNow> {
|
||||
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 {
|
||||
|
||||
@@ -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")];
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user