fix: Space Audit lines up its columns and counts form fonts as fonts (#2118)

* fix(optimize): count what form fields and comments draw with by kind

The space audit claimed the AcroForm and annotations before any page
resources, so the fonts in /DR and in widget and comment appearance
streams were counted as Forms or Comments. A fillable form with an
embedded field font showed mostly "Forms" and almost no "Fonts".

Resources are now sorted by kind first, wherever they are used: page
resources, the form's default resources and every annotation
appearance (one stream or a dictionary of states). A form XObject's
other entries now count as X Object Forms instead of overhead.

Sorting nested forms' resources recursed once per form, so a long
chain of nested forms overflowed the stack. The recursion is now
bounded at 64 levels; deeper forms are still counted as X Object
Forms through the iterative walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): line up the Space Audit columns and say what it shows

The percentages were drawn at the dialog's far edge, away from their
header and outside the striped rows: egui gives a grid's last column
the rest of the window, and the right-to-left layout used to
right-align the numbers filled all of it. Every cell is now laid out
first and given exactly its column's width; numeric columns and
their headers are right-aligned, and the description column takes
the rest, so the stripes span the dialog.

A caption under the title says the table is what takes up the file's
space now (not what optimizing would save), translated in every
catalogue. The parity notes no longer call the space audit missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refresh catalog hashes after merging main

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Aleksander Helgaker
2026-10-15 02:45:58 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 716a957186
commit b369a50b68
27 changed files with 273 additions and 78 deletions
+21 -21
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0576900abcb7d419ec81843274632db330daaa5964c91ccb5933b2a3053257cc"
sha256 = "22f3fdd8849e6ef0f8dca9dcbb4f7a14835162379b84f89de05eafdc68f3557d"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "aaf94f24b7fb7c7fcbbd9003ba01e8fe5e775497d5c691e161dadadb0a85ac18"
sha256 = "96bf1d0993b3d1e19fb4953c37f47649d7b7e373d600b17f12c8aca2e3644d57"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4d25e7a10678751d8fecf8ee8688e04343c807b9565a21e72bf784cdd8e30238"
sha256 = "78569ef1bffd54432b57ce84d21f1c65e6e3cf60f47f19a284b4d5e7a4c4aa71"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bfa7f7d2cc008f676c5c533cda9f6b9415c8b6cfe43d2c88fab62440825cb57b"
sha256 = "e3e2e7b926767e32d1d4c105749d8f2fa570245b4ed6cdddf0ec6ff0f19eee24"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "003747152cfb5f42ccb2b3e77351902a2594e2a263cc0750e122d6f1f94802d1"
sha256 = "aca4600c31bbc48a9c46328643704850f383cf799f7fed4651aaf1f0bcecc1b7"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "dc3267d8d4882998e32de736fb28ebc247e5102530fea852caa585f1c43d3150"
sha256 = "0d5992212ead5820c0686b9a1990f29949d347e73d4c485a66e029c8edbaccb0"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "869505afeb011a27067fa55f1521a8d06ab3efd026fed4854610816acca13926"
sha256 = "bae65abd8480f3dfcb36fbdc9ad7578adf78e7630e3c81ffc4cc7351d61cfd55"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cffb456aac29be21da005ca52ae8131f0c07430e1723dd6da61073bba7501c8b"
sha256 = "9db8eaa561c362f9efcf44f4b5f2c380615dead8da29e66fbe49085c71c4af13"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7db981779e3c478b51b8c8aba71a53dc8153165e31ea57096d1307d30027b658"
sha256 = "96a8ed3759e00d5b400472d186d418b88b1a0c33fa15644399506d998975083b"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6b6197bc0dd5b93813d5d6652c7ba2a3dc9b0d06eaed65292837a8f15d880f43"
sha256 = "84900cd6c913d77f55d229f2204e778136029f52772d82a0230525a24fada4a1"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e6c9510edf8cb9677be5b830d6626740b8f2821799211701d8d16708fa9408b5"
sha256 = "944d8e2bb7f5ea24adac2251a4f3f17af971f521e70026727cf17e362339bbea"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9f6d11f2ccf4c873a16d0f2ae3dddd449f7fad1238126b968ed8813e293d0ee8"
sha256 = "4716f4e2124a669634e68f93a0d99e339c531ade667b1cb6c769f9349cd15517"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "15f572ed291aebc26911915e7647aef30998787a2bc87128addd273a84678406"
sha256 = "161b8fec2d1d832d2a88c479decc0f666cda01ccec5f60d1338b54ba11242358"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "35f1711fd3358dd29c784769bd9ff2094f977bf78cb9146eefa2f45f62f80d52"
sha256 = "e7fd488de8417519614725f150ba3621c86abcf97e70bd4dfcc4fb01bd09d7ac"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8974dfe02d8c8f8453a156d68846f2171334fcda70d5fc795b53b52e13c2eb8f"
sha256 = "df9c65e807e3bcd86b283248510ccca5ab470ae6209fe4a367a7238826416b4f"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5ef65d3f913a1f97cdd54a07f4cc819bf128dfc20de247eadb2519bb98c7f102"
sha256 = "a7fbbd81686e0001b7173b51e4feaa8d4e00cfbbcb48fef350122088a494f6d6"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "91da344ec2e6181f7c7b8f13dc0c878ccfe0600a8d464cbd188d082c6c402d4c"
sha256 = "c7550bde0d8b00e348a7c89f6ade7bb960371a75d88968bd5af588257ebc68be"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3f7534373129ce75ef26a87aaef3f5074be3d8326ded9d2d77d873e24359ec87"
sha256 = "10a56fcf63ac1714ec23dd57ee5ddf7e0d16fd7eaebf28b065e58ed96c9f5d8e"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d045ebd97c1383e63ea8b9ff2a4279f8484357ff88b15f4bb4bb2cacce63022d"
sha256 = "f111d02153db4277b6cddb77c5823ac30e1377f3aae59bca962a5884f3857d48"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "afd09cdbcb97339d57a0be0010512ba8336d1b40ffcfbd2b4730808851c71d64"
sha256 = "30b8a65a5fae8cf2c3fd5d43d53991095d2f00042899a66c3949573b28d4e3c4"
[[asset]]
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "714b6a58f5cd4f0c9220e88d2d3f7c584b64829b0336e23d7893209c84babf5d"
sha256 = "ad4439af89320c314064d1062ab057b53366d857c6dd0deafbea7069ffc5920c"
[[asset]]
path = "vendor/vello_common/assets/probe.png"
+75 -32
View File
@@ -1,8 +1,10 @@
//! 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.
//! Every object is sized as it would be written (dictionary plus stream data). Resources go by
//! kind first, wherever they are used: a font or image a form field or comment draws with is
//! still a font or image. Every other object goes to 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};
@@ -80,6 +82,11 @@ pub struct SpaceUse {
pub percent: f64,
}
/// How deep form XObjects nested in each other's resources are sorted by kind. Deeper forms
/// (and what they use) are still counted, as X Object Forms; the limit keeps a crafted chain
/// of nested forms from overflowing the stack.
const MAX_FORM_DEPTH: usize = 64;
struct Audit<'a> {
doc: &'a Document,
owner: HashMap<u32, SpaceCategory>,
@@ -116,24 +123,22 @@ impl Audit<'_> {
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) {
/// A resource dictionary, by kind; `depth` counts the form XObjects it is nested in.
fn resources(&mut self, res: &Dict, depth: usize) {
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) {
match &*self.doc.get(r) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self.claim(x, SpaceCategory::Images),
Object::Stream(s) if depth < MAX_FORM_DEPTH && !self.owner.contains_key(&r.num) => {
// A form: its own resources first, by kind, then the rest of it. Owning it
// first stops a form that draws itself.
self.owner.insert(r.num, SpaceCategory::XObjectForms);
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.resources(&inner, depth + 1);
self.claim(&Object::Dict(s.dict.clone()), SpaceCategory::XObjectForms);
}
self.claim(x, SpaceCategory::XObjectForms);
_ => self.claim(x, SpaceCategory::XObjectForms),
}
}
for (key, cat) in [
@@ -148,6 +153,40 @@ impl Audit<'_> {
}
}
}
/// The resources an annotation's appearances draw with, by kind. Each of /N, /R and /D is
/// one appearance stream or a dictionary of them, one per state.
fn appearance_resources(&mut self, annot: &Object) {
let ap = self.resolve_dict(self.resolve_dict(Some(annot)).get(b"AP"));
for (_, v) in ap.iter() {
let streams = match &*self.doc.resolve(v) {
Object::Stream(s) => vec![s.dict.clone()],
Object::Dict(states) => states
.iter()
.filter_map(|(_, s)| match &*self.doc.resolve(s) {
Object::Stream(s) => Some(s.dict.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
};
for dict in streams {
let res = self.resolve_dict(dict.get(b"Resources"));
self.resources(&res, 1);
}
}
}
}
/// A page's annotations.
fn annotations(doc: &Document, page: &Dict) -> Vec<Object> {
page.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default()
}
/// Form fields' widgets belong to the form; every other annotation is a comment.
fn annotation_category(doc: &Document, annot: &Object) -> SpaceCategory {
let widget = annot.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget")));
if widget { SpaceCategory::Forms } else { SpaceCategory::Comments }
}
fn size(doc: &Document, num: u32) -> u64 {
@@ -164,16 +203,27 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
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 = pdfcraft_model::pages(doc);
// Order matters: the most specific owners first.
// Order matters: the most specific owners first. Resources by kind before anything that
// uses them: a content stream's dictionary may point at what it draws (PdfCraft's /PCAdded
// records an added image), and form fields and comments draw with fonts and images too.
for p in &pages {
let res = a.resolve_dict(p.dict.get(b"Resources"));
a.resources(&res, 0);
}
let form = a.resolve_dict(cat.get(b"AcroForm"));
let form_resources = a.resolve_dict(form.get(b"DR"));
a.resources(&form_resources, 0);
for p in &pages {
for x in annotations(doc, &p.dict) {
a.appearance_resources(&x);
}
}
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).
// and annotations are claimed first).
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 });
}
for x in annotations(doc, &p.dict) {
a.claim(&x, annotation_category(doc, &x));
}
}
a.claim(s, SpaceCategory::StructureInfo);
@@ -182,16 +232,9 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
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 });
}
for x in annotations(doc, &p.dict) {
a.claim(&x, annotation_category(doc, &x));
}
// Resources before the content: a content stream's dictionary may point at what it
// draws (PdfCraft's /PCAdded records an added image), which stays an image or font.
let res = a.resolve_dict(p.dict.get(b"Resources"));
a.resources(&res);
if let Some(c) = p.dict.get(b"Contents") {
a.claim(c, SpaceCategory::ContentStreams);
}
+94
View File
@@ -245,6 +245,100 @@ fn space_audit_counts_images_a_content_stream_dictionary_points_at_as_images() {
assert!(images.percent > 80.0, "{audit:?}");
}
#[test]
fn space_audit_counts_what_fields_and_comments_draw_with_by_kind() {
// A font or image a form field or comment draws with is still a font or image. The form
// was claimed first, so its fonts made a fillable form look like mostly "Forms".
let mut doc = Document::new_empty();
let p = page(&mut doc, &[], "BT ET");
let font = |doc: &mut Document| {
let file = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![7; 40_000])));
let mut desc = Dict::new();
desc.set(b"Type".to_vec(), Object::name("FontDescriptor"));
desc.set(b"FontFile2".to_vec(), Object::Ref(file));
let desc = doc.add(Object::Dict(desc));
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("TrueType"));
f.set(b"FontDescriptor".to_vec(), Object::Ref(desc));
doc.add(Object::Dict(f))
};
let (field_font, comment_font) = (font(&mut doc), font(&mut doc));
let icon = image(&mut doc, 200, 200, 3, false, None);
let appearance = |doc: &mut Document, kind: &str, name: &str, r: ObjRef| {
let mut entries = Dict::new();
entries.set(name.as_bytes().to_vec(), Object::Ref(r));
let mut res = Dict::new();
res.set(kind.as_bytes().to_vec(), Object::Dict(entries));
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"Resources".to_vec(), Object::Dict(res));
Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, b"q Q".to_vec()))))
};
let annot = |doc: &mut Document, subtype: &str, ap: Object| {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name(subtype));
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
d.set(b"P".to_vec(), Object::Ref(p));
let mut aps = Dict::new();
aps.set(b"N".to_vec(), ap);
d.set(b"AP".to_vec(), Object::Dict(aps));
Object::Ref(doc.add(Object::Dict(d)))
};
// A text field's appearance uses the form's default font; a check box's "on" state (one of
// a dictionary of states) draws an image; a free-text comment has a font of its own.
let text_ap = appearance(&mut doc, "Font", "F1", field_font);
let text = annot(&mut doc, "Widget", text_ap);
let mut states = Dict::new();
states.set(b"On".to_vec(), appearance(&mut doc, "XObject", "Im0", icon));
let check = annot(&mut doc, "Widget", Object::Dict(states));
let comment_ap = appearance(&mut doc, "Font", "F2", comment_font);
let comment = annot(&mut doc, "FreeText", comment_ap);
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![text.clone(), check.clone(), comment]))).unwrap();
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(field_font));
let mut dr = Dict::new();
dr.set(b"Font".to_vec(), Object::Dict(fonts));
let mut form = Dict::new();
form.set(b"Fields".to_vec(), Object::Array(vec![text, check]));
form.set(b"DR".to_vec(), Object::Dict(dr));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
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);
let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().bytes;
assert!(get(SpaceCategory::Fonts) > 80_000, "both fonts are fonts: {audit:?}");
assert!(get(SpaceCategory::Images) > 2_000, "the check box's image is an image: {audit:?}");
assert!(get(SpaceCategory::Forms) < 2_000 && get(SpaceCategory::Comments) < 1_000, "{audit:?}");
assert!(get(SpaceCategory::Forms) > 0 && get(SpaceCategory::Comments) > 0, "the fields and the comment are still counted: {audit:?}");
}
#[test]
fn space_audit_survives_deeply_nested_forms() {
// Each form draws the next; sorting their resources by kind recursed once per form, so a
// long enough chain overflowed the stack.
let mut doc = Document::new_empty();
let mut next: Option<ObjRef> = None;
for _ in 0..20_000 {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Form"));
if let Some(n) = next {
let mut x = Dict::new();
x.set(b"X".to_vec(), Object::Ref(n));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(x));
d.set(b"Resources".to_vec(), Object::Dict(res));
}
next = Some(doc.add(Object::Stream(Stream::from_raw(d, b"/X Do".to_vec()))));
}
page(&mut doc, &[("X", next.unwrap())], "/X Do");
let audit = audit_space(&doc, 0);
let forms = audit.iter().find(|u| u.category == SpaceCategory::XObjectForms).unwrap();
assert!(forms.bytes > 20_000 * 40, "every form is counted, however deep: {audit:?}");
}
#[test]
fn invalid_links_and_unreferenced_destinations_go() {
let mut doc = Document::new_empty();
+1
View File
@@ -583,6 +583,7 @@
Remove unused objects and merge identical ones إزالة الكائنات غير المستخدمة ودمج المتطابقة
Audit space usage… تدقيق استخدام المساحة…
Space Audit تدقيق المساحة
What takes up the space in this file, by kind of content. ما الذي يشغل المساحة في هذا الملف، حسب نوع المحتوى.
Bytes بايت
Content Streams تدفقات المحتوى
X Object Forms نماذج X Object
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -257,6 +257,7 @@
Remove unused objects and merge identical ones Премахни неизползваните обекти и обедини еднаквите
Audit space usage… Анализ на използваното място…
Space Audit Анализ на мястото
What takes up the space in this file, by kind of content. Какво заема мястото в този файл, по вид съдържание.
Bytes Байтове
Content Streams Потоци на съдържанието
X Object Forms Форми X Object
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -663,6 +663,7 @@ key Space Mezerník
Remove unused objects and merge identical ones Odebrat nepoužívané objekty a sloučit shodné
Audit space usage… Analyzovat využití místa…
Space Audit Analýza využití místa
What takes up the space in this file, by kind of content. Co zabírá místo v tomto souboru, podle druhu obsahu.
Bytes Bajty
Content Streams Proudy obsahu
X Object Forms Objekty Form XObject
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -556,6 +556,7 @@
Remove unused objects and merge identical ones Unbenutzte Objekte entfernen und identische zusammenführen
Audit space usage… Speicherbelegung prüfen…
Space Audit Speicherbelegung
What takes up the space in this file, by kind of content. Wofür der Platz in dieser Datei verwendet wird, nach Art des Inhalts.
Bytes Bytes
Content Streams Inhalts-Streams
X Object Forms XObject-Formulare
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -1883,6 +1883,7 @@
Remove unused objects and merge identical ones Αφαίρεση αχρησιμοποίητων αντικειμένων και συγχώνευση πανομοιότυπων
Audit space usage… Έλεγχος χρήσης χώρου…
Space Audit Έλεγχος χώρου
What takes up the space in this file, by kind of content. Τι καταλαμβάνει τον χώρο σε αυτό το αρχείο, ανά είδος περιεχομένου.
Bytes Byte
Content Streams Ροές περιεχομένου
X Object Forms Φόρμες XObject
Can't render this file because it contains an unexpected character in line 475 and column 9.
+1
View File
@@ -579,6 +579,7 @@
Remove unused objects and merge identical ones Quitar objetos sin usar y fusionar los idénticos
Audit space usage… Auditar uso del espacio…
Space Audit Auditoría de espacio
What takes up the space in this file, by kind of content. Qué ocupa el espacio de este archivo, por tipo de contenido.
Bytes Bytes
Content Streams Secuencias de contenido
X Object Forms Formularios XObject
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -556,6 +556,7 @@
Remove unused objects and merge identical ones Retirer les objets inutilisés et fusionner les identiques
Audit space usage… Auditer l'espace utilisé…
Space Audit Audit de l'espace
What takes up the space in this file, by kind of content. Ce qui occupe l'espace dans ce fichier, par type de contenu.
Bytes Octets
Content Streams Flux de contenu
X Object Forms Formulaires XObject
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -554,6 +554,7 @@
Remove unused objects and merge identical ones Nem használt objektumok eltávolítása és az azonosak egyesítése
Audit space usage… Helyfoglalás vizsgálata…
Space Audit Helyfoglalás
What takes up the space in this file, by kind of content. Mi foglalja a helyet ebben a fájlban, tartalomtípusonként.
Bytes Bájt
Content Streams Tartalomfolyamok
X Object Forms XObject-űrlapok
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -580,6 +580,7 @@
Remove unused objects and merge identical ones Rimuovi oggetti inutilizzati e unisci quelli identici
Audit space usage… Analizza uso dello spazio…
Space Audit Analisi dello spazio
What takes up the space in this file, by kind of content. Cosa occupa lo spazio in questo file, per tipo di contenuto.
Bytes Byte
Content Streams Flussi di contenuto
X Object Forms Form XObject
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -583,6 +583,7 @@
Remove unused objects and merge identical ones 未使用オブジェクトを削除し同一オブジェクトを統合
Audit space usage… 容量の使用状況を確認…
Space Audit 容量の使用状況
What takes up the space in this file, by kind of content. このファイルの容量を何が占めているか、コンテンツの種類別に示します。
Bytes バイト数
Content Streams コンテンツストリーム
X Object Forms フォーム XObject
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -543,6 +543,7 @@
Remove unused objects and merge identical ones Пайдаланылмаған нысандарды алып тастау және ұқсастарын біріктіру
Audit space usage… Кеңістікті пайдалануды тексеру…
Space Audit Кеңістікті пайдалану аудиті
What takes up the space in this file, by kind of content. Бұл файлдағы орынды не алып жатыр, мазмұн түрі бойынша.
Bytes Байт
Content Streams Мазмұн ағындары
X Object Forms XObject формалары
1 Menu Мәзір
543 Remove unused objects and merge identical ones Пайдаланылмаған нысандарды алып тастау және ұқсастарын біріктіру
544 Audit space usage… Кеңістікті пайдалануды тексеру…
545 Space Audit Кеңістікті пайдалану аудиті
546 What takes up the space in this file, by kind of content. Бұл файлдағы орынды не алып жатыр, мазмұн түрі бойынша.
547 Bytes Байт
548 Content Streams Мазмұн ағындары
549 X Object Forms XObject формалары
+1
View File
@@ -1096,6 +1096,7 @@
Sort items Rūšiuoti elementus
Source Šaltinis
Space Audit Vietos naudojimo analizė
What takes up the space in this file, by kind of content. Kas užima vietą šiame faile, pagal turinio rūšį.
Special Specialusis
Split document Skaidyti dokumentą
Split into files… Skaidyti į failus…
Can't render this file because it contains an unexpected character in line 401 and column 9.
+1
View File
@@ -578,6 +578,7 @@
Remove unused objects and merge identical ones Usuń nieużywane obiekty i scal identyczne
Audit space usage… Analiza wykorzystania miejsca…
Space Audit Analiza miejsca
What takes up the space in this file, by kind of content. Co zajmuje miejsce w tym pliku, według rodzaju zawartości.
Bytes Bajty
Content Streams Strumienie zawartości
X Object Forms Obiekty Form XObject
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -213,6 +213,7 @@
Remove unused objects and merge identical ones Remover objetos não utilizados e mesclar os idênticos
Audit space usage… Auditar uso de espaço…
Space Audit Auditoria de espaço
What takes up the space in this file, by kind of content. O que ocupa o espaço deste arquivo, por tipo de conteúdo.
Bytes Bytes
Content Streams Fluxos de conteúdo
X Object Forms Formulários XObject
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -576,6 +576,7 @@
Remove unused objects and merge identical ones Удалить неиспользуемые объекты и объединить одинаковые
Audit space usage… Анализ использования места…
Space Audit Анализ места
What takes up the space in this file, by kind of content. Что занимает место в этом файле, по типу содержимого.
Bytes Байты
Content Streams Потоки содержимого
X Object Forms Формы X Object
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1705,6 +1705,7 @@
Sort items Razvrsti elemente
Source Vir
Space Audit Pregled prostora
What takes up the space in this file, by kind of content. Kaj zaseda prostor v tej datoteki, po vrsti vsebine.
Special Posebno
Split Razdeli
Split document Razdeli dokument
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -560,6 +560,7 @@ comment menu Edit సవరించు
Remove unused objects and merge identical ones ఉపయోగించని ఆబ్జెక్ట్‌లను తీసివేసి, ఒకేలా ఉన్నవాటిని విలీనం చేయి
Audit space usage… స్థల వినియోగాన్ని పరిశీలించు…
Space Audit స్థల వినియోగ పరిశీలన
What takes up the space in this file, by kind of content. ఈ ఫైల్‌లో స్థలాన్ని ఏవి ఆక్రమిస్తున్నాయి, కంటెంట్ రకం వారీగా.
Bytes బైట్‌లు
Content Streams కంటెంట్ స్ట్రీమ్‌లు
X Object Forms X ఆబ్జెక్ట్ ఫారాలు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -541,6 +541,7 @@
Remove unused objects and merge identical ones Kullanılmayan nesneleri kaldır ve aynı olanları birleştir
Audit space usage… Alan kullanımını denetle…
Space Audit Alan Denetimi
What takes up the space in this file, by kind of content. Bu dosyadaki alanı neyin kapladığı, içerik türüne göre.
Bytes Bayt
Content Streams İçerik Akışları
X Object Forms XObject Formları
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
541
542
543
544
545
546
547
+1
View File
@@ -581,6 +581,7 @@
Remove unused objects and merge identical ones Видалити невикористані об’єкти й об’єднати однакові
Audit space usage… Аналіз використання місця…
Space Audit Аналіз місця
What takes up the space in this file, by kind of content. Що займає місце в цьому файлі, за типом вмісту.
Bytes Байти
Content Streams Потоки вмісту
X Object Forms Форми XObject
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -263,6 +263,7 @@
Remove unused objects and merge identical ones 删除未使用的对象并合并相同的对象
Audit space usage… 审计空间使用情况…
Space Audit 空间审计
What takes up the space in this file, by kind of content. 此文件的空间被哪些内容占用,按内容类型列出。
Bytes 字节
Content Streams 内容流
X Object Forms XObject 表单
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -219,6 +219,7 @@
Remove unused objects and merge identical ones 刪除未使用的物件併合並相同的物件
Audit space usage… 審計空間使用情況…
Space Audit 空間審計
What takes up the space in this file, by kind of content. 此檔案的空間被哪些內容佔用,依內容類型列出。
Bytes 位元組
Content Streams 內容流
X Object Forms XObject 表單
Can't render this file because it contains an unexpected character in line 9 and column 16.
+42 -23
View File
@@ -243,33 +243,52 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
(ok, cancel)
}
/// Audit Space Usage: bytes and share of the file per kind of content.
/// Audit Space Usage: how the file's bytes are spent now, by kind of content (not what
/// optimizing would save).
pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) -> bool {
ui.label(egui::RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0)));
use egui::{RichText, TextWrapMode, WidgetText};
ui.label(RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0)));
ui.add_space(4.0);
ui.label(RichText::new(tl!("What takes up the space in this file, by kind of content.")).color(t.text_muted));
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 [tl!("Description"), tl!("Bytes"), tl!("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) {
// "Patterns" also names the redaction search patterns ("模式"); the audit's PDF
// graphics objects need their own key.
let category = if r.category == pdfcraft_engine::optimize::SpaceCategory::Patterns {
tl!("Patterns (graphics objects)").to_string()
} else {
tl!(r.category.label()).to_string()
};
ui.label(category);
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)));
// Every cell is laid out first so each column can be exactly as wide as its widest cell. A
// right-to-left layout in a grid's last column takes the rest of the window instead, which
// drew the percentages at the dialog's far edge, away from their rows.
let text = |s: RichText| WidgetText::from(s).into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body);
let header = |s: &str| text(RichText::new(s).color(t.text_muted));
let mut table = vec![[header(tl!("Description")), header(tl!("Bytes")), header(tl!("Percentage"))]];
for r in rows.iter().filter(|r| r.bytes > 0) {
// "Patterns" also names the redaction search patterns ("模式"); the audit's PDF
// graphics objects need their own key.
let category = if r.category == pdfcraft_engine::optimize::SpaceCategory::Patterns {
tl!("Patterns (graphics objects)")
} else {
tl!(r.category.label())
};
table.push([text(RichText::new(category)), text(RichText::new(r.bytes.to_string())), text(RichText::new(format!("{:.2}%", r.percent)))]);
}
let total: u64 = rows.iter().map(|r| r.bytes).sum();
table.push([
text(RichText::new(tl!("Total")).strong()),
text(RichText::new(total.to_string()).strong()),
text(RichText::new("100.00%").strong()),
]);
let gap = 24.0;
let width = |column: usize| table.iter().filter_map(|row| row.get(column)).map(|g| g.size().x).fold(0.0, f32::max);
// The description takes what the numbers leave, so the striped rows span the dialog.
let numbers = width(1) + width(2) + 2.0 * gap;
let widths = [width(0).max(ui.available_width() - numbers), width(1), width(2)];
let height = table.iter().flatten().map(|g| g.size().y).fold(ui.spacing().interact_size.y, f32::max);
egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).show(ui, |ui| {
for row in table {
for (column, (cell, width)) in row.into_iter().zip(widths).enumerate() {
// Numbers line up on their last digit.
let layout = if column == 0 { Layout::left_to_right(Align::Center) } else { Layout::right_to_left(Align::Center) };
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout)).label(cell);
}
ui.end_row();
}
ui.label(egui::RichText::new(tl!("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;
+16
View File
@@ -900,6 +900,22 @@ fn optimizer_audits_space_usage() {
assert_eq!(h.state().dialog, Some(Dialog::Optimize), "back to the optimizer");
}
#[test]
fn space_audit_numbers_line_up_under_their_headers() {
// The percentages were drawn at the dialog's far edge, away from their header and the
// striped rows: a right-to-left layout in a grid's last column takes the rest of the window.
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);
let total = h.state().space_audit.iter().map(|r| r.bytes).sum::<u64>().to_string();
let right = |label: &str| h.get_by_label(label).rect().right();
assert!((right("Percentage") - right("100.00%")).abs() < 1.0, "{} vs {}", right("Percentage"), right("100.00%"));
assert!((right("Bytes") - right(&total)).abs() < 1.0, "{} vs {}", right("Bytes"), right(&total));
assert!(right("100.00%") < right("OK") + 1.0, "the table stays inside the dialog");
}
#[test]
fn each_document_keeps_its_own_scroll_position() {
// #189: every document's page view shared one scroll position, so switching tabs showed a
+4 -2
View File
@@ -7267,6 +7267,8 @@ commands = ["optimize.advanced"]
tools = ["doc_optimize"]
evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"]
notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit"
evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"]
notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels"
[[feature]]
id = "optimize.downsample-images"
@@ -7603,8 +7605,8 @@ milestone = "M11"
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"
evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"]
notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 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. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)"
[[feature]]
id = "optimize.optimizer-presets"