Files
pdfcraft/crates/engine/src/catalog.rs
T
huppifluppandClaude Opus 5.5 308d22cbad M10: Enhance scanned file cleans scanned pages in place
Scan & OCR ▸ Enhance scanned file (Acrobat's Enhance ▸ Scanned document)
now cleans up scanned pages: background removal (the camera clean-up's
level 0-100), edge shadow removal (book gutters, copier borders) and
despeckle, each its own switch.

The camera tool renders the page and replaces it with a picture, which
would drop a scan's invisible OCR text, comments and links. Instead each
scan image (pictures covering at least half the page, each at least a
tenth) is decoded, cleaned and written back into its own image object at
the same size and colour space: a JPEG stays a JPEG (quality 90), other
grey/RGB images are stored losslessly, a 1-bit image stays 1-bit with its
decode array, and every other dictionary entry (SMask, Interpolate, ...)
is kept. An image several pages share is cleaned once. What can't be
cleaned (CMYK, indexed, JPEG 2000, JBIG2, CCITT, colour-key masks, broken
data, oversized images) is reported, never skipped silently; pages that
are no scan are listed too.

The engine works as a job like Optimize (scan_clean_job, run on a worker
with progress and cancel, apply_scan_clean refuses a result for an older
document state) and applies one undoable step, "Enhance scanned file".
pdfcraft-optimize exposes decode_samples/encode_samples for in-place
pixel edits. camera_clean gains clean_samples for grey and RGB samples
with separate switches and an edge-shadow pass; the camera tool's output
is unchanged. page_enhance_scan drives it headlessly.

Not yet an option of Recognize text. Refs #4119.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:56:22 +01:00

654 lines
27 KiB
Rust

//! The tool catalogue: Acrobat's "All tools" information architecture, as data.
//!
//! Every UI (the egui shell today, anything tomorrow) builds its tool panel, palette and menus
//! from this table; nothing is hard-coded in the toolkit. Each item names the command it will
//! dispatch and the milestone (plan/execution-plan.md) in which it ships, so the UI can be honest
//! about what works today.
//!
//! Source of the inventory: plan/acrobat/02-ui-ux.md §2 (observed in Acrobat Pro 26.002).
/// Where a tool or item stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Availability {
/// Works in this build.
Ready,
/// Planned; ships in the named milestone.
Planned(&'static str),
/// Cloud-only in Acrobat; PdfCraft offers an optional pluggable provider instead.
Provider,
}
#[derive(Clone, Copy, Debug)]
pub struct ToolItem {
pub label: &'static str,
pub icon: &'static str,
pub command: &'static str,
pub availability: Availability,
/// Further commands of the same tool, shown as a drop-down beside the entry: Acrobat's
/// Watermark control offers Add, Update and Remove. The first is `command` itself. A UI
/// enables each one by its own command's needs, so Update and Remove stay greyed out until
/// the document has that kind of mark. Empty for a plain entry.
pub actions: &'static [&'static str],
}
#[derive(Clone, Copy, Debug)]
pub struct ToolSection {
pub title: &'static str,
pub items: &'static [ToolItem],
}
#[derive(Clone, Copy, Debug)]
pub struct ToolGroup {
pub id: &'static str,
pub label: &'static str,
pub icon: &'static str,
/// Icon tint, as Acrobat colour-codes its tool families.
pub hue: [u8; 3],
pub badge: Option<&'static str>,
pub availability: Availability,
pub sections: &'static [ToolSection],
}
const fn item(label: &'static str, icon: &'static str, command: &'static str, availability: Availability) -> ToolItem {
ToolItem { label, icon, command, availability, actions: &[] }
}
/// An entry that opens a menu of `actions` (the first one is its `command`).
const fn menu_item(label: &'static str, icon: &'static str, actions: &'static [&'static str]) -> ToolItem {
let command = match actions {
[first, ..] => *first,
[] => "",
};
ToolItem { label, icon, command, availability: Ready, actions }
}
use Availability::{Planned, Provider, Ready};
const RED: [u8; 3] = [0xE0, 0x3E, 0x3E];
const PURPLE: [u8; 3] = [0x8E, 0x4E, 0xE6];
const BLUE: [u8; 3] = [0x3A, 0x6F, 0xE8];
const GREEN: [u8; 3] = [0x2D, 0x9D, 0x5B];
const ORANGE: [u8; 3] = [0xE8, 0x8A, 0x1A];
const TEAL: [u8; 3] = [0x14, 0x9C, 0xA8];
const PINK: [u8; 3] = [0xD6, 0x3B, 0x8F];
pub static TOOL_GROUPS: &[ToolGroup] = &[
ToolGroup {
id: "export",
label: "Export a PDF",
icon: "file-output",
hue: BLUE,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Export to",
items: &[
item("Microsoft Word (.docx)", "file-text", "export.docx", Ready),
item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")),
item("Presentation (.pptx)", "presentation", "export.pptx", Ready),
item("Image (PNG)", "image", "export.image", Ready),
item("Export all images", "image", "export.all_images", Ready),
item("HTML web page", "file-symlink", "export.html", Ready),
item("Rich Text Format (.rtf)", "file-text", "export.rtf", Ready),
item("Text (plain)", "type", "export.text", Ready),
item("PostScript / EPS", "file-down", "export.ps", Planned("M10")),
],
}],
},
ToolGroup {
id: "fill_sign",
label: "Fill & Sign",
icon: "pen-line",
hue: PURPLE,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Fill",
items: &[
item("Add text", "type", "sign.fill.text", Ready),
item("Checkmark", "check", "sign.fill.check", Ready),
item("Cross", "x", "sign.fill.cross", Ready),
item("Dot", "circle-dot", "sign.fill.dot", Ready),
item("Line", "minus", "sign.fill.line", Ready),
item("Date", "clock-3", "sign.fill.date", Ready),
item("Signature", "signature", "sign.fill.signature", Ready),
item("Initials", "signature", "sign.fill.initials", Ready),
item("Change signature", "signature", "sign.fill.signature.change", Ready),
item("Change initials", "signature", "sign.fill.initials.change", Ready),
],
}],
},
ToolGroup {
id: "edit",
label: "Edit a PDF",
icon: "file-pen-line",
hue: PINK,
badge: None,
availability: Planned("M7"),
sections: &[
ToolSection {
title: "Modify page",
items: &[
item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready),
item("Rotate pages", "rotate-cw", "page.rotate", Ready),
item("Insert pages", "file-plus-2", "page.insert", Ready),
item("Delete pages", "trash-2", "page.delete", Ready),
item("Extract pages", "file-output", "page.extract", Ready),
item("Organize pages", "layout-grid", "page.organize", Ready),
],
},
ToolSection {
title: "Add content",
items: &[
item("Text", "type", "edit.text", Ready),
item("Image", "image-plus", "edit.image", Ready),
menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]),
menu_item("Watermark", "stamp", &["edit.watermark", "edit.watermark.update", "edit.watermark.remove"]),
item("Link", "link-2", "edit.link", Ready),
item("Bates numbering", "hash", "edit.bates", Ready),
menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]),
item("Attach file", "paperclip", "edit.attach", Ready),
],
},
],
},
ToolGroup {
id: "create",
label: "Create a PDF",
icon: "file-plus-2",
hue: RED,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Create from",
items: &[
item("Single file", "file-input", "create.file", Ready),
item("Multiple files", "files", "create.multiple", Ready),
item("Separate PDFs", "files", "create.multiple_separate", Ready),
item("Images", "image", "create.images", Ready),
item("Clipboard", "copy-plus", "create.clipboard", Ready),
item("Blank page", "file-plus-2", "create.blank", Ready),
],
}],
},
ToolGroup {
id: "combine",
label: "Combine files",
icon: "files",
hue: BLUE,
badge: None,
availability: Ready,
sections: &[ToolSection { title: "Combine", items: &[item("Add files to combine", "files", "page.combine", Ready)] }],
},
ToolGroup {
id: "organize",
label: "Organize pages",
icon: "layout-grid",
hue: GREEN,
badge: None,
availability: Ready,
sections: &[
ToolSection {
title: "Page options",
items: &[
item("Page grid", "layout-grid", "page.organize", Ready),
item("Rotate", "rotate-cw", "page.rotate", Ready),
item("Delete", "trash-2", "page.delete", Ready),
item("Extract", "file-output", "page.extract", Ready),
item("Insert", "file-plus-2", "page.insert", Ready),
item("Replace", "replace", "page.replace", Ready),
item("Split", "scissors", "page.split", Ready),
],
},
ToolSection {
title: "More",
items: &[
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Crop pages", "crop", "page.crop", Ready),
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
item("Page labels", "tag", "page.number", Ready),
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
],
},
],
},
ToolGroup {
id: "comment",
label: "Add comments",
icon: "message-square-text",
hue: ORANGE,
badge: None,
availability: Ready,
sections: &[
ToolSection {
title: "Markup",
items: &[
item("Sticky note", "sticky-note", "comment.note", Ready),
item("Highlight text", "highlighter", "comment.highlight", Ready),
item("Underline", "underline", "comment.underline", Ready),
item("Strikethrough", "strikethrough", "comment.strikeout", Ready),
item("Squiggly underline", "spline", "comment.squiggly", Ready),
item("Text box", "type", "comment.freetext", Ready),
],
},
ToolSection {
title: "Drawing",
items: &[
item("Draw freehand", "pencil", "comment.ink", Ready),
item("Rectangle", "square", "comment.square", Ready),
item("Oval", "circle", "comment.circle", Ready),
item("Line", "minus", "comment.line", Ready),
item("Arrow", "move-right", "comment.arrow", Ready),
item("Cloud", "cloud", "comment.cloud", Planned("M5")),
item("Stamp", "stamp", "comment.stamp", Planned("M5")),
],
},
ToolSection {
title: "Review",
items: &[
item("Comment list", "message-square-text", "comment.list", Ready),
item("Flatten comments", "layers", "comment.flatten", Ready),
],
},
],
},
ToolGroup {
id: "scan",
label: "Scan & OCR",
icon: "scan-text",
hue: GREEN,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Recognize text",
items: &[
item("In this file", "scan-text", "ocr.recognize", Ready),
item("In multiple files", "files", "ocr.recognize_batch", Ready),
item("Enhance scanned file", "sparkles", "ocr.enhance", Ready),
item("Enhance camera image", "camera", "ocr.enhance_camera", Ready),
item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")),
],
}],
},
ToolGroup {
id: "protect",
label: "Protect a PDF",
icon: "shield-check",
hue: BLUE,
badge: None,
availability: Planned("M8"),
sections: &[
ToolSection {
title: "Protect",
items: &[
item("Protect with password", "lock", "protect.password", Ready),
item("Remove hidden information", "eye-off", "protect.remove_hidden", Ready),
],
},
ToolSection {
title: "Advanced options",
items: &[
item("Encrypt with certificate", "file-lock-2", "protect.certificate", Planned("M8")),
item("Security properties", "shield-check", "protect.properties", Ready),
item("Remove security", "lock-open", "protect.remove", Ready),
],
},
],
},
ToolGroup {
id: "redact",
label: "Redact a PDF",
icon: "rectangle-horizontal",
hue: RED,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Redact",
items: &[
item("Redact text and images", "rectangle-horizontal", "redact.mark", Ready),
item("Redact pages", "file-x", "redact.pages", Ready),
item("Find text and redact", "file-search", "redact.search", Ready),
item("Set properties", "settings-2", "redact.properties", Ready),
item("Sanitize document", "sparkles", "redact.sanitize", Ready),
],
}],
},
ToolGroup {
id: "compress",
label: "Compress a PDF",
icon: "file-down",
hue: RED,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Optimize",
items: &[
item("Reduce file size", "file-down", "optimize.reduce", Ready),
item("Advanced optimization", "settings-2", "optimize.advanced", Ready),
item("Audit space usage", "gauge", "optimize.audit", Planned("M11")),
],
}],
},
ToolGroup {
id: "form",
label: "Prepare a form",
icon: "text-cursor-input",
hue: PURPLE,
badge: None,
availability: Ready,
sections: &[
ToolSection {
title: "Fields",
items: &[
item("Field list", "list", "form.fields", Ready),
item("Detect form fields", "scan", "form.detect", Ready),
item("Clear form", "eraser", "form.clear", Ready),
item("Field properties", "settings-2", "form.field.properties", Ready),
item("Tab order by rows", "rows-3", "form.tab_order.row", Ready),
item("Tab order by columns", "columns-3", "form.tab_order.column", Ready),
item("Calculation order", "list-ordered", "form.calculation_order", Ready),
item("Flatten form fields", "layers", "form.flatten", Ready),
item("Import data", "file-input", "form.import_data", Ready),
item("Export data", "file-output", "form.export_data", Ready),
],
},
ToolSection {
title: "Add form components",
items: &[
item("Text field", "text-cursor-input", "form.add.text", Ready),
item("Text field table", "grid-3x3", "form.add.text_table", Ready),
item("Checkbox", "check-circle-2", "form.add.checkbox", Ready),
item("Radio button", "circle", "form.add.radio", Ready),
item("Drop-down list", "chevron-down", "form.add.combo", Ready),
item("List box", "list", "form.add.list", Ready),
item("Button", "square", "form.add.button", Ready),
item("Image field", "image", "form.add.image", Ready),
item("Date field", "clock-3", "form.add.date", Ready),
item("Digital signature", "signature", "form.add.signature", Ready),
],
},
],
},
ToolGroup {
id: "stamp",
label: "Add a stamp",
icon: "stamp",
hue: PURPLE,
badge: None,
availability: Ready,
sections: &[ToolSection { title: "Stamps", items: &[item("Stamp palette", "stamp", "comment.stamp", Ready)] }],
},
ToolGroup {
id: "certificate",
label: "Use a certificate",
icon: "badge-check",
hue: TEAL,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Certificates",
items: &[
item("Digitally sign", "signature", "sign.digital", Ready),
item("Timestamp", "clock-3", "sign.timestamp", Planned("M9")),
item("Validate all signatures", "badge-check", "sign.validate", Ready),
item("Certify (visible signature)", "badge-check", "sign.certify", Ready),
item("Certify (invisible signature)", "badge-check", "sign.certify_invisible", Ready),
],
}],
},
ToolGroup {
id: "print_production",
label: "Use print production",
icon: "printer",
hue: PURPLE,
badge: None,
availability: Planned("M11"),
sections: &[ToolSection {
title: "Print production",
items: &[
item("Output preview", "layers", "prepress.output_preview", Planned("M11")),
item("Preflight", "file-check", "preflight.run", Planned("M11")),
item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")),
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Add printer marks", "ruler", "prepress.marks", Planned("M11")),
item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")),
],
}],
},
ToolGroup {
id: "measure",
label: "Measure objects",
icon: "ruler",
hue: PINK,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Measure",
items: &[
item("Distance", "ruler", "measure.distance", Ready),
item("Perimeter", "ruler", "measure.perimeter", Ready),
item("Area", "ruler", "measure.area", Ready),
item("Drawing scale", "ruler", "measure.scale", Ready),
item("Measurement information", "ruler", "measure.info", Ready),
item("Snapping", "ruler", "measure.snap", Ready),
item("Export measurements", "file-output", "measure.export", Ready),
item("Geospatial location", "compass", "measure.geo", Planned("M12")),
],
}],
},
ToolGroup {
id: "compare",
label: "Compare files",
icon: "git-compare",
hue: PINK,
badge: None,
availability: Ready,
sections: &[ToolSection { title: "Compare", items: &[item("Select files to compare", "git-compare", "doc.compare", Ready)] }],
},
ToolGroup {
id: "actions",
label: "Use guided actions",
icon: "list-checks",
hue: PURPLE,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Actions list",
items: &[
item("Action Wizard", "list-checks", "actions.wizard", Ready),
item("Make accessible", "accessibility", "actions.make_accessible", Planned("M13")),
item("Prepare for distribution", "send", "actions.distribution", Ready),
item("Optimize scanned documents", "scan-text", "actions.optimize_scans", Ready),
item("Archive documents", "file-check", "actions.archive", Planned("M13")),
],
}],
},
ToolGroup {
id: "accessibility",
label: "Prepare for accessibility",
icon: "accessibility",
hue: PURPLE,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Accessibility",
items: &[
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
item("Tag form fields", "tag", "a11y.autotag_fields", Ready),
item("Change reading options", "book-open", "a11y.reading_options", Ready),
item("Check for accessibility", "accessibility", "a11y.check", Ready),
item("Find untagged content", "search", "a11y.find_untagged", Ready),
item("Open accessibility report", "file-text", "a11y.report", Ready),
item("Add alternate text", "image", "a11y.alt_text", Ready),
item("Tags panel", "tag", "a11y.tags", Ready),
item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")),
],
}],
},
ToolGroup {
id: "standards",
label: "Apply PDF standards",
icon: "file-check",
hue: RED,
badge: None,
availability: Ready,
sections: &[ToolSection {
title: "Standards",
items: &[
item("Save as PDF/A", "file-check", "standards.pdfa", Ready),
item("Save as PDF/X", "file-check", "standards.pdfx", Planned("M11")),
item("Save as PDF/UA", "accessibility", "standards.pdfua", Planned("M11")),
item("Preflight", "file-check", "preflight.run", Planned("M11")),
],
}],
},
ToolGroup {
id: "search_index",
label: "Add search index",
icon: "file-search",
hue: GREEN,
badge: None,
availability: Planned("M12"),
sections: &[ToolSection { title: "Index", items: &[item("Manage embedded index", "file-search", "search.index", Planned("M12"))] }],
},
ToolGroup {
id: "javascript",
label: "Use JavaScript",
icon: "braces",
hue: BLUE,
badge: None,
availability: Planned("M6"),
sections: &[ToolSection {
title: "JavaScript",
items: &[
item("Console", "braces", "js.console", Planned("M6")),
item("Document JavaScripts", "file-text", "js.document", Planned("M6")),
item("Document actions", "list-checks", "js.actions", Planned("M6")),
],
}],
},
ToolGroup {
id: "ai",
label: "AI assistant",
icon: "sparkles",
hue: PINK,
badge: Some("Optional"),
availability: Provider,
sections: &[ToolSection {
title: "Bring your own model (off by default)",
items: &[
item("Summarize", "sparkles", "ai.summary", Provider),
item("Ask about this document", "message-circle-reply", "ai.ask", Provider),
item("Translate", "languages", "ai.translate", Provider),
],
}],
},
];
pub fn group(id: &str) -> Option<&'static ToolGroup> {
TOOL_GROUPS.iter().find(|g| g.id == id)
}
/// A heading of the expanded All tools view, as Acrobat files its tools under "Create & Edit",
/// "Forms & Signatures" and so on. Every group belongs to exactly one category.
#[derive(Clone, Copy, Debug)]
pub struct ToolCategory {
pub label: &'static str,
/// [`ToolGroup::id`]s, in display order.
pub groups: &'static [&'static str],
}
pub static TOOL_CATEGORIES: &[ToolCategory] = &[
ToolCategory { label: "Create & Edit", groups: &["create", "edit", "export", "combine", "organize", "scan", "compress"] },
ToolCategory { label: "Review & Compare", groups: &["comment", "stamp", "compare", "measure"] },
ToolCategory { label: "Forms & Signatures", groups: &["fill_sign", "form", "certificate"] },
ToolCategory { label: "Protect & Standardize", groups: &["protect", "redact", "accessibility", "standards", "print_production"] },
ToolCategory { label: "Customize & Automate", groups: &["actions", "javascript", "search_index", "ai"] },
];
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn ids_are_unique_and_every_group_has_items() {
let mut seen = HashSet::new();
for g in TOOL_GROUPS {
assert!(seen.insert(g.id), "duplicate group id {}", g.id);
assert!(g.sections.iter().any(|s| !s.items.is_empty()), "{} has no items", g.id);
}
}
#[test]
fn every_group_is_in_exactly_one_category() {
let mut seen = HashSet::new();
for c in TOOL_CATEGORIES {
for id in c.groups {
assert!(group(id).is_some(), "{} lists unknown group {id}", c.label);
assert!(seen.insert(*id), "{id} is in two categories");
}
}
assert_eq!(seen.len(), TOOL_GROUPS.len(), "a group is in no category");
}
#[test]
fn commands_are_namespaced() {
for g in TOOL_GROUPS {
for s in g.sections {
for i in s.items {
assert!(i.command.contains('.'), "{} lacks a namespace", i.command);
}
}
}
}
#[test]
fn menu_entries_list_registered_commands_starting_with_their_own() {
let mut menus = 0;
for g in TOOL_GROUPS {
for s in g.sections {
for i in s.items {
if i.actions.is_empty() {
continue;
}
menus += 1;
assert_eq!(i.actions[0], i.command, "{}: the first action is the entry's command", i.label);
for id in i.actions {
assert!(crate::commands::command(id).is_some(), "{id} is not a registered command");
}
}
}
}
// Watermark, Header and footer, Background: Add / Update / Remove each (Acrobat's menu).
assert!(menus >= 3);
}
// Regression test for #2028: Edit text & images sat under "Add content", though it changes
// what is already on the page; it belongs first under "Modify page".
#[test]
fn edit_text_and_images_is_the_first_item_under_modify_page() {
let edit = group("edit").expect("the Edit a PDF tool");
let section_of = |command: &str| edit.sections.iter().find(|s| s.items.iter().any(|i| i.command == command)).map(|s| s.title);
assert_eq!(section_of("edit.edit_text"), Some("Modify page"), "Edit text & images is a Modify page tool");
let modify = edit.sections.iter().find(|s| s.title == "Modify page").expect("a Modify page section");
assert_eq!(modify.items.first().map(|i| i.command), Some("edit.edit_text"), "and the first one, so it is not below the fold");
let add = edit.sections.iter().find(|s| s.title == "Add content").expect("an Add content section");
assert!(add.items.iter().all(|i| i.command != "edit.edit_text"), "it adds nothing, so it is not listed under Add content");
}
#[test]
fn every_update_and_remove_command_is_reachable_from_a_tool_entry() {
let reachable: HashSet<&str> =
TOOL_GROUPS.iter().flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())).flat_map(|i| i.actions.iter().copied()).collect();
for id in crate::commands::COMMANDS
.iter()
.map(|c| c.id)
.filter(|id| id.starts_with("edit.") && (id.ends_with(".update") || id.ends_with(".remove")))
{
assert!(reachable.contains(id), "{id} is in no tool menu");
}
}
}