fix(ui): Document Properties gains a Space Audit tab

Properties ▸ Space Audit shows the same bytes-by-kind table as the PDF
Optimizer's Audit Space Usage, which now shares its table code. The
audit walks the whole file, so it runs when the tab opens and again only
after an edit (keyed on the document and its edit generation). The tab
is also reachable as `--dialog space-audit`.

The audit table no longer overflows its dialog: egui's default minimum
column width stretched the narrow Bytes column past the computed widths,
widening the card by about 5 points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-10-15 20:59:29 +02:00
co-authored by Claude Opus 5.5
parent b349ef480f
commit d48ce4ae1b
5 changed files with 91 additions and 12 deletions
+14 -2
View File
@@ -34,6 +34,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) {
app.session.refresh_fonts(id);
}
// Properties ▸ Space Audit walks the whole file, so it is measured once per edit, not per frame.
if dialog == Dialog::Properties(PropsTab::SpaceAudit) {
app.refresh_space_audit();
}
// Seed the editable Description fields from the document when the dialog opens.
if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
&& app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
@@ -107,6 +111,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
(PropsTab::Security, tl!("Security")),
(PropsTab::Fonts, tl!("Fonts")),
(PropsTab::Advanced, tl!("Advanced")),
(PropsTab::SpaceAudit, tl!("Space Audit")),
] {
if widgets::mode_tab(ui, label, tab == tb).clicked() {
next = Dialog::Properties(tb);
@@ -133,6 +138,11 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
ui.end_row();
};
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
if tab == PropsTab::SpaceAudit {
ui.add_space(4.0);
crate::optimize_ui::audit_table(ui, &app.space_audit, &t);
return;
}
egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
PropsTab::Description => {
row(ui, "File", crate::bidi::visual(&doc.name).into_owned());
@@ -409,7 +419,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
ui.end_row();
}
}
})
// Laid out above, outside the two-column grid.
PropsTab::SpaceAudit => {}
});
});
}
Dialog::Split => {
@@ -727,7 +739,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
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();
app.refresh_space_audit();
next = Dialog::AuditSpace;
}
return;
+5
View File
@@ -360,6 +360,7 @@ pub enum PropsTab {
Security,
Fonts,
Advanced,
SpaceAudit,
}
/// Duplicate Field: the field and the pages (all, or a range, 1-based).
@@ -682,6 +683,8 @@ pub struct PdfCraftApp {
pub cert_viewer: Option<sign_ui::CertViewer>,
/// The last space audit.
pub space_audit: Vec<pdfcraft_engine::optimize::SpaceUse>,
/// The document and edit generation `space_audit` was measured from.
pub(crate) space_audit_of: Option<(DocId, u64)>,
/// Combine files: the files staged so far.
pub combine_draft: Vec<combine_ui::CombineFile>,
/// The combine list's field-name conflict preview (computed once per list, never saved).
@@ -1001,6 +1004,7 @@ impl PdfCraftApp {
os_key_store_ids: false,
cert_viewer: None,
space_audit: Vec::new(),
space_audit_of: None,
combine_draft: Vec::new(),
combine_conflicts: Default::default(),
combine_tab: Default::default(),
@@ -2513,6 +2517,7 @@ impl PdfCraftApp {
"security" => Some(Dialog::Properties(PropsTab::Security)),
"fonts" => Some(Dialog::Properties(PropsTab::Fonts)),
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
"space-audit" => Some(Dialog::Properties(PropsTab::SpaceAudit)),
"shortcuts" => Some(Dialog::Shortcuts),
"split" => Some(Dialog::Split),
"protect" => Some(Dialog::Protect),
+31 -7
View File
@@ -7,7 +7,7 @@
use std::sync::{Arc, Mutex};
use egui::{Align, Layout};
use egui::{Align, Layout, RichText};
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings};
use pdfcraft_engine::optimizer::{OptimizeStage, Optimized};
use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport};
@@ -246,9 +246,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
/// 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 {
use egui::{RichText, TextWrapMode, WidgetText};
ui.label(RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0)));
ui.add_space(4.0);
audit_table(ui, rows, t);
ui.add_space(12.0);
let mut ok = false;
ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked()));
ok
}
/// The audit's explanation and its table: bytes and share of the file per kind of content.
/// Shared by the Optimizer's Audit Space Usage and Properties ▸ Space Audit.
pub(crate) fn audit_table(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) {
use egui::{TextWrapMode, WidgetText};
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);
// Every cell is laid out first so each column can be exactly as wide as its widest cell. A
@@ -279,7 +289,9 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S
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| {
// No minimum column width: egui's default widened a narrow Bytes column past `widths` and
// pushed the table beyond the dialog's padding.
egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).min_col_width(0.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.
@@ -290,13 +302,25 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S
ui.end_row();
}
});
ui.add_space(12.0);
let mut ok = false;
ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked()));
ok
}
impl PdfCraftApp {
/// Measure the active document's space use, unless `space_audit` already describes it as
/// it is now. With no document the audit is empty.
pub(crate) fn refresh_space_audit(&mut self) {
let Some((_, id)) = self.active_ids() else {
self.space_audit.clear();
self.space_audit_of = None;
return;
};
let Some(doc) = self.session.get(id) else { return };
let key = (id, doc.edit_generation());
if self.space_audit_of != Some(key) {
self.space_audit = doc.audit_space();
self.space_audit_of = Some(key);
}
}
/// Optimize PDF with the dialog's choices on a worker thread; the copy is saved when done.
pub fn optimize_with_draft(&mut self) {
let (settings, discard) = (self.optimize_draft.settings.clone(), self.optimize_draft.discard.clone());
+38
View File
@@ -129,3 +129,41 @@ fn document_properties_values_line_up_and_long_fields_wrap() {
let subject = h.get_by_role_and_label(Role::MultilineTextInput, "Subject").rect();
assert!(subject.height() > title.height() + 8.0, "subject field {subject:?}, title field {title:?}");
}
#[test]
fn space_audit_tab_measures_the_file_and_stays_in_the_card() {
use pdfcraft_ui_egui::{Dialog, PropsTab};
let open = |dialog: Dialog| {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture()).unwrap();
app.dialog = Some(dialog);
app
});
h.run_steps(4);
h
};
let fonts = card(&open(Dialog::Properties(PropsTab::Fonts)), "dialog");
// The tab is reachable by name, like the other Properties tabs.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture()).unwrap();
app.set_option("dialog", "space-audit").unwrap();
app
});
h.run_steps(4);
assert_eq!(h.state().dialog, Some(Dialog::Properties(PropsTab::SpaceAudit)));
assert!(!h.state().space_audit.is_empty(), "the tab measures the file when it opens");
h.get_by_label("Total");
// Switching tabs keeps the dialog the same size, and the table stays inside its padding,
// in the tab and in the Optimizer's own Audit Space Usage dialog.
let tab = card(&h, "dialog");
assert!((tab.width() - fonts.width()).abs() < 0.5, "space audit card {tab:?}, fonts card {fonts:?}");
for h in [h, open(Dialog::AuditSpace)] {
let card = card(&h, "dialog");
let pct = h.get_by_label("Percentage").rect();
assert!(pct.right() <= card.right() - 16.0, "Percentage {pct:?} in {card:?}");
}
}
+3 -3
View File
@@ -3436,8 +3436,8 @@ milestone = "M3"
status = "shipped"
commands = ["file.properties"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft"]
notes = ""
evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft", "crates/ui-egui/tests/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"]
notes = "Description, Initial View, Security, Fonts and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. No Custom tab (organize.custom-properties)"
[[feature]]
id = "organize.edit-metadata"
@@ -7597,7 +7597,7 @@ status = "shipped"
commands = ["optimize.advanced"]
tools = ["doc_audit_space"]
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)"
notes = "PDF Optimizer ▸ Audit space usage, and Document Properties ▸ Space Audit: 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"