mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the PrintCraft settings and crash-recovery folders to the new name, and both apps read settings saved under the old storage key, so upgrades keep recent files, preferences and unsaved work. The signing test fixtures keep their PrintCraft subject names (they are binary). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
8.9 KiB
Rust
229 lines
8.9 KiB
Rust
//! 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 pdfcraft_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<u32, SpaceCategory>,
|
||
}
|
||
|
||
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();
|
||
pdfcraft_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<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.
|
||
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 });
|
||
}
|
||
}
|
||
// 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);
|
||
}
|
||
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<SpaceCategory, u64> = 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()
|
||
}
|