Files
pdfcraft/crates/engine/src/commands.rs
T
Michael Higgins 4efcfa6ea3 fix(crypt, engine): refuse page extraction when Protect allows any change except extracting pages (#5081)
## What this changes

Adds `Permissions::extract_pages()`: `assemble()` **and** copying (bit 5), as #5081 asks, since extracted pages carry their text and graphics into a file without the source's protection. It is used for Extract, Split, Split by size and every Combine/Insert/Replace/paste source check (`sources::source_document`). Changes within the document (rotate, delete, move, insert) keep `assemble()`.

Since this PR was opened, #6309 made `assemble()` revision-aware on `main`: bit 11 alone at revision 3 and later, bit 4 at revision 2. That already refuses "Any except extracting pages" at revision 3+. This PR builds on it:

- At revision 2, `assemble()` reads bit 4, which "Any except extracting pages" sets, so such a file could still be extracted and split into unprotected copies. Requiring copying closes that.
- New `Needs::PageExtraction` greys out Extract and Split up front. It reuses the existing security-refusal string, so no catalogue changes.
- `Document::allows_page_extraction()`, and an `extract_pages` key in the automation security JSON.
- `page_extract` with `separate: true` extracts and writes one page at a time (`extract_separate`), so it no longer buffers every page. The first page is extracted before `out_dir` is created, so a refused extraction leaves no empty directory. If a later page fails, the files this call wrote are removed, and `out_dir` too if this call created it (review on this PR).
- Insert Pages checks a PDF through the same path as the insert itself: new `Session::insert_source(dest, name, bytes)`, which the UI's insert now calls (review on this PR).
- The `organize.combine` parity notes now say "page extraction".

Fixes #5081

## How you verified it

`cargo fmt --all` is clean. Regression tests (adopted from Harrison-Booth's review patch, plus CodeRabbit's follow-ups):

- `crates/crypt/src/tests.rs::page_extraction_needs_assembly_and_copying`: unit test for `extract_pages()`.
- `crates/engine/src/tests.rs::extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output`: AnyExceptExtract on RC4-128, AES-128 and AES-256 refuses Extract, Split, Split by size and Combine/Insert/Replace sources without the permissions password, leaves source and destination untouched, and the owner can do everything. The authorized combine output has 2 pages, and a differently encrypted or unprotected destination is refused.
- `crates/engine/src/tests.rs::page_extraction_needs_the_copy_permission_on_every_revision`: a file that withholds only copying (R2 RC4-40 and AES-256) refuses extraction; the owner password lifts it.
- `crates/engine/src/tests.rs::pages_under_the_same_protection_are_inserted_without_page_extraction`: a separately built file under matching protection is inserted, and the result stays restricted.
- `crates/automation/tests/it/automation.rs::refused_extraction_writes_no_split_or_page_output`, and the `extract_pages` checks in `protecting_through_tools`, which now cover three presets, including a user (not the owner) for whom extraction is allowed.

**Only the crypt unit test has been run locally, and it passes.** The engine and automation tests timed out while compiling on this machine and have not been run, nor seen failing without the fix. The one `pdfcraft-ui-egui` call-site change has not been compiled. CI is the first place these build.

- [x] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed (not re-run after the rebase)
- [ ] tests pass on the crates I changed (crypt only)

## Anything a reviewer should look at first

- **Rebased onto #6309.** The tests no longer expect "Any except extracting pages" to allow assembly at revision 3+. The same-protection paste tests now use the "Inserting, deleting and rotating pages" preset without copying.
- **Copy (bit 5) is now required**, as the issue suggests. `crates/ui-egui/tests/it/editing.rs::protected_files_that_can_be_combined_are_flagged` used a copy-withheld file as "protected but combinable"; it now uses a print-withheld file, since a no-copy file is no longer a Combine source without the permissions password.
- **Same-document paste**: Paste re-opens the document's own bytes as an Insert source, which the new check would refuse. `sources::open_source_into` accepts a source that withholds extraction but allows assembly only when the destination will be saved under exactly the same protection (file key and `/Encrypt` values), so cut/copy and paste within one protected document still works. Pasting into an unprotected or differently protected document is refused. One limit: after changing security in the same session without saving, a cut can't be pasted back until you save.
- **Not in this PR:** CodeRabbit's `disabled_reason` suggestion for read-only documents predates this change and covers other commands too, so it belongs in a separate UI change.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

- **New Features**
  - Page extraction and splitting now follow a dedicated permission check, distinct from page assembly.
  - Pages can be inserted from certain protected PDFs when their protection settings match the destination document.

- **Bug Fixes**
  - Separate-page extraction now cleans up files created during a failed operation and avoids creating an output directory when the first page cannot be extracted.
  - Security details now report whether page extraction is permitted.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 09:06:48 +01:00

548 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The command registry: every user-facing action, by id (architecture §4, "everything is a
//! command").
//!
//! Menus, keyboard shortcuts, the ⌘K palette and automation (CLI `run`, the control channel and
//! MCP later) all go through this table. A frontend implements *what* each id does. The registry
//! says what it is called, where it appears, which key runs it, and when it is enabled, so that
//! every surface agrees.
//!
//! View-local keys (zoom, page navigation, find-next) stay with the document view. They act on
//! view state, not on the document.
use crate::{DocId, Session};
/// When a command can run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Needs {
/// Always.
Nothing,
/// A document is open.
Document,
/// The open document's security allows page changes (insert, delete, rotate…).
Assembly,
/// The open document's security allows taking pages out into new files (extract, split).
PageExtraction,
/// The open document's security allows content and metadata changes.
Modification,
/// The open document's security allows adding and changing comments.
Annotate,
/// The open document's security allows copying text and images out of it.
Copying,
/// The open document's security allows printing (low resolution counts).
Printing,
/// The document has form fields and its security allows filling them in.
FillForms,
/// The document has comments and allows changes.
HasComments,
/// The document has form fields and allows changes.
HasFields,
/// The document has redaction marks and allows changes.
HasRedactions,
/// The document has page marks of this kind and allows changes.
Marks(crate::MarkKind),
/// The document's security may be changed (owner, or nothing restricted), or would be
/// once its permissions password is entered.
Security,
/// The document is protected and its security may be removed (perhaps after entering
/// its permissions password).
ProtectedSecurity,
/// The document's restrictions can be lifted with its permissions password.
Unlock,
/// There is something to undo.
Undo,
/// There is something to redo.
Redo,
/// A document is open in two-page view. The engine has no views, so it checks the
/// document and the frontend checks the view.
TwoPageView,
}
/// A keyboard shortcut. `command` is ⌘ on macOS and Ctrl elsewhere.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Shortcut {
pub command: bool,
pub shift: bool,
/// ⌃ on macOS (in addition to ⌘); unused elsewhere.
pub mac_ctrl: bool,
/// Key name: a letter, a digit, punctuation such as `,`, or `Delete`.
pub key: &'static str,
}
impl Shortcut {
/// ⌘ (Ctrl elsewhere) and `key`.
pub const fn cmd(key: &'static str) -> Self {
Self { command: true, shift: false, mac_ctrl: false, key }
}
/// ⇧⌘ (Ctrl+Shift elsewhere) and `key`.
pub const fn cmd_shift(key: &'static str) -> Self {
Self { command: true, shift: true, mac_ctrl: false, key }
}
/// The command modifier on its own, for text such as "⌘-scroll" / "Ctrl-scroll".
pub fn command_name(mac: bool) -> &'static str {
if mac { "⌘" } else { "Ctrl" }
}
/// How the shortcut is written in menus: `⇧⌘S` on macOS, `Ctrl+Shift+S` elsewhere.
pub fn label(&self, mac: bool) -> String {
if mac {
let mut s = String::new();
if self.mac_ctrl {
s.push('⌃');
}
if self.shift {
s.push('⇧');
}
if self.command {
s.push('⌘');
}
s.push_str(self.key);
s
} else {
let mut parts = Vec::new();
if self.command || self.mac_ctrl {
parts.push("Ctrl");
}
if self.shift {
parts.push("Shift");
}
parts.push(self.key);
parts.join("+")
}
}
/// Number of modifiers (more specific shortcuts are matched first).
pub fn modifier_count(&self) -> usize {
usize::from(self.command) + usize::from(self.shift) + usize::from(self.mac_ctrl)
}
}
#[derive(Clone, Copy, Debug)]
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Top-level menu it appears in (`File`, `Edit`, `View`, `Pages`, `Help`), if any.
pub menu: Option<&'static str>,
pub shortcut: Option<Shortcut>,
pub needs: Needs,
/// The shortcut also works while a text field has focus.
pub in_text: bool,
/// Lucide icon name for palettes and toolbars.
pub icon: &'static str,
}
const fn c(
id: &'static str,
label: &'static str,
menu: Option<&'static str>,
shortcut: Option<Shortcut>,
needs: Needs,
icon: &'static str,
) -> CommandSpec {
CommandSpec { id, label, menu, shortcut, needs, in_text: true, icon }
}
/// Like `c`, but the shortcut is left to text fields while one has focus (⌘Z, ⌘A…).
const fn ct(
id: &'static str,
label: &'static str,
menu: Option<&'static str>,
shortcut: Option<Shortcut>,
needs: Needs,
icon: &'static str,
) -> CommandSpec {
CommandSpec { id, label, menu, shortcut, needs, in_text: false, icon }
}
use Needs::*;
const FILE: Option<&str> = Some("File");
const EDIT: Option<&str> = Some("Edit");
const VIEW: Option<&str> = Some("View");
const PAGES: Option<&str> = Some("Pages");
const HELP: Option<&str> = Some("Help");
/// Every command, in menu order.
pub const COMMANDS: &[CommandSpec] = &[
c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"),
c("view.hand_tool", "Hand", None, None, Document, "hand"),
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"),
// Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item.
c("file.clear_recent", "Clear Recent Files", None, None, Nothing, "trash-2"),
c("file.pin_folder", "Pin folder to Home…", FILE, None, Nothing, "folder-plus"),
c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"),
c("measure.distance", "Measure distance", None, None, Annotate, "ruler"),
c("measure.perimeter", "Measure perimeter", None, None, Annotate, "ruler"),
c("measure.area", "Measure area", None, None, Annotate, "ruler"),
c("measure.scale", "Set measurement scale", None, None, Annotate, "ruler"),
c("measure.info", "Measurement information", None, None, Document, "ruler"),
c("measure.snap", "Measurement snapping", None, None, Document, "ruler"),
c("measure.export", "Export measurements as CSV", None, None, Document, "file-output"),
c("page.copy", "Copy pages", None, None, Document, "copy"),
c("page.cut", "Cut pages", None, None, Assembly, "scissors"),
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
c("create.multiple", "Create PDF from multiple files…", FILE, None, Nothing, "files"),
c("create.multiple_separate", "Create separate PDFs…", FILE, None, Nothing, "files"),
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"),
c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"),
c("file.close", "Close file", FILE, Some(Shortcut::cmd("W")), Document, "x"),
c("file.close_all", "Close all", FILE, Some(Shortcut::cmd_shift("W")), Document, "x"),
c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"),
c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Printing, "printer"),
c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"),
c("file.word_count", "Word count…", FILE, None, Copying, "hash"),
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
// Find and replace, in Edit text mode (the Find box's Replace row). Not ⌘H/Ctrl+H: macOS
// takes ⌘H to hide the app, and Ctrl+H is Read mode on Windows and Linux (as in Acrobat).
c("edit.replace", "Replace…", EDIT, Some(Shortcut::cmd_shift("H")), Modification, "replace"),
c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"),
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
// Page display: View ▸ Page display and the rail's button list the base layouts as radios and
// continuous scrolling as a checkbox, so `menu = None` keeps them out of the generated menus.
// The palette still runs them.
c("view.layout.single", "Single page", None, None, Document, "file-text"),
c("view.layout.two_up", "Two-page view", None, None, Document, "columns-2"),
c("view.continuous", "Continuous scrolling", None, None, Document, "arrow-up-down"),
c("view.page_gaps", "Show gaps between pages", None, None, Document, "arrow-up-down"),
c("view.layout.cover", "Show cover page in two-page view", None, None, TwoPageView, "bookmark"),
// Acrobat's view modes, a page display and a zoom at once (the rail's Page display menu).
c("view.fit_width_scrolling", "Fit to width scrolling", None, None, Document, "arrow-left-right"),
c("view.fit_one_page", "Fit one full page", None, None, Document, "maximize-2"),
// In View ▸ Zoom with the view-local zoom keys, so `menu = None` keeps it out of the generated
// View menu (where it would show twice). The palette and ⌘3 still run it.
c("view.fit_visible", "Fit visible", None, Some(Shortcut::cmd("3")), Document, "scan"),
c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"),
// Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option-
// drag with the Select tool does the same; this is the way in where the desktop takes
// Alt-drag for moving windows (several Linux window managers).
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
// Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them,
// ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too.
c("comment.copy", "Copy comments", EDIT, None, Document, "copy"),
c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"),
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
// Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one;
// a tab's right-click menu also splits right or down with any tab.
c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"),
// A document can hide the toolbars, menu and panels (ISO 32000-2 Table 147); this brings them
// back for its tab, and with nothing hidden hides them.
c(
"view.toggle_interface",
"Show or hide toolbars and panels",
VIEW,
Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F8" }),
Document,
"panel-left",
),
// Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS,
// where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and
// also run from Combine files, which has no document open.
c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"),
c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"),
// View-local history also appears in the Page navigation section; don't duplicate its menu.
c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"),
c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"),
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
c(
"view.side_panel",
"Show or hide the side panel",
VIEW,
Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F4" }),
Nothing,
"panel-right",
),
c(
"view.tools_pane",
"Show or hide the Tools panel",
VIEW,
Some(Shortcut { command: false, shift: true, mac_ctrl: false, key: "F4" }),
Nothing,
"panel-left",
),
c("ui.dock.reset", "Reset side panels", VIEW, None, Nothing, "settings"),
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
c("view.theme.system", "Use system setting", None, None, Nothing, "settings"),
c("view.theme.light", "Light gray", None, None, Nothing, "sun"),
c("view.theme.dark", "Dark gray", None, None, Nothing, "moon"),
c("comment.list", "Comments panel", VIEW, None, Document, "message-square-text"),
c("comment.note", "Add a sticky note", None, None, Annotate, "sticky-note"),
c("comment.freetext", "Add a text box", None, None, Annotate, "type"),
c("comment.highlight", "Highlight text", None, None, Annotate, "highlighter"),
c("comment.underline", "Underline text", None, None, Annotate, "underline"),
c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"),
c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"),
c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"),
c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"),
c("comment.line", "Draw a line", None, None, Annotate, "minus"),
c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"),
c("comment.square", "Draw a rectangle", None, None, Annotate, "square"),
c("comment.circle", "Draw an oval", None, None, Annotate, "circle"),
c("comment.polygon", "Draw a polygon", None, None, Annotate, "pentagon"),
c("comment.polyline", "Draw connected lines", None, None, Annotate, "spline"),
c("comment.cloud", "Draw a cloud", None, None, Annotate, "cloud"),
c("comment.callout", "Add a callout", None, None, Annotate, "message-square-quote"),
c("comment.caret", "Insert text", None, None, Annotate, "text-cursor-input"),
c("comment.replace", "Replace text", None, None, Annotate, "replace"),
c("comment.attach", "Attach a file", None, None, Annotate, "paperclip"),
c("comment.eraser", "Erase drawings", None, None, Annotate, "eraser"),
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"),
c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"),
c("comment.import", "Import comments…", None, None, Modification, "file-input"),
c("comment.stamp", "Add a stamp", None, None, Annotate, "stamp"),
c("comment.export", "Export all comments to data file…", None, None, HasComments, "file-output"),
c("comment.hide_all", "Hide all comments", None, None, Document, "eye-off"),
c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"),
c("form.import_data", "Import form data…", None, None, HasFields, "file-input"),
c("form.export_data", "Export form data…", None, None, HasFields, "file-output"),
c("standards.pdfa", "PDF/A…", None, None, Document, "file-check"),
c("actions.wizard", "Action Wizard…", None, None, Nothing, "list-checks"),
c("actions.distribution", "Prepare for distribution…", None, None, Nothing, "send"),
c("actions.optimize_scans", "Optimize scanned documents…", None, None, Nothing, "scan-text"),
c("doc.compare", "Compare files…", None, None, Document, "git-compare"),
c("form.detect", "Detect form fields", None, None, Modification, "scan"),
c("form.merge_data", "Merge data files into spreadsheet…", None, None, Nothing, "file-spreadsheet"),
c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"),
c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"),
c("form.tab_order.row", "Tab order: by rows", None, None, HasFields, "rows-3"),
c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"),
c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"),
c("form.calculation_order", "Calculation order", None, None, Document, "list-ordered"),
c("edit.text", "Add text", None, None, Modification, "type"),
c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"),
c("edit.link", "Add or edit links", None, None, Modification, "link-2"),
c("edit.attach", "Attach a file…", EDIT, None, Modification, "paperclip"),
c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"),
c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"),
c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"),
c("edit.image", "Add image…", None, None, Modification, "image-plus"),
c("redact.mark", "Redact text and images", None, None, Modification, "rectangle-horizontal"),
c("redact.pages", "Redact pages…", None, None, Modification, "file-x"),
c("redact.search", "Find text and redact…", None, None, Modification, "file-search"),
c("redact.properties", "Redaction properties…", None, None, Document, "settings-2"),
c("redact.apply", "Apply redactions…", None, None, HasRedactions, "check"),
c("redact.clear", "Clear redaction marks", None, None, HasRedactions, "eraser"),
c("protect.remove_hidden", "Remove hidden information…", None, None, Modification, "eye-off"),
c("redact.sanitize", "Sanitize document…", None, None, Modification, "sparkles"),
c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"),
c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"),
c("form.add.text_table", "Add text field table", None, None, Modification, "grid-3x3"),
c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"),
c("form.add.radio", "Add a radio button", None, None, Modification, "circle"),
c("form.add.combo", "Add a drop-down list", None, None, Modification, "chevron-down"),
c("form.add.list", "Add a list box", None, None, Modification, "list"),
c("form.add.button", "Add a button", None, None, Modification, "square"),
c("form.add.image", "Add an image field", None, None, Modification, "image"),
c("form.add.date", "Add a date field", None, None, Modification, "clock-3"),
c("form.add.signature", "Add a digital signature field", None, None, Modification, "signature"),
c("sign.digital", "Digitally sign", None, None, Document, "signature"),
c("sign.certify", "Certify (visible signature)", None, None, Document, "badge-check"),
c("sign.certify_invisible", "Certify (invisible signature)", None, None, Document, "badge-check"),
c("sign.validate", "Validate all signatures", None, None, Document, "badge-check"),
c("sign.panel", "Signatures panel", VIEW, None, Document, "signature"),
c("sign.fill.text", "Fill & Sign: add text", None, None, Annotate, "type"),
c("sign.fill.check", "Fill & Sign: checkmark", None, None, Annotate, "check"),
c("sign.fill.cross", "Fill & Sign: cross", None, None, Annotate, "x"),
c("sign.fill.dot", "Fill & Sign: dot", None, None, Annotate, "circle-dot"),
c("sign.fill.line", "Fill & Sign: line", None, None, Annotate, "minus"),
c("sign.fill.date", "Fill & Sign: date", None, None, Annotate, "clock-3"),
c("sign.fill.signature", "Fill & Sign: sign", None, None, Annotate, "signature"),
c("sign.fill.initials", "Fill & Sign: initials", None, None, Annotate, "signature"),
c("sign.fill.signature.change", "Fill & Sign: change signature", None, None, Nothing, "signature"),
c("sign.fill.signature.remove", "Fill & Sign: remove saved signature", None, None, Nothing, "x"),
c("sign.fill.initials.remove", "Fill & Sign: remove saved initials", None, None, Nothing, "x"),
c("sign.fill.initials.change", "Fill & Sign: change initials", None, None, Nothing, "signature"),
c("export.image", "Export to image…", FILE, None, Copying, "image"),
c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"),
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
c("export.text", "Export to text…", FILE, None, Copying, "type"),
c("export.docx", "Export to Word…", FILE, None, Copying, "file-text"),
c("export.pptx", "Export to PowerPoint…", FILE, None, Copying, "presentation"),
c("export.html", "Export to HTML…", FILE, None, Copying, "file-symlink"),
c("export.rtf", "Export to RTF…", FILE, None, Copying, "file-text"),
c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"),
c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"),
c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"),
c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"),
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
c("ocr.enhance", "Enhance scanned file…", None, None, Modification, "sparkles"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("ocr.correct", "Correct recognized text…", None, None, Document, "text-select"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
c("a11y.autotag_fields", "Tag form fields", None, None, Modification, "tag"),
c("a11y.tags", "Tags panel", None, None, Document, "tag"),
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
c("edit.watermark", "Add watermark…", None, None, Modification, "stamp"),
c("edit.watermark.update", "Update watermark…", None, None, Marks(crate::MarkKind::Watermark), "stamp"),
c("edit.watermark.remove", "Remove watermark", None, None, Marks(crate::MarkKind::Watermark), "stamp"),
c("edit.background", "Add background…", None, None, Modification, "palette"),
c("edit.background.update", "Update background…", None, None, Marks(crate::MarkKind::Background), "palette"),
c("edit.background.remove", "Remove background", None, None, Marks(crate::MarkKind::Background), "palette"),
c("protect.password", "Protect using password…", FILE, None, Security, "lock"),
c("protect.remove", "Remove security", FILE, None, ProtectedSecurity, "lock-open"),
c("protect.unlock", "Enter permissions password…", None, None, Unlock, "lock-open"),
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
// Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693).
c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"),
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
c("page.replace", "Replace pages…", PAGES, None, Assembly, "replace"),
c("page.extract", "Extract pages…", PAGES, None, PageExtraction, "file-output"),
c("page.split", "Split document…", PAGES, None, PageExtraction, "scissors"),
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"),
c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"),
c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"),
c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"),
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"),
c("help.github", "PdfCraft on GitHub", HELP, None, Nothing, "code-xml"),
c("help.website", "ArtCraft website", HELP, None, Nothing, "external-link"),
c("help.check_updates", "Check for updates…", HELP, None, Nothing, "cloud"),
c("help.about", "About PdfCraft", HELP, None, Nothing, "info"),
];
pub fn command(id: &str) -> Option<&'static CommandSpec> {
COMMANDS.iter().find(|c| c.id == id)
}
/// Commands that appear in `menu`, in order.
pub fn menu(menu: &str) -> impl Iterator<Item = &'static CommandSpec> + '_ {
COMMANDS.iter().filter(move |c| c.menu == Some(menu))
}
/// Whether `spec` can run now, for the document `active` (the focused tab).
pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>) -> bool {
let doc = active.and_then(|id| session.get(id));
match spec.needs {
Nothing => true,
Document | TwoPageView => doc.is_some(),
Assembly => doc.is_some_and(|d| d.allows_assembly()),
PageExtraction => doc.is_some_and(|d| d.allows_page_extraction()),
Modification => doc.is_some_and(|d| d.allows_modification()),
Annotate => doc.is_some_and(|d| d.allows_annotation()),
Copying => doc.is_some_and(|d| d.allows_copying()),
Printing => doc.is_some_and(|d| d.allows_printing()),
FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)),
HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()),
HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()),
HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0),
Security => doc.is_some_and(|d| d.allows_security_change() || d.can_unlock()),
ProtectedSecurity => doc.is_some_and(|d| (d.allows_security_change() || d.can_unlock()) && d.security_summary().is_some()),
Unlock => doc.is_some_and(|d| d.can_unlock()),
Undo => doc.is_some_and(|d| d.can_undo().is_some()),
Redo => doc.is_some_and(|d| d.can_redo().is_some()),
}
}
/// The edit `spec` names now, with the wording that names it ("Undo {}"), so an interface can say
/// each part in its own language. `None` for every other command, and when there is nothing to undo.
pub fn current_edit<'a>(spec: &CommandSpec, session: &'a Session, active: Option<DocId>) -> Option<(&'static str, &'a crate::Label)> {
let doc = active.and_then(|id| session.get(id))?;
match spec.id {
"edit.undo" => doc.undo_label().map(|l| ("Undo {}", l)),
"edit.redo" => doc.redo_label().map(|l| ("Redo {}", l)),
_ => None,
}
}
/// The label to show for `spec` now ("Undo Rotate page" rather than "Undo").
pub fn current_label(spec: &CommandSpec, session: &Session, active: Option<DocId>) -> String {
match current_edit(spec, session, active) {
Some((wording, label)) => wording.replacen("{}", label.as_str(), 1),
None => spec.label.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_and_shortcuts_are_unique() {
let mut ids = std::collections::HashSet::new();
let mut keys = std::collections::HashSet::new();
for c in COMMANDS {
assert!(ids.insert(c.id), "duplicate id {}", c.id);
if let Some(s) = c.shortcut {
assert!(keys.insert(s), "duplicate shortcut {} ({})", s.label(true), c.id);
}
}
}
#[test]
fn every_ready_catalogue_item_is_a_registered_command() {
for g in crate::catalog::TOOL_GROUPS {
for s in g.sections {
for i in s.items {
if i.availability == crate::catalog::Availability::Ready {
assert!(command(i.command).is_some(), "catalogue item {} ({}) is Ready but not registered", i.label, i.command);
}
}
}
}
}
#[test]
fn shortcut_labels_follow_platform_conventions() {
let s = command("file.save_as").unwrap().shortcut.unwrap();
assert_eq!(s.label(true), "⇧⌘S");
assert_eq!(s.label(false), "Ctrl+Shift+S");
assert_eq!(command("view.read_mode").unwrap().shortcut.unwrap().label(true), "⌃⌘H");
assert_eq!(Shortcut::cmd("+").label(false), "Ctrl++");
assert_eq!(Shortcut::cmd_shift("−").label(true), "⇧⌘−");
assert_eq!(Shortcut::cmd_shift("−").label(false), "Ctrl+Shift+−");
assert_eq!(Shortcut::command_name(true), "⌘");
assert_eq!(Shortcut::command_name(false), "Ctrl");
}
#[test]
fn enablement_follows_the_document_state() {
let mut s = Session::new();
let undo = command("edit.undo").unwrap();
let save = command("file.save").unwrap();
assert!(!is_enabled(save, &s, None));
assert!(is_enabled(command("file.open").unwrap(), &s, None));
let id = s.open("a.pdf", None, std::sync::Arc::new(crate::tests::fixture(2)), None).unwrap();
assert!(is_enabled(save, &s, Some(id)));
assert!(!is_enabled(undo, &s, Some(id)));
s.apply(id, crate::Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
assert!(is_enabled(undo, &s, Some(id)));
assert_eq!(current_label(undo, &s, Some(id)), "Undo Rotate page");
}
}