From 0f28c6a9029997fd8bead65e6cefce6dc708f6a3 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 18:02:10 +0800 Subject: [PATCH] M11: target PDF version and object streams option in PDF Optimizer (#3090) --- crates/automation/src/lib.rs | 10 ++- crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 12 ++++ crates/cos/src/document.rs | 5 ++ crates/engine/src/optimizer.rs | 6 +- crates/optimize/src/lib.rs | 91 +++++++++++++++++++++++++++ crates/optimize/tests/optimize.rs | 75 +++++++++++++++++++++- crates/ui-egui/src/optimize_ui.rs | 24 ++++++- crates/ui-egui/tests/create.rs | 13 ++++ parity/acrobat-features.toml | 20 +++--- 10 files changed, 244 insertions(+), 16 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b..eb320da76 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1059,7 +1059,7 @@ impl Automation { } fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset}; + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; @@ -1067,6 +1067,10 @@ impl Automation { None => Preset::Standard.settings(), Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), }; + if let Some(c) = a.opt_str("compatibility")? { + settings.compatibility = Compatibility::from_name(c) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; + } let images = |key: &str, base: ImageSettings| -> Result { let Some(v) = a.get(key) else { return Ok(base) }; let mut s = base; @@ -1100,6 +1104,7 @@ impl Automation { ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), ("flate_unencoded", &mut settings.flate_unencoded), + ("object_streams", &mut settings.object_streams), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), ] { @@ -1122,9 +1127,12 @@ impl Automation { }; let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; write_atomic(&path, &bytes)?; + let pdf_version = std::str::from_utf8(bytes.get(5..8).unwrap_or_default()).unwrap_or_default(); let o = &r.optimize; Ok(json!({ "path": path.to_string_lossy(), + "pdf_version": pdf_version, + "object_streams": settings.effective_object_streams(), "bytes_before": before, "bytes_after": bytes.len(), "merged_images": o.merged_images, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78..b967a526a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1303,13 +1303,14 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. 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). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); 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. 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). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); 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. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ "doc": doc(), "path": { "type": "string" }, "preset": { "type": "string", "enum": ["standard", "mobile"] }, + "compatibility": { "type": "string", "enum": ["retain", "1.4", "1.5", "1.6", "1.7", "2.0"] }, "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, @@ -1318,6 +1319,7 @@ pub fn tools() -> Vec { "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, + "object_streams": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f3..b69a6b207 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4626,6 +4626,18 @@ fn optimizing_through_tools() { assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); assert_eq!(reduced["written"], true); assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); + + // Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off). + let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" })); + assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}"); + let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap(); + assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm")); + + let v20 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v20.pdf", "compatibility": "2.0", "object_streams": false })); + assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}"); + assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n")); + + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!( a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })), Err(ToolError::InvalidArgs(_)) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56ee..f016b2ae4 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -805,6 +805,11 @@ impl Document { &self.version } + /// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save. + pub fn set_version(&mut self, version: impl Into) { + self.version = version.into(); + } + pub fn trailer(&self) -> &Dict { &self.trailer } diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f9..3616ecd47 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -68,7 +68,11 @@ impl Session { cos: editor.cos.clone(), settings: settings.clone(), discard: discard.to_vec(), - opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }, + opts: SaveOptions { + mod_date: self.now().map(pdfcraft_cos::pdf_date), + object_streams: settings.effective_object_streams(), + ..SaveOptions::default() + }, }) } } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b6769..1b84cadac 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -38,12 +38,58 @@ pub use images::effective_resolutions; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] + IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error("the optimization was cancelled")] Cancelled, } +/// The PDF version an optimized copy is made compatible with (Acrobat: Make compatible with). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Compatibility { + /// Keep the document's version (upgraded to 1.5 when object streams are written). + #[default] + Retain, + Pdf1_4, + Pdf1_5, + Pdf1_6, + Pdf1_7, + Pdf2_0, +} + +impl Compatibility { + pub const ALL: [Compatibility; 6] = + [Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0]; + + /// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`). + pub fn name(self) -> &'static str { + match self { + Compatibility::Retain => "retain", + Compatibility::Pdf1_4 => "1.4", + Compatibility::Pdf1_5 => "1.5", + Compatibility::Pdf1_6 => "1.6", + Compatibility::Pdf1_7 => "1.7", + Compatibility::Pdf2_0 => "2.0", + } + } + + pub fn from_name(name: &str) -> Option { + let trimmed = name.trim(); + let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed }; + Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped)) + } + + /// The three-character `%PDF-` header version, when a target version is chosen. + pub fn version(self) -> Option<&'static str> { + match self { + Compatibility::Retain => None, + _ => Some(self.name()), + } + } +} + /// Where an optimization is, reported to [`optimize_with_progress`]'s callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { @@ -89,6 +135,8 @@ impl ImageSettings { #[derive(Clone, Debug, PartialEq)] pub struct Settings { + /// Target PDF version (`Retain` by default). + pub compatibility: Compatibility, /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). pub merge_fragments: bool, pub color: ImageSettings, @@ -100,16 +148,28 @@ pub struct Settings { /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, + /// Clean Up: pack non-stream objects into compressed object streams with a cross-reference + /// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`]. + pub object_streams: bool, /// Clean Up: remove links (and bookmark destinations) that point nowhere. pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, } +impl Settings { + /// Whether `write_full` should pack non-stream objects into compressed object streams: + /// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7). + pub fn effective_object_streams(&self) -> bool { + self.object_streams && self.compatibility != Compatibility::Pdf1_4 + } +} + impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + compatibility: Compatibility::Retain, merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, @@ -118,6 +178,7 @@ impl Default for Settings { discard_tags: false, discard_print_settings: false, flate_unencoded: true, + object_streams: true, remove_invalid_links: true, remove_unreferenced_dests: true, } @@ -186,6 +247,20 @@ pub struct Report { pub unused_xobjects: usize, } +/// The minimum PDF version required by `doc`'s active security handler, when it uses features +/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7). +fn required_encryption_version(doc: &Document) -> Option<&'static str> { + let h = doc.output_handler()?; + let d = h.dict(); + if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) { + Some("1.7") + } else if d.v == 4 { + if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") } + } else { + None + } +} + /// Optimize `doc` in place. pub fn optimize(doc: &mut Document, settings: &Settings) -> Result { optimize_with_progress(doc, settings, &mut |_| true) @@ -195,6 +270,12 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { + if let Some(chosen) = settings.compatibility.version() + && let Some(required) = required_encryption_version(doc) + && chosen < required + { + return Err(OptimizeError::IncompatibleEncryption { required, chosen }); + } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; if settings.merge_fragments { @@ -230,6 +311,16 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } } let root = doc.root(); + if let Some(chosen) = settings.compatibility.version() { + doc.set_version(chosen); + if let Some(root) = root + && doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version")) + { + doc.update_dict(root, |c| { + c.remove(b"Version"); + })?; + } + } if settings.discard_tags && let Some(root) = root && doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot")) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6a..e9689af52 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; -use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize}; +use pdfcraft_optimize::{ + Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, +}; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { // A smooth gradient with a little texture (photo-like). @@ -1022,3 +1024,74 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { let refs = pdfcraft_annot::page_refs(&doc).unwrap(); assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } + +#[test] +fn compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() { + let mut base = Document::new_empty(); + page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET"); + let root = base.root().unwrap(); + base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap(); + + for (compat, expected) in [ + (Compatibility::Pdf1_4, "1.4"), + (Compatibility::Pdf1_5, "1.5"), + (Compatibility::Pdf1_6, "1.6"), + (Compatibility::Pdf1_7, "1.7"), + (Compatibility::Pdf2_0, "2.0"), + ] { + let mut doc = base.clone(); + let settings = Settings { compatibility: compat, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + assert_eq!(doc.version(), expected); + assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared"); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}"); + let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm"); + assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}"); + let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.pdf_version, expected); + } + + // Disabling object_streams on PDF 1.7 writes a classic cross-reference table. + let mut doc = base.clone(); + let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm")); + + // Encryption that requires a newer PDF version than the chosen compatibility is refused. + let mut aes256 = base.clone(); + aes256 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" }) + )); + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap(); + assert_eq!(aes256.version(), "2.0"); + + let mut aes128 = base; + aes128 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" }) + )); + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap(); +} diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184..9660b0a92 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; -use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings}; +use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -172,6 +172,17 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } } }); + ui.add_space(8.0); + ui.label(tl!("Compatibility")); + let compat_label = |c: Compatibility| match c { + Compatibility::Retain => tl!("Retain existing").to_string(), + _ => format!("PDF {}", c.name()), + }; + egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { + for c in Compatibility::ALL { + ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c)); + } + }); }); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { @@ -221,7 +232,16 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded"))); ui.add(widgets::checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks"))); ui.add(widgets::checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations"))); - ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Compress document structure (object streams)"))); + let mut object_streams = s.effective_object_streams(); + if ui + .add_enabled( + s.compatibility != Compatibility::Pdf1_4, + widgets::checkbox(&mut object_streams, tl!("Compress document structure (object streams)")), + ) + .changed() + { + s.object_streams = object_streams; + } ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Remove unused objects and merge identical ones"))); } } diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index ce2a5f37e..4d80c4fe6 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -238,6 +238,19 @@ fn the_pdf_optimizer_dialog_picks_a_preset() { h.run_steps(2); assert!(!h.state().optimize_draft.settings.merge_fragments); h.get_by_value("Custom"); + + // Pick a target PDF version and toggle structure compression on Clean Up. + h.get_by_value("Retain existing").click(); + h.run_steps(4); + h.get_by_label("PDF 2.0").click(); + h.run_steps(2); + assert_eq!(h.state().optimize_draft.settings.compatibility, pdfcraft_engine::optimize::Compatibility::Pdf2_0); + h.get_by_label("Clean Up").click(); + h.run_steps(2); + assert!(h.state().optimize_draft.settings.object_streams); + h.get_by_label("Compress document structure (object streams)").click(); + h.run_steps(2); + assert!(!h.state().optimize_draft.settings.object_streams); } fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec)> { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 157e9e368..395b95bd7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7245,11 +7245,11 @@ title = "Make compatible with a chosen PDF version" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] +notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090)" [[feature]] id = "optimize.optimizer" @@ -7545,11 +7545,11 @@ title = "Compress document structure (object streams)" area = "K" tier = "P1" milestone = "M11" -status = "partial" -commands = ["file.save_as"] -tools = ["doc_save"] -evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams"] -notes = "on by default for full saves; no Optimizer option yet" +status = "shipped" +commands = ["file.save_as", "optimize.advanced"] +tools = ["doc_save", "doc_optimize"] +evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams", "crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] +notes = "on by default for full saves and in PDF Optimizer ▸ Clean Up (and doc_optimize object_streams); turning it off or targeting PDF 1.4 unpacks object streams into standalone objects with a classic cross-reference table" [[feature]] id = "optimize.flate-unencoded"