From 763cd70ece4b7518a48822fdf9898d220a52b5c3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 07:37:48 -0400 Subject: [PATCH] =?UTF-8?q?M11:=20PDF=20Optimizer=20=E2=96=B8=20Audit=20sp?= =?UTF-8?q?ace=20usage:=20bytes=20and=20share=20of=20the=20file=20per=20ki?= =?UTF-8?q?nd=20of=20content;=20doc=5Faudit=5Fspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/automation/src/lib.rs | 9 + crates/automation/src/tools.rs | 4 + crates/automation/tests/automation.rs | 13 ++ crates/engine/src/lib.rs | 5 + crates/optimize/Cargo.toml | 1 + crates/optimize/src/audit.rs | 226 ++++++++++++++++++++++++++ crates/optimize/src/lib.rs | 3 + crates/optimize/tests/optimize.rs | 19 ++- crates/ui-egui/src/dialogs.rs | 10 ++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/optimize_ui.rs | 48 +++++- crates/ui-egui/tests/viewer.rs | 15 ++ parity/acrobat-features.toml | 10 +- 14 files changed, 356 insertions(+), 13 deletions(-) create mode 100644 crates/optimize/src/audit.rs diff --git a/Cargo.lock b/Cargo.lock index e991a91bc..d5ceaa13c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3504,6 +3504,7 @@ dependencies = [ "printcraft-annot", "printcraft-content", "printcraft-cos", + "printcraft-model", "printcraft-render", "thiserror 2.0.21", ] diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d3ddff110..1de5faa08 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -275,6 +275,15 @@ impl Automation { "doc_optimize" => self.doc_optimize(&a)?, "doc_initial_view" => self.doc_initial_view(&a)?, "doc_revisions" => self.doc_revisions(&a)?, + "doc_audit_space" => { + let rows: Vec = self + .doc(&a)? + .audit_space() + .iter() + .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) + .collect(); + json!({ "categories": rows }) + } "doc_open_revision" => { let id = self.doc(&a)?.id; let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 90dce2e44..35d12a96c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -804,6 +804,10 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("doc_audit_space", "Audit space usage", "How many bytes each kind of content takes and its share of the file (PDF Optimizer ▸ Audit space usage): images, content streams, fonts, forms, comments, structure, bookmarks, … and document overhead.") + .ro() + .cmd("optimize.advanced") + .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .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.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 8a7229e9b..80dc1d004 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -635,6 +635,19 @@ trailer << /Root 1 0 R >> assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_)))); } +#[test] +fn auditing_space_through_tools() { + let dir = workdir("audit"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_audit_space", json!({ "doc": doc })); + let rows = r["categories"].as_array().unwrap(); + let total: f64 = rows.iter().map(|x| x["percent"].as_f64().unwrap()).sum(); + assert!((total - 100.0).abs() < 0.1, "{r}"); + let content = rows.iter().find(|x| x["category"] == "Content Streams").unwrap(); + assert!(content["bytes"].as_u64().unwrap() > 0, "{r}"); +} + #[test] fn exporting_all_images_through_tools() { let dir = workdir("export-all-images"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9ed0dcefc..8a1377515 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -162,6 +162,11 @@ impl Document { self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) } + /// PDF Optimizer ▸ Audit space usage. + pub fn audit_space(&self) -> Vec { + self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() + } + /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index ebb619439..82a12690f 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] printcraft-cos = { workspace = true } printcraft-content = { workspace = true } +printcraft-model = { workspace = true } printcraft-annot = { workspace = true } thiserror.workspace = true # JPEG decoding (zune-jpeg) and encoding, and bicubic resampling; already in the tree. diff --git a/crates/optimize/src/audit.rs b/crates/optimize/src/audit.rs new file mode 100644 index 000000000..f5105d6e0 --- /dev/null +++ b/crates/optimize/src/audit.rs @@ -0,0 +1,226 @@ +//! PDF Optimizer ▸ Audit space usage: how many bytes each kind of content takes. +//! +//! Every object is sized as it would be written (dictionary plus stream data) and put in the +//! first category that reaches it; what remains (the catalog, page tree, info, metadata, +//! cross-reference data and anything unreachable) is document overhead. + +use std::collections::{HashMap, HashSet}; + +use printcraft_cos::{Dict, Document, ObjRef, Object}; + +/// The categories, in Acrobat's order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceCategory { + Images, + ContentStreams, + Fonts, + XObjectForms, + StructureInfo, + Bookmarks, + Thumbnails, + Comments, + Forms, + NamedDestinations, + ColorSpaces, + Shadings, + Patterns, + ExtendedGraphicsStates, + EmbeddedFiles, + DocumentOverhead, +} + +impl SpaceCategory { + pub const ALL: [SpaceCategory; 16] = [ + SpaceCategory::Images, + SpaceCategory::ContentStreams, + SpaceCategory::Fonts, + SpaceCategory::XObjectForms, + SpaceCategory::StructureInfo, + SpaceCategory::Bookmarks, + SpaceCategory::Thumbnails, + SpaceCategory::Comments, + SpaceCategory::Forms, + SpaceCategory::NamedDestinations, + SpaceCategory::ColorSpaces, + SpaceCategory::Shadings, + SpaceCategory::Patterns, + SpaceCategory::ExtendedGraphicsStates, + SpaceCategory::EmbeddedFiles, + SpaceCategory::DocumentOverhead, + ]; + + pub fn label(self) -> &'static str { + match self { + SpaceCategory::Images => "Images", + SpaceCategory::ContentStreams => "Content Streams", + SpaceCategory::Fonts => "Fonts", + SpaceCategory::XObjectForms => "X Object Forms", + SpaceCategory::StructureInfo => "Structure Info", + SpaceCategory::Bookmarks => "Bookmarks", + SpaceCategory::Thumbnails => "Thumbnails", + SpaceCategory::Comments => "Comments", + SpaceCategory::Forms => "Forms", + SpaceCategory::NamedDestinations => "Named Destinations", + SpaceCategory::ColorSpaces => "Color Spaces", + SpaceCategory::Shadings => "Shadings", + SpaceCategory::Patterns => "Patterns", + SpaceCategory::ExtendedGraphicsStates => "Extended Graphics States", + SpaceCategory::EmbeddedFiles => "Embedded Files", + SpaceCategory::DocumentOverhead => "Document Overhead", + } + } +} + +/// One row of the audit. +#[derive(Clone, Debug, PartialEq)] +pub struct SpaceUse { + pub category: SpaceCategory, + pub bytes: u64, + /// Share of the file (0–100). + pub percent: f64, +} + +struct Audit<'a> { + doc: &'a Document, + owner: HashMap, +} + +impl Audit<'_> { + /// Claim `o` and everything it references for `cat` (objects already claimed keep theirs). + fn claim(&mut self, o: &Object, cat: SpaceCategory) { + let mut stack = vec![o.clone()]; + let mut seen = HashSet::new(); + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => { + if !seen.insert(r.num) || self.owner.contains_key(&r.num) { + continue; + } + // Pages belong to the page tree, not to whatever links to them. + let obj = self.doc.get(r); + if obj.as_dict().is_some_and(|d| matches!(d.name(b"Type"), Some(b"Page" | b"Pages"))) { + continue; + } + self.owner.insert(r.num, cat); + stack.push((*obj).clone()); + } + Object::Array(a) => stack.extend(a), + Object::Dict(d) => stack.extend(d.iter().filter(|(k, _)| k.as_slice() != b"Parent" && k.as_slice() != b"P").map(|(_, v)| v.clone())), + Object::Stream(s) => stack.extend(s.dict.iter().filter(|(k, _)| k.as_slice() != b"Parent").map(|(_, v)| v.clone())), + _ => {} + } + } + } + + fn resolve_dict(&self, o: Option<&Object>) -> Dict { + o.map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default() + } + + /// A page's resources, by kind. + fn resources(&mut self, res: &Dict) { + let xobjects = self.resolve_dict(res.get(b"XObject")); + for (_, x) in xobjects.iter() { + let Some(r) = x.as_ref() else { continue }; + let is_image = matches!(&*self.doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")); + if is_image { + self.claim(x, SpaceCategory::Images); + } else { + // A form: its own resources first, by kind, then the form itself. + if let Object::Stream(s) = &*self.doc.get(r) { + let inner = self.resolve_dict(s.dict.get(b"Resources")); + if let std::collections::hash_map::Entry::Vacant(e) = self.owner.entry(r.num) { + e.insert(SpaceCategory::XObjectForms); + self.resources(&inner); + } + } + self.claim(x, SpaceCategory::XObjectForms); + } + } + for (key, cat) in [ + (&b"Font"[..], SpaceCategory::Fonts), + (b"ColorSpace", SpaceCategory::ColorSpaces), + (b"Shading", SpaceCategory::Shadings), + (b"Pattern", SpaceCategory::Patterns), + (b"ExtGState", SpaceCategory::ExtendedGraphicsStates), + ] { + if let Some(v) = res.get(key) { + self.claim(v, cat); + } + } + } +} + +fn size(doc: &Document, num: u32) -> u64 { + let obj = doc.get(ObjRef::new(num, doc.generation(num))); + let mut out = Vec::new(); + printcraft_cos::serialize(&obj, &mut out); + // "n g obj\n … \nendobj\n" and its cross-reference entry. + out.len() as u64 + 20 + 20 +} + +/// Audit the space a document uses; `file_len` is the size of the file on disk (the rest is +/// overhead). +pub fn audit_space(doc: &Document, file_len: u64) -> Vec { + let mut a = Audit { doc, owner: HashMap::new() }; + let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default(); + let pages = printcraft_model::pages(doc); + // Order matters: the most specific owners first. + if let Some(s) = cat.get(b"StructTreeRoot") { + // Structure elements point at pages and annotations; those stay out (claim skips pages, + // and annotations are claimed first below). + for p in &pages { + if let Some(annots) = p.dict.get(b"Annots") { + for x in doc.resolve(annots).as_array().cloned().unwrap_or_default() { + let widget = x.as_ref().and_then(|r| doc.get(r).as_dict().map(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap_or(false); + a.claim(&x, if widget { SpaceCategory::Forms } else { SpaceCategory::Comments }); + } + } + } + a.claim(s, SpaceCategory::StructureInfo); + } + if let Some(f) = cat.get(b"AcroForm") { + a.claim(f, SpaceCategory::Forms); + } + for p in &pages { + if let Some(annots) = p.dict.get(b"Annots") { + for x in doc.resolve(annots).as_array().cloned().unwrap_or_default() { + let widget = x.as_ref().and_then(|r| doc.get(r).as_dict().map(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap_or(false); + a.claim(&x, if widget { SpaceCategory::Forms } else { SpaceCategory::Comments }); + } + } + if let Some(c) = p.dict.get(b"Contents") { + a.claim(c, SpaceCategory::ContentStreams); + } + let res = a.resolve_dict(p.dict.get(b"Resources")); + a.resources(&res); + if let Some(t) = p.dict.get(b"Thumb") { + a.claim(t, SpaceCategory::Thumbnails); + } + } + if let Some(o) = cat.get(b"Outlines") { + a.claim(o, SpaceCategory::Bookmarks); + } + let names = a.resolve_dict(cat.get(b"Names")); + if let Some(d) = names.get(b"Dests").or_else(|| cat.get(b"Dests")) { + a.claim(d, SpaceCategory::NamedDestinations); + } + if let Some(e) = names.get(b"EmbeddedFiles") { + a.claim(e, SpaceCategory::EmbeddedFiles); + } + let mut bytes: HashMap = HashMap::new(); + for num in doc.object_numbers() { + if let Some(c) = a.owner.get(&num) { + *bytes.entry(*c).or_default() += size(doc, num); + } + } + let counted: u64 = bytes.values().sum(); + let total = file_len.max(counted).max(1); + bytes.insert(SpaceCategory::DocumentOverhead, total - counted); + SpaceCategory::ALL + .into_iter() + .map(|c| { + let b = bytes.get(&c).copied().unwrap_or(0); + SpaceUse { category: c, bytes: b, percent: b as f64 * 100.0 / total as f64 } + }) + .collect() +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 8a69bd54a..eeea90699 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -15,8 +15,11 @@ //! fields are discarded by `printcraft-redact`'s Remove Hidden Information, which the engine //! runs alongside. +mod audit; mod images; +pub use audit::{SpaceCategory, SpaceUse, audit_space}; + use printcraft_cos::{Document, ObjRef, Object, Stream}; pub use images::effective_resolutions; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 54f55eb22..f6662820c 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use printcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; -use printcraft_optimize::{Compression, ImageSettings, Settings, effective_resolutions, optimize}; +use printcraft_optimize::{Compression, ImageSettings, 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). @@ -175,3 +175,20 @@ fn discards_and_clean_up() { let vp = c.get(b"ViewerPreferences").and_then(|v| v.as_dict().cloned()).unwrap(); assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay"); } + +#[test] +fn space_audit_shares_the_file_out_by_kind() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 300, 300, 3, false, None); + page(&mut doc, &[("Im0", img)], "q 300 0 0 300 0 0 cm /Im0 Do Q BT ET"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + let audit = audit_space(&doc, bytes.len() as u64); + assert_eq!(audit.len(), SpaceCategory::ALL.len()); + let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().clone(); + let (images, content, overhead) = (get(SpaceCategory::Images), get(SpaceCategory::ContentStreams), get(SpaceCategory::DocumentOverhead)); + assert!(images.percent > 80.0, "{audit:?}"); + assert!(content.bytes > 0 && overhead.bytes > 0, "{audit:?}"); + assert_eq!(audit.iter().map(|u| u.bytes).sum::(), bytes.len() as u64); + assert!((audit.iter().map(|u| u.percent).sum::() - 100.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f86a5aed3..b99427ebb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -651,6 +651,16 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); optimize_now = ok; close = ok || cancel; + if std::mem::take(&mut app.optimize_draft.audit) { + app.space_audit = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.audit_space()).unwrap_or_default(); + next = Dialog::AuditSpace; + } + return; + } + Dialog::AuditSpace => { + if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { + next = Dialog::Optimize; + } return; } Dialog::Sign => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 06b1bd403..19b686a80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -187,6 +187,8 @@ pub enum Dialog { CreateStamp, /// Prepare for accessibility ▸ Add alternate text. AltText, + /// PDF Optimizer ▸ Audit space usage (then back to the optimizer). + AuditSpace, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -290,6 +292,8 @@ pub struct PrintCraftApp { pub a11y: a11y_ui::A11yState, pub a11y_skipped: std::collections::BTreeSet, pub alt_draft: a11y_ui::AltDraft, + /// The last space audit. + pub space_audit: Vec, /// Combine files: the files staged so far. pub combine_draft: Vec, /// The custom stamp library, and the stamp being created. @@ -418,6 +422,7 @@ impl PrintCraftApp { a11y: a11y_ui::A11yState::default(), a11y_skipped: Default::default(), alt_draft: Default::default(), + space_audit: Vec::new(), combine_draft: Vec::new(), custom_stamps: Vec::new(), stamp_draft: Default::default(), diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 0531c8b66..2d0f7bab2 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -22,11 +22,13 @@ pub struct OptimizeDraft { pub settings: Settings, /// Remove Hidden Information categories to discard. pub discard: Vec, + /// "Audit space usage…" was pressed. + pub audit: bool, } impl Default for OptimizeDraft { fn default() -> Self { - Self { tab: OptimizeTab::Images, settings: Settings::default(), discard: Vec::new() } + Self { tab: OptimizeTab::Images, settings: Settings::default(), discard: Vec::new(), audit: false } } } @@ -133,17 +135,49 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } ui.add_space(12.0); let (mut ok, mut cancel) = (false, false); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { - ok = true; - } - if widgets::pill_button(ui, "Cancel", false).clicked() { - cancel = true; + ui.horizontal(|ui| { + if widgets::pill_button(ui, "Audit space usage…", false).clicked() { + d.audit = true; } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "OK", true).clicked() { + ok = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); }); (ok, cancel) } +/// Audit Space Usage: bytes and share of the file per kind of content. +pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[printcraft_engine::optimize::SpaceUse], t: &Tokens) -> bool { + ui.label(egui::RichText::new("Space Audit").font(crate::theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([24.0, 4.0]).show(ui, |ui| { + for h in ["Description", "Bytes", "Percentage"] { + ui.label(egui::RichText::new(h).color(t.text_muted)); + } + ui.end_row(); + let total: u64 = rows.iter().map(|r| r.bytes).sum(); + for r in rows.iter().filter(|r| r.bytes > 0) { + ui.label(r.category.label()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(r.bytes.to_string())); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(format!("{:.2}%", r.percent))); + ui.end_row(); + } + ui.label(egui::RichText::new("Total").strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(total.to_string()).strong())); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new("100.00%").strong())); + ui.end_row(); + }); + ui.add_space(12.0); + let mut ok = false; + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, "OK", true).clicked()); + ok +} + impl PrintCraftApp { /// Optimize PDF with the dialog's choices and save the copy. pub fn optimize_with_draft(&mut self) { diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index aac36aca8..90a35c26c 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -351,3 +351,18 @@ fn advanced_search_lists_results_with_context() { h.run_steps(2); assert!(!h.state().views[i].find.as_ref().unwrap().in_panel); } + +#[test] +fn optimizer_audits_space_usage() { + let mut h = harness(); + assert!(h.state_mut().execute("optimize.advanced")); + h.run_steps(2); + h.get_by_label("Audit space usage…").click(); + h.run_steps(3); + h.get_by_label("Space Audit"); + h.get_by_label("Content Streams"); + h.get_by_label("Total"); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Optimize), "back to the optimizer"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4fc62c4a0..1730eb07f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7427,11 +7427,11 @@ title = "Audit space usage" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_audit_space"] +evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage"] +notes = "PDF Optimizer ▸ Audit space usage: 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages" [[feature]] id = "optimize.optimizer-presets"