mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
Merge remote-tracking branch 'upstream/main' into main
This commit is contained in:
Generated
+36
-13
@@ -229,7 +229,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1171,6 +1171,29 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-quit",
|
||||
"egui",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1187,11 +1210,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
@@ -1199,7 +1221,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
@@ -1948,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3941,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4035,7 +4057,6 @@ name = "pdfcraft"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4471,6 +4492,8 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"craft-keymap",
|
||||
"craft-menu",
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -5074,7 +5097,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5595,7 +5618,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5665,7 +5688,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6549,10 +6572,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7664,7 +7687,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-2
@@ -13,9 +13,12 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
|
||||
@@ -29,9 +29,6 @@ chrono = { workspace = true }
|
||||
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
|
||||
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
|
||||
# window to close instead of winit's, which ends the process without asking.
|
||||
craft-quit = { workspace = true, features = ["menu"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
|
||||
|
||||
@@ -429,10 +429,11 @@ fn app_creator<'a>(
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
|
||||
// menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
|
||||
if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
|
||||
log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
|
||||
// The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as
|
||||
// winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close
|
||||
// button.
|
||||
if !app.install_native_menu(&cc.egui_ctx) {
|
||||
log::warn!("couldn't install the macOS menu bar; the menus stay in the window");
|
||||
}
|
||||
}
|
||||
if let Some(server) = instance {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The bookmark list.
|
||||
|
||||
/// A bookmark as the bookmark list shows it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BookmarkRow {
|
||||
/// Child indices from the top level, 0-based, as the edit functions take them.
|
||||
pub path: Vec<usize>,
|
||||
pub title: String,
|
||||
/// Shown expanded (a positive `/Count`).
|
||||
pub open: bool,
|
||||
/// The 0-based page it goes to.
|
||||
pub page: Option<usize>,
|
||||
/// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say).
|
||||
pub view: pdfcraft_render::DestView,
|
||||
/// The title's colour (`/C`), `None` for the default black, and its style (`/F`).
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub italic: bool,
|
||||
pub bold: bool,
|
||||
}
|
||||
|
||||
/// One page of the bookmark list, in outline order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BookmarkPage {
|
||||
pub rows: Vec<BookmarkRow>,
|
||||
/// Where the next page starts, or `None` at the end.
|
||||
pub next: Option<usize>,
|
||||
/// Set only when the document's structure could not be read for editing: its listing then
|
||||
/// comes from the viewer's copy of the outline, which is shorter than the file's.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// The rows of a viewer outline, in outline order, with their paths.
|
||||
pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
|
||||
let mut rows = Vec::new();
|
||||
// One entry per open level: its items, and how many of them have been listed.
|
||||
let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)];
|
||||
while let Some(&(list, next)) = levels.last() {
|
||||
let Some(item) = list.get(next) else {
|
||||
levels.pop();
|
||||
continue;
|
||||
};
|
||||
if let Some(top) = levels.last_mut() {
|
||||
top.1 += 1;
|
||||
}
|
||||
let path: Vec<usize> = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect();
|
||||
rows.push(BookmarkRow {
|
||||
path,
|
||||
title: item.title.clone(),
|
||||
open: item.open,
|
||||
page: item.page,
|
||||
view: item.view,
|
||||
color: item.color,
|
||||
italic: item.italic,
|
||||
bold: item.bold,
|
||||
});
|
||||
if !item.children.is_empty() {
|
||||
levels.push((&item.children, 0));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The comment list and its summary.
|
||||
|
||||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||||
pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation> {
|
||||
let mut list: Vec<pdfcraft_render::Annotation> = pdfcraft_annot::summaries(doc)
|
||||
.into_iter()
|
||||
.map(|s| pdfcraft_render::Annotation {
|
||||
page: s.page,
|
||||
subtype: s.subtype,
|
||||
author: s.author,
|
||||
contents: s.contents,
|
||||
modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)),
|
||||
name: s.name,
|
||||
in_reply_to: s.in_reply_to,
|
||||
parent: s.parent,
|
||||
grouped: s.grouped,
|
||||
rect: s.rect,
|
||||
color: s.color,
|
||||
index: s.index,
|
||||
state: s.state,
|
||||
quads: s.quads,
|
||||
locked: s.locked,
|
||||
intent: s.intent,
|
||||
fill_sign: s.fill_sign,
|
||||
no_rotate: s.no_rotate,
|
||||
points: s.points,
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&mut list);
|
||||
list
|
||||
}
|
||||
|
||||
/// Order of Summarize Comments.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SummarySort {
|
||||
#[default]
|
||||
Page,
|
||||
Author,
|
||||
Date,
|
||||
Type,
|
||||
}
|
||||
|
||||
impl SummarySort {
|
||||
pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
SummarySort::Page => "Page",
|
||||
SummarySort::Author => "Author",
|
||||
SummarySort::Date => "Date",
|
||||
SummarySort::Type => "Type",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<SummarySort> {
|
||||
Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Acrobat's name for a comment type, as comment lists show it.
|
||||
pub fn comment_type_name(subtype: &str) -> &str {
|
||||
match subtype {
|
||||
"Text" => "Sticky Note",
|
||||
"FreeText" => "Text Box",
|
||||
"Highlight" => "Highlight",
|
||||
"Underline" => "Underline",
|
||||
"StrikeOut" => "Strikethrough",
|
||||
"Squiggly" => "Squiggly",
|
||||
"Square" => "Rectangle",
|
||||
"Circle" => "Oval",
|
||||
"Line" => "Line",
|
||||
"Ink" => "Pencil",
|
||||
"Polygon" => "Polygon",
|
||||
"PolyLine" => "Polygonal Line",
|
||||
"Stamp" => "Stamp",
|
||||
"Caret" => "Inserted Text",
|
||||
"FileAttachment" => "File Attachment",
|
||||
"Sound" => "Sound",
|
||||
"Redact" => "Redaction",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`.
|
||||
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
match a.intent.as_deref() {
|
||||
Some("FreeTextCallout") => "Callout",
|
||||
Some("PolygonCloud") => "Cloud",
|
||||
Some("FreeTextTypeWriter") => "Typewriter",
|
||||
_ => comment_type_name(&a.subtype),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a comment summary: one block per comment (replies indented under it), with a
|
||||
/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are
|
||||
/// pop-ups; the number is the comment's position on its page.
|
||||
pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
|
||||
use std::fmt::Write;
|
||||
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect();
|
||||
// Each reply under the annotation its `/IRT` points at, in document order, gathered once.
|
||||
type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>;
|
||||
let mut children = Children::new();
|
||||
for r in all.iter().filter(|r| r.state.is_none()) {
|
||||
if let Some(parent) = r.parent {
|
||||
children.entry(parent).or_default().push(r);
|
||||
}
|
||||
}
|
||||
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
|
||||
// direct parent, so following one level would leave the deeper replies out of the summary.
|
||||
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
use std::fmt::Write;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
|
||||
let indent = " ".repeat(depth + 1);
|
||||
let _ =
|
||||
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
|
||||
for line in r.contents.as_deref().unwrap_or("").lines() {
|
||||
let _ = writeln!(out, "{indent}{line}");
|
||||
}
|
||||
write_replies(out, children, r, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
|
||||
let mut last = usize::MAX;
|
||||
let mut n = 0;
|
||||
for a in &top {
|
||||
if a.page != last {
|
||||
last = a.page;
|
||||
n = 0;
|
||||
}
|
||||
n += 1;
|
||||
numbered.push((n, a));
|
||||
}
|
||||
match sort {
|
||||
SummarySort::Page => {}
|
||||
SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)),
|
||||
SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)),
|
||||
SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))),
|
||||
}
|
||||
let mut out = format!("Summary of Comments on {name}\n\n");
|
||||
if numbered.is_empty() {
|
||||
out.push_str("This document has no comments.\n");
|
||||
return out;
|
||||
}
|
||||
let mut heading = usize::MAX;
|
||||
for (n, a) in numbered {
|
||||
if sort == SummarySort::Page && a.page != heading {
|
||||
heading = a.page;
|
||||
let _ = writeln!(out, "Page: {}", a.page + 1);
|
||||
}
|
||||
let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a));
|
||||
if sort != SummarySort::Page {
|
||||
let _ = write!(out, " Page: {}", a.page + 1);
|
||||
}
|
||||
let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or(""));
|
||||
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
|
||||
let _ = writeln!(out, "{c}");
|
||||
}
|
||||
write_replies(&mut out, &children, a, 0);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
//! An open document: its working file, view data and edit state.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_incremental};
|
||||
use pdfcraft_render::{DocInfo, RenderConfig, RenderPool};
|
||||
|
||||
use crate::history::Editor;
|
||||
use crate::{
|
||||
CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary,
|
||||
SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{record_link_scan, record_marker_scan};
|
||||
|
||||
pub struct Document {
|
||||
pub id: DocId,
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
/// The working file: what Save writes.
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances
|
||||
/// drawn for display only for comments that have none ([`display_bytes`]). Never saved.
|
||||
pub display: Arc<Vec<u8>>,
|
||||
pub info: DocInfo,
|
||||
pub renderer: RenderPool,
|
||||
/// The password the document was opened with (needed to read attachments, etc.).
|
||||
pub password: Option<String>,
|
||||
/// `true` when there are edits that have not been saved.
|
||||
pub dirty: bool,
|
||||
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
|
||||
pub(crate) generation: u64,
|
||||
/// The generation last handed out by `autosave_snapshots`.
|
||||
pub(crate) snapshot_generation: u64,
|
||||
/// Why the document cannot be edited (e.g. encryption), if so.
|
||||
pub read_only_reason: Option<String>,
|
||||
/// Interactive form fields of the current state (empty without a form).
|
||||
pub form: Arc<Vec<pdfcraft_forms::Field>>,
|
||||
pub(crate) marker_scan: OnceLock<pdfcraft_edit::MarkerScan>,
|
||||
pub(crate) links: OnceLock<Vec<pdfcraft_annot::links::LinkItem>>,
|
||||
/// Signature fields, validated against the session's trust store (Signatures panel).
|
||||
pub signatures: Arc<Vec<SignatureInfo>>,
|
||||
pub(crate) trust: Arc<TrustStore>,
|
||||
pub(crate) sig_cache: Arc<pdfcraft_sign::DigestCache>,
|
||||
pub(crate) editor: Option<Editor>,
|
||||
pub(crate) config: RenderConfig,
|
||||
/// What field scripts printed or asked for (see [`Session::take_js_output`]).
|
||||
pub(crate) js_output: js::JsOutput,
|
||||
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
|
||||
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
|
||||
pub xfa: Option<XfaLayout>,
|
||||
/// The parsed template of a laid-out XFA form, for its scripts.
|
||||
pub(crate) xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
|
||||
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
|
||||
pub(crate) xfa_scripts_off: bool,
|
||||
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
|
||||
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
|
||||
pub(crate) fonts_stale: bool,
|
||||
/// XFA forms: what was approximated, rewritten or could not be written to the XFA data
|
||||
/// (also in `info.warnings`, kept there when the document is re-read).
|
||||
pub xfa_warnings: Vec<String>,
|
||||
/// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed,
|
||||
/// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it.
|
||||
pub edit_notices: Vec<TextNotice>,
|
||||
}
|
||||
|
||||
/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn
|
||||
/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat
|
||||
/// keeps such text too, drawing it with fonts PdfCraft can't embed yet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TextNotice {
|
||||
/// The form field's full name, or `None` for a comment.
|
||||
pub field: Option<String>,
|
||||
/// The page (0-based) of the comment or of the field's widget.
|
||||
pub page: usize,
|
||||
pub ch: char,
|
||||
/// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances`
|
||||
/// set); otherwise the `?` shows in every viewer.
|
||||
pub redrawn_elsewhere: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextNotice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch));
|
||||
let page = self.page + 1;
|
||||
match (&self.field, self.redrawn_elsewhere) {
|
||||
(Some(name), true) => write!(
|
||||
f,
|
||||
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value"
|
||||
),
|
||||
(Some(name), false) => write!(
|
||||
f,
|
||||
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere"
|
||||
),
|
||||
(None, _) => write!(
|
||||
f,
|
||||
"a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying
|
||||
/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order.
|
||||
pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<TextNotice> {
|
||||
let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() };
|
||||
let redrawn_elsewhere = pdfcraft_forms::need_appearances(next);
|
||||
let mut names: Option<std::collections::HashMap<pdfcraft_cos::ObjRef, String>> = None;
|
||||
let mut out: Vec<TextNotice> = Vec::new();
|
||||
for (page, p) in pages.iter().enumerate() {
|
||||
let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a));
|
||||
for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) {
|
||||
let now = next.get(r);
|
||||
let Some(d) = now.as_dict() else { continue };
|
||||
let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue };
|
||||
// Only what this edit drew: a new annotation, or a new appearance.
|
||||
let was = prev.get(r);
|
||||
if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) {
|
||||
continue;
|
||||
}
|
||||
let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| {
|
||||
let names = names.get_or_insert_with(|| {
|
||||
pdfcraft_forms::fields(next)
|
||||
.into_iter()
|
||||
.flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>())
|
||||
.collect()
|
||||
});
|
||||
names.get(&r).cloned().unwrap_or_default()
|
||||
});
|
||||
if field.is_some() && out.iter().any(|n| n.field == field) {
|
||||
continue;
|
||||
}
|
||||
out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// How the document opens (Document Properties ▸ Initial View).
|
||||
/// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be
|
||||
/// read for editing).
|
||||
pub fn accessibility_check(&self, options: &a11y::Options) -> Option<a11y::Report> {
|
||||
self.editor.as_ref().map(|e| a11y::check(&e.cos, options))
|
||||
}
|
||||
|
||||
/// A script of this XFA form ran past its time limit and was abandoned, so the form's
|
||||
/// scripts no longer run in this document (reopening it runs them again). The script's
|
||||
/// error is in the JavaScript output; this is the state, for a viewer to show.
|
||||
pub fn xfa_scripts_off(&self) -> bool {
|
||||
self.xfa_scripts_off
|
||||
}
|
||||
|
||||
/// PDF Optimizer ▸ Audit space usage.
|
||||
pub fn audit_space(&self) -> Vec<optimize::SpaceUse> {
|
||||
self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Edit a PDF: the images `page` draws (0-based).
|
||||
pub fn page_images(&self, page: usize) -> Vec<pdfcraft_edit::PageImage> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mixed editable objects on one page. Source inventories are zero-based and added items
|
||||
/// appear once. Rectangles are user-space points, independent of the viewer's zoom.
|
||||
pub fn editable_objects(&self, page: usize) -> Result<Vec<EditableObject>, String> {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Displayed top-left-origin motion to a user-space vector. Use the linear map only,
|
||||
/// so even a large crop-box origin cannot erase a small drag by cancellation.
|
||||
pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> {
|
||||
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
|
||||
let [x0, y0, x1, y1] = p.crop.map(f64::from);
|
||||
let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3)));
|
||||
let (u, v) = match p.rotation {
|
||||
90 => (b, -a),
|
||||
180 => (-a, -b),
|
||||
270 => (-b, a),
|
||||
_ => (a, b),
|
||||
};
|
||||
let offset = [u * (x1 - x0), -v * (y1 - y0)];
|
||||
if !offset.iter().all(|v| v.is_finite()) {
|
||||
return Err("the move must be finite and non-zero".into());
|
||||
}
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
/// Save image as: image `index` on `page` as a file (extension, bytes).
|
||||
pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
|
||||
self.check_copying().map_err(|e| e.to_string())?;
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
|
||||
if img.is_form {
|
||||
return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into());
|
||||
}
|
||||
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
|
||||
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
|
||||
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Saved measurement annotations, calculated from their geometry and PDF scales, plus
|
||||
/// the ones that couldn't be read (unsupported formats are skipped, not fatal).
|
||||
pub fn measurements(&self) -> Result<measure::Listing, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
Ok(measure::list(&e.cos))
|
||||
}
|
||||
pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result<measure::Scale, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string())
|
||||
}
|
||||
/// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a
|
||||
/// point) where the page sets no scale.
|
||||
pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result<measure::Scale, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_paths(&self, page: usize) -> Result<measure::snap::Geometry, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A counter that changes with every edit (for caches of derived data).
|
||||
pub fn edit_generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
|
||||
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The form's calculation order: its calculated fields in the order they calculate, what each
|
||||
/// formula reads, what is wrong with the order, and the order Fix order writes.
|
||||
pub fn calculation_order(&self) -> CalculationOrder {
|
||||
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add alternate text: the figures, in document order.
|
||||
pub fn figures(&self) -> Vec<a11y::Figure> {
|
||||
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reachable indirect structure tags, in document order.
|
||||
pub fn tags(&self) -> Vec<a11y::TagInfo> {
|
||||
self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Marked-content items directly owned by one reachable indirect structure tag.
|
||||
pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result<Vec<a11y::TagContentItem>, a11y::TagError> {
|
||||
self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new()))
|
||||
}
|
||||
|
||||
/// The edit that fixes `rule`, for the rules with an automatic fix: the document language
|
||||
/// (`value` is the language), the title (`value` replaces it; else the current title or the
|
||||
/// file name) and the tab order.
|
||||
pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result<Edit, String> {
|
||||
let value = value.map(str::trim).filter(|v| !v.is_empty());
|
||||
match rule {
|
||||
a11y::Rule::PrimaryLanguage => {
|
||||
let mut v = self.initial_view();
|
||||
v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned());
|
||||
Ok(Edit::SetInitialView(Box::new(v)))
|
||||
}
|
||||
a11y::Rule::Title => {
|
||||
let title = value
|
||||
.map(str::to_owned)
|
||||
.or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty()))
|
||||
.unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned());
|
||||
let mut v = self.initial_view();
|
||||
v.display_title = true;
|
||||
Ok(Edit::Batch {
|
||||
label: "Set document title".into(),
|
||||
edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))],
|
||||
})
|
||||
}
|
||||
a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }),
|
||||
other => Err(format!("\"{}\" has no automatic fix", other.name())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initial_view(&self) -> InitialView {
|
||||
self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Signed: at least one signature field holds a signature.
|
||||
pub fn is_signed(&self) -> bool {
|
||||
self.signatures.iter().any(|s| s.signed)
|
||||
}
|
||||
|
||||
/// Whether comments are hidden (Comments ▸ Hide all comments).
|
||||
pub fn comments_hidden(&self) -> bool {
|
||||
self.config.hide_comments
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> Option<&str> {
|
||||
self.undo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> Option<&str> {
|
||||
self.redo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
/// What Undo would undo, in parts, for an interface that says it in another language.
|
||||
pub fn undo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l)
|
||||
}
|
||||
|
||||
/// What Redo would redo, in parts.
|
||||
pub fn redo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l)
|
||||
}
|
||||
|
||||
pub fn editable(&self) -> bool {
|
||||
self.editor.is_some()
|
||||
}
|
||||
|
||||
/// What the opening password allows; `None` when the document is not encrypted.
|
||||
pub fn permissions(&self) -> Option<pdfcraft_cos::Permissions> {
|
||||
self.editor.as_ref().and_then(|e| e.cos.permissions())
|
||||
}
|
||||
|
||||
/// Printing is allowed (Table 22, bit 3).
|
||||
pub fn allows_printing(&self) -> bool {
|
||||
self.permissions().is_none_or(|p| p.print())
|
||||
}
|
||||
|
||||
/// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying
|
||||
/// selected text, snapshots, Save Image As, exporting. Assistive technology may read the
|
||||
/// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask.
|
||||
pub fn allows_copying(&self) -> bool {
|
||||
self.permissions().is_none_or(|p| p.copy())
|
||||
}
|
||||
|
||||
/// [`Self::allows_copying`], as the error to report when it isn't.
|
||||
pub fn check_copying(&self) -> Result<(), EditError> {
|
||||
if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) }
|
||||
}
|
||||
|
||||
/// Page changes (insert, delete, rotate, move, extract) are allowed.
|
||||
pub fn allows_assembly(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.assemble())
|
||||
}
|
||||
|
||||
/// Redaction marks waiting to be applied.
|
||||
pub fn redaction_marks(&self) -> usize {
|
||||
self.info.annotations.iter().filter(|a| a.subtype == "Redact").count()
|
||||
}
|
||||
|
||||
/// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove.
|
||||
pub fn marks(&self) -> &[MarkKind] {
|
||||
&self
|
||||
.marker_scan
|
||||
.get_or_init(|| {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
#[cfg(test)]
|
||||
record_marker_scan();
|
||||
pdfcraft_edit::scan_markers(&e.cos)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.marks
|
||||
}
|
||||
|
||||
/// Text and images added with Edit a PDF ▸ Add content (still editable).
|
||||
pub fn added(&self) -> &[pdfcraft_edit::Added] {
|
||||
&self
|
||||
.marker_scan
|
||||
.get_or_init(|| {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
#[cfg(test)]
|
||||
record_marker_scan();
|
||||
pdfcraft_edit::scan_markers(&e.cos)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.added
|
||||
}
|
||||
|
||||
/// Link annotations, for Edit a PDF ▸ Link.
|
||||
pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] {
|
||||
self.links.get_or_init(|| {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
#[cfg(test)]
|
||||
record_link_scan();
|
||||
pdfcraft_annot::links::list(&e.cos)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Changes to content and document information are allowed.
|
||||
pub fn allows_modification(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.modify())
|
||||
}
|
||||
|
||||
/// How widget `widget` of text field `name` lays its value out, for asking whether a typed
|
||||
/// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no
|
||||
/// more than fits. `None` without such a field or widget, or when the document can't be
|
||||
/// edited.
|
||||
pub fn field_text_box(&self, name: &str, widget: usize) -> Option<form_appearance::TextBox> {
|
||||
let cos = &self.editor.as_ref()?.cos;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
let w = f.widgets.get(widget)?;
|
||||
Some(form_appearance::text_box(cos, f, w))
|
||||
}
|
||||
|
||||
/// Filling in form fields is allowed (Table 22, bits 6 and 9).
|
||||
pub fn allows_form_filling(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.fill_forms())
|
||||
}
|
||||
|
||||
/// Adding and changing comments is allowed (Table 22, bit 6).
|
||||
pub fn allows_annotation(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.annotate())
|
||||
}
|
||||
|
||||
/// The document's security may be changed (Protect, Remove security): it is editable and,
|
||||
/// if encrypted, was opened with the owner password.
|
||||
pub fn allows_security_change(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| unrestricted(&p))
|
||||
}
|
||||
|
||||
/// A password restricts the document and it wasn't opened with the owner password: entering
|
||||
/// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions.
|
||||
pub fn can_unlock(&self) -> bool {
|
||||
self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p))
|
||||
}
|
||||
|
||||
/// A summary of the document's security for Document Properties ▸ Security, including
|
||||
/// protection applied in this session (written by the next save).
|
||||
pub fn security_summary(&self) -> Option<SecuritySummary> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let h = editor.cos.output_handler()?;
|
||||
let d = h.dict();
|
||||
let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m);
|
||||
let method = match (d.v, stream) {
|
||||
(1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit",
|
||||
(1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit",
|
||||
(_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit",
|
||||
(_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit",
|
||||
_ => "Attachments only",
|
||||
};
|
||||
let pending = editor.cos.encryption_changed();
|
||||
let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() };
|
||||
Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending })
|
||||
}
|
||||
|
||||
/// Comment properties of the comment at `(page, index)` (Comment Properties dialog).
|
||||
pub fn comment_props(&self, page: usize, index: usize) -> Option<pdfcraft_annot::Props> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index))
|
||||
}
|
||||
|
||||
/// A form field's Appearance-tab look (borders, colours, font).
|
||||
pub fn field_look(&self, name: &str) -> Option<pdfcraft_forms::Look> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
Some(pdfcraft_forms::look(&e.cos, f))
|
||||
}
|
||||
|
||||
/// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields.
|
||||
pub fn field_check_style(&self, name: &str) -> Option<CheckStyle> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f))
|
||||
}
|
||||
|
||||
/// Whether text field `name`'s value is cut off by its box at the field's font size (#4018):
|
||||
/// the page (0-based) of the first widget that cuts it, and how.
|
||||
pub fn field_overflow(&self, name: &str) -> Option<(Option<usize>, pdfcraft_forms::appearance::Overflow)> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o)))
|
||||
}
|
||||
|
||||
/// Remove Hidden Information: what each category would remove.
|
||||
pub fn hidden_info(&self) -> Vec<(Hidden, usize)> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Verification: faux redactions (opaque covers over extractable text) and redaction
|
||||
/// marks that were never applied. Fails closed: any read error is returned, never an
|
||||
/// empty list.
|
||||
pub fn audit_redactions(&self) -> Result<Vec<pdfcraft_audit::AuditFinding>, String> {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes.
|
||||
pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Current value of a document-information entry (Title, Author, …).
|
||||
pub fn info_value(&self, key: &str) -> Option<String> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key))
|
||||
}
|
||||
|
||||
/// Whether the document information has an entry `key`, of any type, listed or not.
|
||||
pub fn has_info(&self, key: &str) -> bool {
|
||||
self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key))
|
||||
}
|
||||
|
||||
/// The custom document properties as they are now (name, text), Document Properties ▸ Custom.
|
||||
pub fn custom_info(&self) -> Vec<(String, String)> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The name shown on the tab and window: the document title when the document asks for it
|
||||
/// (Initial View ▸ Show: Document Title) and has a real one, else the file name.
|
||||
pub fn display_name(&self) -> String {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.filter(|e| pdfcraft_organize::displays_doc_title(&e.cos))
|
||||
.and_then(|e| pdfcraft_organize::info(&e.cos, "Title"))
|
||||
.map(|t| t.trim().to_owned())
|
||||
.filter(|t| !t.is_empty() && !is_placeholder_title(t))
|
||||
.unwrap_or_else(|| self.name.clone())
|
||||
}
|
||||
|
||||
/// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that
|
||||
/// revision). Unsaved edits aren't a revision yet.
|
||||
pub fn revision_ends(&self) -> Vec<usize> {
|
||||
self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The file as last opened or saved, without unsaved edits: what an incremental save appends
|
||||
/// to. This stays that file when undo has restored an older snapshot.
|
||||
pub fn saved_bytes(&self) -> Arc<Vec<u8>> {
|
||||
self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone())
|
||||
}
|
||||
|
||||
/// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
|
||||
pub fn repair_log(&self) -> Vec<String> {
|
||||
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A title that names no document: what a web browser writes when it saves a blank page or a
|
||||
/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a
|
||||
/// title says less than the file name, so the file name is shown instead. An address is one
|
||||
/// word: "About: our company" is a real title.
|
||||
pub(crate) fn is_placeholder_title(title: &str) -> bool {
|
||||
let t = title.trim().to_lowercase();
|
||||
let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p));
|
||||
address || t == "untitled"
|
||||
}
|
||||
|
||||
/// The working file plus appearances for comments that have none, and for form fields the file
|
||||
/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the
|
||||
/// viewer and never enter these bytes, which image/text exports and Print as Image also consume.
|
||||
pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
|
||||
let Some(e) = editor else { return bytes.clone() };
|
||||
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
|
||||
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
|
||||
let Some(display) = fields.or(comments) else {
|
||||
return bytes.clone();
|
||||
};
|
||||
match write_incremental(&display, &SaveOptions::default()) {
|
||||
Ok(written) => Arc::new(written),
|
||||
Err(err) => {
|
||||
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
|
||||
bytes.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `comments` (the display copy with comment appearances, if it needed any; else the document
|
||||
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
|
||||
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
|
||||
pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments)
|
||||
}
|
||||
|
||||
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
|
||||
pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation)
|
||||
}
|
||||
|
||||
/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read
|
||||
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none.
|
||||
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 6] {
|
||||
let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
|
||||
[
|
||||
(&mut info.title, read("Title")),
|
||||
(&mut info.author, read("Author")),
|
||||
(&mut info.subject, read("Subject")),
|
||||
(&mut info.keywords, read("Keywords")),
|
||||
(&mut info.creator, read("Creator")),
|
||||
(&mut info.producer, read("Producer")),
|
||||
]
|
||||
}
|
||||
|
||||
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
|
||||
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
|
||||
/// images agree with the app and independent viewers. It never changes the source document, and an
|
||||
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
|
||||
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
|
||||
guard(|| {
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
|
||||
.ok()
|
||||
.and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos))
|
||||
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
|
||||
.map(Arc::new)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(bytes)
|
||||
}
|
||||
|
||||
/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with
|
||||
/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their
|
||||
/// owner password this is the user password that owner authentication recovers; otherwise it is
|
||||
/// `password` itself. Render paths outside a [`Session`](crate::Session), such as the CLI, use this so the owner
|
||||
/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps
|
||||
/// it, so the renderer reports the same error it always did.
|
||||
pub fn render_password_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Option<String> {
|
||||
let password = password?;
|
||||
let recovered =
|
||||
guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten();
|
||||
Some(recovered.unwrap_or_else(|| password.to_owned()))
|
||||
}
|
||||
|
||||
/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it.
|
||||
pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option<String> {
|
||||
cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user))
|
||||
}
|
||||
|
||||
/// Fails when the renderer still draws text under an applied redaction mark.
|
||||
///
|
||||
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
|
||||
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
|
||||
/// /Widths, whose advances the interpreter approximates; a CMap
|
||||
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
|
||||
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
|
||||
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
|
||||
/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its
|
||||
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
|
||||
/// the document as it was.
|
||||
pub(crate) fn shown_under_redactions(
|
||||
after: &pdfcraft_cos::Document,
|
||||
marks: &[pdfcraft_redact::Mark],
|
||||
overlays: &[pdfcraft_cos::ObjRef],
|
||||
pages: &[pdfcraft_render::PageInfo],
|
||||
password: Option<&str>,
|
||||
) -> Result<(), EditError> {
|
||||
use pdfcraft_cos::Object;
|
||||
use pdfcraft_redact::RedactError;
|
||||
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
|
||||
marked.sort_unstable();
|
||||
marked.dedup();
|
||||
if marked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut shown = after.clone();
|
||||
let model = pdfcraft_model::pages(&shown);
|
||||
for &pi in &marked {
|
||||
let Some(page) = model.get(pi) else { continue };
|
||||
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
|
||||
let kept: Vec<Object> = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect();
|
||||
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
|
||||
let mut residue = 0;
|
||||
for &pi in &marked {
|
||||
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
|
||||
// A page that cannot be read back cannot be confirmed clean.
|
||||
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
|
||||
let rects: Vec<[f64; 4]> = marks
|
||||
.iter()
|
||||
.filter(|m| m.page == pi)
|
||||
.flat_map(|m| m.rects.iter())
|
||||
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
|
||||
.collect();
|
||||
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
|
||||
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
|
||||
let area = (x1 - x0) * (y1 - y0);
|
||||
if area.is_nan() || area <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Redaction itself takes any glyph a fifth under a mark; one that is half under it
|
||||
// and still drawn was not where redaction thought it was.
|
||||
let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area;
|
||||
if rects.iter().any(under) {
|
||||
residue += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if residue > 0 {
|
||||
return Err(RedactError::Residue(residue).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
//! The errors editing and combining report.
|
||||
|
||||
use crate::measure;
|
||||
|
||||
/// Why a file can't be a source for Combine Files (or Insert / Replace pages).
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SourceProblem {
|
||||
#[error("it is password-protected")]
|
||||
Password,
|
||||
#[error("the password is wrong")]
|
||||
WrongPassword,
|
||||
#[error("its security settings don't allow copying pages")]
|
||||
NotPermitted,
|
||||
#[error("{0}")]
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum EditError {
|
||||
#[error("no such document")]
|
||||
NoDocument,
|
||||
#[error("this document can't be edited: {0}")]
|
||||
ReadOnly(String),
|
||||
#[error("the document's security settings don't allow {0}; open it with the owner password to make this change")]
|
||||
NotPermitted(&'static str),
|
||||
#[error("{0}")]
|
||||
Organize(#[from] pdfcraft_organize::OrganizeError),
|
||||
#[error("{0}")]
|
||||
Bookmark(#[from] pdfcraft_organize::OutlineError),
|
||||
#[error("{0}")]
|
||||
Comment(#[from] pdfcraft_annot::AnnotError),
|
||||
#[error(transparent)]
|
||||
Measure(#[from] measure::MeasureError),
|
||||
#[error("{0}")]
|
||||
Protection(String),
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
#[error("{0}")]
|
||||
Form(#[from] pdfcraft_forms::FormError),
|
||||
#[error(transparent)]
|
||||
Redact(#[from] pdfcraft_redact::RedactError),
|
||||
#[error("{0}")]
|
||||
Print(String),
|
||||
#[error("{0}")]
|
||||
Accessibility(String),
|
||||
#[error(transparent)]
|
||||
Data(#[from] pdfcraft_xfdf::DataError),
|
||||
#[error("{0}")]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error("{0}")]
|
||||
Create(#[from] pdfcraft_create::CreateError),
|
||||
#[error("the edited document could not be written: {0}")]
|
||||
Write(String),
|
||||
#[error("the edited document could not be reopened: {0}")]
|
||||
Reopen(String),
|
||||
#[error("couldn't use {0}")]
|
||||
Source(String),
|
||||
#[error("nothing to undo")]
|
||||
NothingToUndo,
|
||||
#[error("nothing to redo")]
|
||||
NothingToRedo,
|
||||
#[error("{0}")]
|
||||
Sign(String),
|
||||
#[error("{0}")]
|
||||
Optimize(String),
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
#[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")]
|
||||
SignedRewrite(String),
|
||||
#[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")]
|
||||
Signed,
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl From<pdfcraft_sign::SignError> for EditError {
|
||||
fn from(e: pdfcraft_sign::SignError) -> Self {
|
||||
EditError::Sign(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them.
|
||||
|
||||
use crate::{Shape, script_outline, signature_image};
|
||||
|
||||
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
|
||||
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
|
||||
///
|
||||
/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`):
|
||||
/// the box runs along the displayed axes, like an image signature's
|
||||
/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across.
|
||||
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option<Shape> {
|
||||
let o = script_outline(text);
|
||||
let [left, bottom, right, top] = o.bounds();
|
||||
let span = (top - bottom).max(0.1);
|
||||
let width = right - left;
|
||||
if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let k = height / span;
|
||||
let rect = signature_image::upright_box(rotation, at, width * k, height)?;
|
||||
// Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box
|
||||
// is at this fraction of the user-space box. Exact for an unturned page.
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)];
|
||||
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
|
||||
Some(Shape::TypedSignature { rect, contours })
|
||||
}
|
||||
|
||||
/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`).
|
||||
pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] {
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
[at[0] + a * right + c * up, at[1] + b * right + d * up]
|
||||
}
|
||||
|
||||
/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`,
|
||||
/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a
|
||||
/// non-finite point.
|
||||
pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> {
|
||||
signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height)
|
||||
}
|
||||
|
||||
/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types
|
||||
/// it. `None` for text with no outlines.
|
||||
pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
|
||||
let o = script_outline(text);
|
||||
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Undo/redo: the editing state of a document, its snapshots and their labels.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AppearanceCache, Edit, EditError, Shape};
|
||||
|
||||
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
|
||||
pub(crate) const MAX_UNDO: usize = 100;
|
||||
|
||||
/// The passwords that go with one state of a document (protection can change them).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Keys {
|
||||
/// What the renderer and inspector open the working file with (the user password).
|
||||
pub(crate) render: Option<String>,
|
||||
/// What the editor re-opens a saved file with: the strongest password known (owner if any).
|
||||
pub(crate) reopen: Option<String>,
|
||||
}
|
||||
|
||||
/// What an edit can change, and so what the view data must be rebuilt from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Scope {
|
||||
/// Anything: re-inspect the whole document.
|
||||
Full,
|
||||
/// Document-information entries; page content and appearances are unchanged.
|
||||
Metadata,
|
||||
/// Only comments: re-read the comment list from the object graph.
|
||||
Comments,
|
||||
/// Only form field values (and their widget appearances).
|
||||
Form,
|
||||
/// Link annotations; the full inspection is refreshed, but page-content indexes stay valid.
|
||||
Links,
|
||||
/// Only page content streams, resources and added content/marks: the page tree,
|
||||
/// annotations, form fields, links, outline and fonts are unchanged, so the full
|
||||
/// re-inspection is skipped (seconds on very large files).
|
||||
Content,
|
||||
}
|
||||
|
||||
/// Edits during which field scripts run.
|
||||
pub(crate) fn uses_scripts(edit: &Edit) -> bool {
|
||||
match edit {
|
||||
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true,
|
||||
Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
match edit {
|
||||
Edit::SetInfo { .. } => Scope::Metadata,
|
||||
// The Attachments list is read from the whole document.
|
||||
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full,
|
||||
// A file attachment also changes the Attachments list.
|
||||
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::AddCustomStamp { .. }
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
| Edit::SetAnnotationContents { .. }
|
||||
| Edit::ReplyToAnnotation { .. }
|
||||
| Edit::SetAnnotationStatus { .. }
|
||||
| Edit::MarkAnnotation { .. }
|
||||
| Edit::LockAnnotation { .. }
|
||||
| Edit::ReplaceText { .. }
|
||||
| Edit::EraseInk { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ReshapeAnnotation { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
|
||||
// Page content, resources and added content/marks only: the page tree, annotations,
|
||||
// fields, links and document metadata are untouched (see `refresh_content`).
|
||||
Edit::MoveObjects { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::AddText { .. }
|
||||
| Edit::AddImage { .. }
|
||||
| Edit::UpdateContent { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::AddHeaderFooter { .. }
|
||||
| Edit::AddWatermark { .. }
|
||||
| Edit::AddBackground { .. }
|
||||
| Edit::RemoveMarks { .. } => Scope::Content,
|
||||
Edit::Batch { edits, .. } => {
|
||||
let mut scopes = edits.iter().map(scope_of);
|
||||
let first = scopes.next().unwrap_or(Scope::Full);
|
||||
if scopes.all(|s| s == first) { first } else { Scope::Full }
|
||||
}
|
||||
_ => Scope::Full,
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo/redo step: its label, the document state, its passwords, and what it changed.
|
||||
pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope);
|
||||
|
||||
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Editor {
|
||||
pub(crate) cos: pdfcraft_cos::Document,
|
||||
/// The file as last opened or saved. Incremental saves append to it, and Revert opens it.
|
||||
/// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it.
|
||||
pub(crate) saved: Arc<Vec<u8>>,
|
||||
/// Passwords that open [`Self::saved`].
|
||||
pub(crate) saved_keys: Keys,
|
||||
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
|
||||
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
|
||||
/// undo brings back, or closing it would lose the whole document without asking.
|
||||
pub(crate) unsaved_file: bool,
|
||||
pub(crate) undo: Vec<Snapshot>,
|
||||
pub(crate) redo: Vec<Snapshot>,
|
||||
pub(crate) keys: Keys,
|
||||
/// Display appearances built for annotations without any, reused while no annotation edit
|
||||
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
|
||||
/// that may change annotations; see [`AppearanceCache`].
|
||||
pub(crate) appearances: AppearanceCache,
|
||||
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
|
||||
/// for each widget while the objects it was drawn from are unchanged; see
|
||||
/// [`pdfcraft_forms::FieldAppearanceCache`].
|
||||
pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache,
|
||||
/// The text blocks last computed for one page, reused while the document is unchanged.
|
||||
pub(crate) text_blocks: TextBlocksCache,
|
||||
}
|
||||
|
||||
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
|
||||
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
|
||||
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
|
||||
/// with undo, so a stale entry never matches, also after the document has been edited.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
|
||||
|
||||
/// The document state, the page (0-based) and its text blocks.
|
||||
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
||||
|
||||
impl Clone for TextBlocksCache {
|
||||
fn clone(&self) -> Self {
|
||||
// A poisoned cache only loses the cached blocks.
|
||||
Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
|
||||
pub(crate) fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
|
||||
if let Ok(cache) = self.text_blocks.0.lock()
|
||||
&& let Some((state, p, blocks)) = cache.as_ref()
|
||||
&& *p == page
|
||||
&& state.matches(&self.cos)
|
||||
{
|
||||
return Ok(blocks.clone());
|
||||
}
|
||||
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
|
||||
if let Ok(mut cache) = self.text_blocks.0.lock() {
|
||||
*cache = Some((self.cos.read_state(), page, blocks.clone()));
|
||||
}
|
||||
Ok(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts:
|
||||
/// a whole English wording, and the one value the document itself supplies for the wording's hole
|
||||
/// (a file name, a field name). An interface in another language looks the wording up and says the
|
||||
/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so
|
||||
/// the two cannot drift apart.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Label {
|
||||
/// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS).
|
||||
pub wording: &'static str,
|
||||
/// What the document supplies, when the wording has a hole for it.
|
||||
pub value: Option<String>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
/// A wording with nothing to fill in.
|
||||
pub fn of(wording: &'static str) -> Self {
|
||||
Self { wording, value: None, text: wording.to_string() }
|
||||
}
|
||||
|
||||
/// A wording whose hole the document fills in.
|
||||
pub fn with(wording: &'static str, value: impl Into<String>) -> Self {
|
||||
let mut label = Self { wording, value: Some(value.into()), text: String::new() };
|
||||
label.text = label.said(wording);
|
||||
label
|
||||
}
|
||||
|
||||
/// The label read with this wording: the interface's translation of [`wording`](Self::wording),
|
||||
/// or the English wording itself. What the document supplies goes in as it stands and is not
|
||||
/// read again, so a name with braces in it stays whole.
|
||||
pub fn said(&self, wording: &str) -> String {
|
||||
let Some(value) = &self.value else { return wording.to_owned() };
|
||||
// `{` and `}` are ASCII, so these are char boundaries.
|
||||
match (wording.find('{'), wording.find('}')) {
|
||||
(Some(open), Some(close)) if open < close => {
|
||||
let mut said = String::with_capacity(wording.len() + value.len());
|
||||
said.push_str(&wording[..open]);
|
||||
said.push_str(value);
|
||||
said.push_str(&wording[close + 1..]);
|
||||
said
|
||||
}
|
||||
// A translation that lost its hole is said as it stands rather than losing the value.
|
||||
_ => wording.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The label as English reads it.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Label {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// The passwords after `edit`, if it changes them.
|
||||
pub(crate) fn keys_after(edit: &Edit) -> Option<Keys> {
|
||||
match edit {
|
||||
Edit::Protect(p) => {
|
||||
Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) })
|
||||
}
|
||||
Edit::RemoveProtection => Some(Keys::default()),
|
||||
Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Layer (optional content) visibility: what the view shows and what printing writes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{Layer, LayerOp, RenderPool};
|
||||
|
||||
use crate::{Document, render_threads};
|
||||
|
||||
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
|
||||
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
|
||||
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
|
||||
/// is left alone.
|
||||
pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object};
|
||||
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
|
||||
let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
|
||||
let Some(properties) = root.get(b"OCProperties").cloned() else { return };
|
||||
let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
|
||||
let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
|
||||
let refs =
|
||||
|visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
|
||||
config.set(b"BaseState".to_vec(), Object::name("ON"));
|
||||
config.set(b"ON".to_vec(), refs(true));
|
||||
config.set(b"OFF".to_vec(), refs(false));
|
||||
properties.set(b"D".to_vec(), Object::Dict(config));
|
||||
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
|
||||
cos.set(root_ref, Object::Dict(root));
|
||||
}
|
||||
|
||||
/// Render `doc` with the layer visibility in its `info.layers`.
|
||||
pub(crate) fn use_layer_choices(doc: &mut Document) {
|
||||
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
|
||||
doc.config.layers = Arc::new(overrides);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
|
||||
}
|
||||
|
||||
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
|
||||
/// state the changes before it left. Returns whether any layer ended up changed.
|
||||
pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
|
||||
let before: Vec<bool> = layers.iter().map(|l| l.visible).collect();
|
||||
let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect();
|
||||
for &(op, ocg) in changes {
|
||||
let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue };
|
||||
let on = match op {
|
||||
LayerOp::On => true,
|
||||
LayerOp::Off => false,
|
||||
LayerOp::Toggle => !layer.visible,
|
||||
};
|
||||
layer.visible = on;
|
||||
// Turning a layer off leaves the rest of its groups alone.
|
||||
if on && preserve_rb {
|
||||
for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) {
|
||||
if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) {
|
||||
l.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
layers.iter().zip(before).any(|(l, was)| l.visible != was)
|
||||
}
|
||||
+50
-5655
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
//! Opening a document: preparing it away from the session, then registering it.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_incremental};
|
||||
use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
|
||||
|
||||
use crate::history::{Editor, Keys, TextBlocksCache};
|
||||
use crate::{
|
||||
AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings,
|
||||
recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
|
||||
};
|
||||
|
||||
/// Immutable session state needed while a document is prepared away from its owning thread.
|
||||
#[derive(Clone)]
|
||||
pub struct OpenContext {
|
||||
now: Option<i64>,
|
||||
js_off: bool,
|
||||
trust: Arc<TrustStore>,
|
||||
xfa_time_scale: u32,
|
||||
}
|
||||
|
||||
/// A fully prepared document that has not yet been registered in a session.
|
||||
pub struct PreparedDocument {
|
||||
document: Document,
|
||||
}
|
||||
|
||||
impl OpenContext {
|
||||
/// Prepare a document without mutating the session that supplied this context.
|
||||
pub fn prepare_open(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
let name = name.into();
|
||||
guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password))
|
||||
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
|
||||
pub fn open_context(&self) -> OpenContext {
|
||||
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) }
|
||||
}
|
||||
|
||||
/// Prepare a document without adding it to this session.
|
||||
pub fn prepare_open(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
self.open_context().prepare_open(name, path, bytes, password)
|
||||
}
|
||||
|
||||
/// Register a prepared document. This is the only open step that mutates the session.
|
||||
pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId {
|
||||
self.next_id += 1;
|
||||
let id = DocId(self.next_id);
|
||||
let mut document = prepared.document;
|
||||
document.id = id;
|
||||
self.docs.push(document);
|
||||
id
|
||||
}
|
||||
|
||||
/// Synchronous convenience API used by the CLI and automation.
|
||||
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||||
let prepared = self.prepare_open(name, path, bytes, password)?;
|
||||
Ok(self.commit_open(prepared))
|
||||
}
|
||||
|
||||
fn prepare_open_unguarded(
|
||||
context: &OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password));
|
||||
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
|
||||
// files, so give it the user password that owner authentication recovers.
|
||||
let (info, render_password) = match inspect(bytes.clone(), password) {
|
||||
Ok(info) => (info, password.map(str::to_owned)),
|
||||
Err(OpenError::WrongPassword) => {
|
||||
let user = match &cos {
|
||||
Ok(Ok(d)) => recovered_render_password(d),
|
||||
_ => None,
|
||||
};
|
||||
let user = user.ok_or(OpenError::WrongPassword)?;
|
||||
(inspect(bytes.clone(), Some(&user))?, Some(user))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// A dynamic XFA form is a shell around an XML template; lay the template out into real
|
||||
// pages and fields so the rest of the engine works on it. The original bytes stay: the
|
||||
// laid-out form is one appended revision.
|
||||
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// Write `work` as one more revision and reopen it: what the document then is.
|
||||
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
|
||||
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
|
||||
let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
Ok((new_bytes, new_info, new_cos))
|
||||
};
|
||||
let (bytes, info, cos, xfa) = match (info.xfa, cos) {
|
||||
(Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => {
|
||||
let mut work = cos.clone();
|
||||
let laid_out = guard(|| xfa_layout(&mut work))
|
||||
.unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})")))
|
||||
.and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report)));
|
||||
match laid_out {
|
||||
Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)),
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A static XFA form, or a dynamic one laid out earlier: its datasets may hold values
|
||||
// filled in by another viewer since; give the fields those values.
|
||||
(Some(_), Ok(Ok(cos))) => {
|
||||
let report = pdfcraft_xfa::existing_layout(&cos);
|
||||
let mut work = cos.clone();
|
||||
let synced =
|
||||
guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})")));
|
||||
match synced {
|
||||
Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report),
|
||||
Ok(names) => match rebase(&work) {
|
||||
Ok((b, mut i, c)) => {
|
||||
// The fields were rewritten from the XFA data: say so, and which.
|
||||
let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect();
|
||||
let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() };
|
||||
i.warnings.push(format!(
|
||||
"{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}",
|
||||
names.len(),
|
||||
if names.len() == 1 { "" } else { "s" },
|
||||
shown.join(", ")
|
||||
));
|
||||
(b, i, Ok(Ok(c)), report)
|
||||
}
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), report)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), report)
|
||||
}
|
||||
}
|
||||
}
|
||||
(_, cos) => (bytes, info, cos, None),
|
||||
};
|
||||
// A laid-out XFA form: its initialize and calculate scripts run now, as on opening in
|
||||
// Acrobat; what they change is one more revision, still nothing to save.
|
||||
let mut script_output = js::JsOutput::default();
|
||||
let mut ran_away = false;
|
||||
let xfa_template = match (&xfa, &cos) {
|
||||
(Some(_), Ok(Ok(c))) => xfa::template(c),
|
||||
_ => None,
|
||||
};
|
||||
let (bytes, info, cos) = match (&xfa, cos) {
|
||||
(Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() {
|
||||
Some(tpl) => {
|
||||
let mut work = cos.clone();
|
||||
let pages = info.pages.len();
|
||||
let ran = guard(|| {
|
||||
let mut run = xfa::XfaRun {
|
||||
page_count: pages,
|
||||
out: &mut script_output,
|
||||
datasets: None,
|
||||
ran_away: false,
|
||||
time_scale: context.xfa_time_scale,
|
||||
};
|
||||
let changes = xfa::on_open(&mut work, &tpl, &mut run);
|
||||
if run.ran_away {
|
||||
ran_away = true;
|
||||
}
|
||||
changes
|
||||
})
|
||||
.unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})")));
|
||||
match ran {
|
||||
Ok(changes) if work.is_modified() => match rebase(&work) {
|
||||
Ok((b, mut i, c)) => {
|
||||
// The file as saved differs from what opening shows: say so.
|
||||
if !changes.is_empty() {
|
||||
i.warnings.push(format!(
|
||||
"This form's scripts changed it on opening ({}); saving keeps those changes",
|
||||
changes.describe()
|
||||
));
|
||||
}
|
||||
(b, i, Ok(Ok(c)))
|
||||
}
|
||||
Err(e) => {
|
||||
script_output.errors.push(format!("The form's scripts could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)))
|
||||
}
|
||||
},
|
||||
Ok(_) => (bytes, info, Ok(Ok(cos))),
|
||||
Err(e) => {
|
||||
script_output.errors.push(format!("The form's scripts could not run: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => (bytes, info, Ok(Ok(cos))),
|
||||
},
|
||||
(_, cos) => (bytes, info, cos),
|
||||
};
|
||||
// The catalog leads to no page, but the reader found pages by scanning: gather the page
|
||||
// objects into a page tree as one more revision, or page edits address none of them
|
||||
// (#1656). Kept only when the reader then shows the same pages, and never on a signed
|
||||
// document, where writing the repair could rewrite the file under its signatures.
|
||||
let (bytes, info, cos) = match cos {
|
||||
Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => {
|
||||
let mut work = cos.clone();
|
||||
let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed);
|
||||
let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) {
|
||||
Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)),
|
||||
_ => None,
|
||||
};
|
||||
match rebuilt {
|
||||
Some((b, i, mut c)) => {
|
||||
for line in cos.repair_log() {
|
||||
c.note_repair(line.clone());
|
||||
}
|
||||
c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len()));
|
||||
(b, i, Ok(Ok(c)))
|
||||
}
|
||||
None => (bytes, info, Ok(Ok(cos))),
|
||||
}
|
||||
}
|
||||
cos => (bytes, info, cos),
|
||||
};
|
||||
// XFA notes survive the document being re-read after edits.
|
||||
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
|
||||
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
|
||||
// A script that ran away on open turns the form's scripts off for this document.
|
||||
document.xfa_template = if ran_away { None } else { xfa_template };
|
||||
document.js_output.append(script_output);
|
||||
if ran_away {
|
||||
document.xfa_scripts_off = true;
|
||||
document.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
note_warnings(&mut document.xfa_warnings, &xfa_warnings);
|
||||
Ok(PreparedDocument { document })
|
||||
}
|
||||
|
||||
/// The last step of opening: build the document record and register it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_document(
|
||||
trust: &Arc<TrustStore>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
mut info: DocInfo,
|
||||
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
|
||||
render_password: Option<String>,
|
||||
password: Option<&str>,
|
||||
xfa: Option<XfaLayout>,
|
||||
) -> Document {
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let (mut editor, read_only_reason) = match cos {
|
||||
Ok(Ok(cos)) => {
|
||||
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
|
||||
let saved = cos.bytes().clone();
|
||||
(
|
||||
Some(Editor {
|
||||
cos,
|
||||
saved,
|
||||
saved_keys: keys.clone(),
|
||||
unsaved_file: false,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
keys,
|
||||
appearances: AppearanceCache::default(),
|
||||
field_appearances: pdfcraft_forms::FieldAppearanceCache::default(),
|
||||
text_blocks: TextBlocksCache::default(),
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => (None, Some(e.to_string())),
|
||||
Err(_) => (None, Some("the document structure could not be read for editing".into())),
|
||||
};
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
|
||||
// The inspection reads only `/Info`; a file stating its title or author only in XMP still
|
||||
// shows them (#2373).
|
||||
if let Some(e) = &editor {
|
||||
for (field, value) in info_fields(&mut info, &e.cos) {
|
||||
if field.is_none() {
|
||||
*field = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
let display = display_bytes(editor.as_mut(), &bytes);
|
||||
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
|
||||
let (form, adopted) = editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos);
|
||||
xfa::mark_script_buttons(&e.cos, &mut fields.items);
|
||||
(fields.items, fields.adopted_page_fields)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Fields the file lists only as page widgets are adopted leniently; the file says so.
|
||||
if adopted > 0
|
||||
&& let Some(e) = &mut editor
|
||||
{
|
||||
let line = if adopted == 1 {
|
||||
"the form's /Fields list omits 1 field; it was read from the page annotations".to_string()
|
||||
} else {
|
||||
format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations")
|
||||
};
|
||||
e.cos.note_repair(line);
|
||||
}
|
||||
Document {
|
||||
id: DocId(0),
|
||||
name,
|
||||
path,
|
||||
bytes,
|
||||
display,
|
||||
info,
|
||||
renderer,
|
||||
password: render_password,
|
||||
dirty: false,
|
||||
generation: 0,
|
||||
snapshot_generation: 0,
|
||||
read_only_reason,
|
||||
form: Arc::new(form),
|
||||
marker_scan: OnceLock::new(),
|
||||
links: OnceLock::new(),
|
||||
signatures: Arc::new(signatures),
|
||||
trust: trust.clone(),
|
||||
sig_cache,
|
||||
editor,
|
||||
config,
|
||||
js_output: Default::default(),
|
||||
xfa,
|
||||
xfa_template: None,
|
||||
xfa_scripts_off: false,
|
||||
fonts_stale: false,
|
||||
xfa_warnings: Vec::new(),
|
||||
edit_notices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Page filters: even or odd page numbers, and orientation as displayed.
|
||||
|
||||
/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and
|
||||
/// orientation (as displayed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PageParity {
|
||||
#[default]
|
||||
Both,
|
||||
Even,
|
||||
Odd,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PageOrientation {
|
||||
#[default]
|
||||
Both,
|
||||
Landscape,
|
||||
Portrait,
|
||||
}
|
||||
|
||||
/// The pages of `pages` (0-based) that pass the filters.
|
||||
pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec<usize> {
|
||||
pages
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| match parity {
|
||||
PageParity::Both => true,
|
||||
// Page numbers are 1-based: index 1 is page 2, an even page.
|
||||
PageParity::Even => p % 2 == 1,
|
||||
PageParity::Odd => p % 2 == 0,
|
||||
})
|
||||
.filter(|p| {
|
||||
let Some(pg) = info.pages.get(*p) else { return false };
|
||||
match orientation {
|
||||
PageOrientation::Both => true,
|
||||
PageOrientation::Landscape => pg.width > pg.height,
|
||||
PageOrientation::Portrait => pg.width <= pg.height,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
|
||||
/// (0-based) of a PDF.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MarkFile {
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
|
||||
pub(crate) fn embed_image(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
|
||||
pdfcraft_create::image_xobject(doc, name, bytes)
|
||||
}
|
||||
|
||||
/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its
|
||||
/// shape in a box of any other shape. A box that is already that shape (to within rounding) is
|
||||
/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite
|
||||
/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse.
|
||||
pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] {
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let usable = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !(usable(bw) && usable(bh) && usable(w) && usable(h)) {
|
||||
return rect;
|
||||
}
|
||||
let k = (bw / w).min(bh / h);
|
||||
let (fw, fh) = (w * k, h * k);
|
||||
if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 {
|
||||
return rect;
|
||||
}
|
||||
let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0);
|
||||
[cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0]
|
||||
}
|
||||
|
||||
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
|
||||
pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
|
||||
let head = &f.bytes[..f.bytes.len().min(1024)];
|
||||
if head.windows(5).any(|w| w == b"%PDF-") {
|
||||
let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?;
|
||||
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
|
||||
} else {
|
||||
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Password protection to apply, and the security a document has.
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// Document Properties ▸ Security.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SecuritySummary {
|
||||
/// "AES, 256-bit" etc.
|
||||
pub method: String,
|
||||
/// Opened with the owner password (no restrictions apply).
|
||||
pub owner: bool,
|
||||
/// For pending protection: the restrictions as they will apply to others.
|
||||
pub permissions: pdfcraft_cos::Permissions,
|
||||
/// Set in this session; written by the next save.
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// What printing a protected document allows (Acrobat: Printing allowed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Printing {
|
||||
None,
|
||||
/// "Low Resolution (150 dpi)".
|
||||
Low,
|
||||
High,
|
||||
}
|
||||
|
||||
/// What changes a protected document allows (Acrobat: Changes allowed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Changes {
|
||||
None,
|
||||
/// "Inserting, deleting, and rotating pages".
|
||||
Pages,
|
||||
/// "Filling in form fields and signing existing signature fields".
|
||||
FillSign,
|
||||
/// "Commenting, filling in form fields, and signing existing signature fields".
|
||||
CommentFillSign,
|
||||
/// "Any except extracting pages".
|
||||
AnyExceptExtract,
|
||||
}
|
||||
|
||||
/// Password protection to apply (Protect Using Password and its Advanced options).
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Protection {
|
||||
/// Required to open the document (the user password).
|
||||
pub open_password: Option<String>,
|
||||
/// Required to change security and lift the restrictions below (the owner password).
|
||||
pub permissions_password: Option<String>,
|
||||
pub printing: Printing,
|
||||
pub changes: Changes,
|
||||
/// "Enable copying of text, images, and other content".
|
||||
pub copy: bool,
|
||||
/// "Enable text access for screen reader devices".
|
||||
pub accessibility: bool,
|
||||
/// Compatibility level; AES-256 (Acrobat X and later) by default.
|
||||
pub algorithm: pdfcraft_cos::Algorithm,
|
||||
/// `false`: "Encrypt all document contents except metadata".
|
||||
pub encrypt_metadata: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Protection {
|
||||
// Never print passwords (edits end up in logs and error messages).
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Protection")
|
||||
.field("open_password", &self.open_password.as_ref().map(|_| "…"))
|
||||
.field("permissions_password", &self.permissions_password.as_ref().map(|_| "…"))
|
||||
.field("printing", &self.printing)
|
||||
.field("changes", &self.changes)
|
||||
.field("copy", &self.copy)
|
||||
.field("accessibility", &self.accessibility)
|
||||
.field("algorithm", &self.algorithm)
|
||||
.field("encrypt_metadata", &self.encrypt_metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Protection {
|
||||
/// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open_password: None,
|
||||
permissions_password: None,
|
||||
printing: Printing::High,
|
||||
changes: Changes::None,
|
||||
copy: false,
|
||||
accessibility: true,
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
encrypt_metadata: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Protection {
|
||||
/// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices.
|
||||
pub fn permission_bits(&self) -> i32 {
|
||||
let bit = |n: u32| 1i32 << (n - 1);
|
||||
if self.permissions_password.is_none() {
|
||||
return -1; // nothing restricted without a permissions password
|
||||
}
|
||||
let mut p = 0;
|
||||
match self.printing {
|
||||
Printing::None => {}
|
||||
Printing::Low => p |= bit(3),
|
||||
Printing::High => p |= bit(3) | bit(12),
|
||||
}
|
||||
p |= match self.changes {
|
||||
Changes::None => 0,
|
||||
Changes::Pages => bit(11),
|
||||
Changes::FillSign => bit(9),
|
||||
Changes::CommentFillSign => bit(6) | bit(9),
|
||||
Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9),
|
||||
};
|
||||
if self.copy {
|
||||
p |= bit(5) | bit(10);
|
||||
}
|
||||
if self.accessibility {
|
||||
p |= bit(10);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
pub(crate) fn validate(&self) -> Result<(), EditError> {
|
||||
let bad = |m: &str| Err(EditError::Protection(m.to_string()));
|
||||
match (&self.open_password, &self.permissions_password) {
|
||||
(None, None) => return bad("enter a password to open the document, a permissions password, or both"),
|
||||
(Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"),
|
||||
_ => {}
|
||||
}
|
||||
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) {
|
||||
return bad("passwords can't be empty");
|
||||
}
|
||||
if self.algorithm != pdfcraft_cos::Algorithm::Aes256
|
||||
&& [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c)))
|
||||
{
|
||||
return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)");
|
||||
}
|
||||
// Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep,
|
||||
// which drops soft hyphens and folds compatibility forms; older levels: the first 32
|
||||
// bytes, padded). One that comes out as the empty password opens the file without asking,
|
||||
// and an open password that comes out as the permissions password lifts the restrictions.
|
||||
let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p);
|
||||
let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective));
|
||||
let empty = effective("");
|
||||
if open.iter().chain(&permissions).any(|p| *p == empty) {
|
||||
return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens");
|
||||
}
|
||||
if open.is_some() && open == permissions {
|
||||
return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Opened as owner, or nothing is restricted (no permissions password was set): security may
|
||||
/// be changed, as in Acrobat.
|
||||
pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool {
|
||||
const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12
|
||||
p.owner || p.bits & ALL == ALL
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one.
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
|
||||
|
||||
pub use pdfcraft_organize::LabelStyle;
|
||||
pub use pdfcraft_organize::Standard;
|
||||
pub use pdfcraft_organize::check_custom_key;
|
||||
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
|
||||
|
||||
pub use pdfcraft_cos::Algorithm;
|
||||
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
|
||||
pub use pdfcraft_edit::added::{
|
||||
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
|
||||
text_width as added_text_width,
|
||||
};
|
||||
pub use pdfcraft_edit::{
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
};
|
||||
pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
|
||||
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
|
||||
calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable};
|
||||
pub use pdfcraft_measure as measure;
|
||||
pub use pdfcraft_xfa::Report as XfaLayout;
|
||||
|
||||
pub use pdfcraft_edit::{ArrangeOrder, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
|
||||
|
||||
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
|
||||
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
pub use pdfcraft_annot::{
|
||||
AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps,
|
||||
ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use pdfcraft_forms::detect;
|
||||
pub use pdfcraft_optimize as optimize;
|
||||
pub use pdfcraft_preflight as pdfa;
|
||||
pub use pdfcraft_print as print;
|
||||
pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet};
|
||||
pub use pdfcraft_redact::patterns::{
|
||||
MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list,
|
||||
};
|
||||
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
|
||||
pub use pdfcraft_sign as sign;
|
||||
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
|
||||
pub use pdfcraft_xfdf::Format as DataFormat;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
//! Other files to combine with or copy pages from: opening them, and why one can't be used.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{EditError, SourceProblem};
|
||||
|
||||
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
|
||||
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
|
||||
|
||||
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
|
||||
/// separate (old fully-qualified name → new one), in order across the sources.
|
||||
#[derive(Debug)]
|
||||
pub struct Combined {
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Parse another PDF to copy pages from.
|
||||
pub(crate) fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
|
||||
open_source_with(name, bytes, None)
|
||||
}
|
||||
|
||||
/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file.
|
||||
pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, EditError> {
|
||||
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
||||
Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword),
|
||||
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
||||
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not
|
||||
/// why: what Combine would refuse it for, so the Combine files list can say so before Combine
|
||||
/// is pressed. The owner (permissions) password lifts the restriction on copying pages.
|
||||
pub fn combine_source_check(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<(), SourceProblem> {
|
||||
source_document(bytes, password).map(|_| ())
|
||||
}
|
||||
|
||||
/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever
|
||||
/// its permissions allow; `None` when it can't be read.
|
||||
pub fn source_page_count(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option<usize> {
|
||||
let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?;
|
||||
pdfcraft_organize::page_count(&doc).ok()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Test-only hooks: injected refresh panics and counts of the secondary page scans.
|
||||
|
||||
thread_local! {
|
||||
static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
|
||||
static REFRESH_PANICS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub(crate) fn panic_on_refresh(count: usize) {
|
||||
REFRESH_PANICS.with(|n| n.set(count));
|
||||
}
|
||||
|
||||
pub(crate) fn check_refresh_panic() {
|
||||
REFRESH_PANICS.with(|n| {
|
||||
let rem = n.get();
|
||||
if rem > 0 {
|
||||
n.set(rem - 1);
|
||||
panic!("injected refresh panic");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn reset_secondary_scan_counts() {
|
||||
SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0)));
|
||||
}
|
||||
|
||||
pub(crate) fn secondary_scan_counts() -> (usize, usize) {
|
||||
SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn record_marker_scan() {
|
||||
SECONDARY_SCAN_COUNTS.with(|counts| {
|
||||
let (markers, links) = counts.get();
|
||||
counts.set((markers + 1, links));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn record_link_scan() {
|
||||
SECONDARY_SCAN_COUNTS.with(|counts| {
|
||||
let (markers, links) = counts.get();
|
||||
counts.set((markers, links + 1));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::document::is_placeholder_title;
|
||||
use super::edit::check_permission;
|
||||
use super::*;
|
||||
use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors};
|
||||
|
||||
/// A minimal `n`-page document; page `i` shows "Page i+1".
|
||||
pub(crate) fn fixture(n: usize) -> Vec<u8> {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out.
|
||||
|
||||
use crate::edit::EditCtx;
|
||||
use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa};
|
||||
|
||||
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
|
||||
pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
|
||||
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
|
||||
xfa_redraw(doc)?;
|
||||
// The form's own values: one its field's font can't draw is drawn with `?` and reported.
|
||||
report.warnings.extend(undrawn_field_warnings(doc));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the
|
||||
/// picture its signer's application drew.
|
||||
pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> {
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) {
|
||||
continue;
|
||||
}
|
||||
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
|
||||
/// can't draw (laying out an XFA form's own values).
|
||||
pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec<String> {
|
||||
pdfcraft_forms::undrawn_fields(doc)
|
||||
.into_iter()
|
||||
.map(|(name, c)| {
|
||||
format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The form's fields as the XFA data layer wants them.
|
||||
fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum> {
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
pdfcraft_forms::fields(doc)
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let data = match f.kind {
|
||||
K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")),
|
||||
K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()),
|
||||
K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()),
|
||||
K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None,
|
||||
};
|
||||
pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not
|
||||
/// be written.
|
||||
/// `datasets`: the stream this edit already wrote, replaced in place rather than added again
|
||||
/// (and set to the one written).
|
||||
/// `tpl`: the form's parsed template when the document has one cached (else it is parsed).
|
||||
pub(crate) fn xfa_sync_datasets(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
tpl: Option<&pdfcraft_xfa::model::Template>,
|
||||
datasets: &mut Option<pdfcraft_cos::ObjRef>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?;
|
||||
if r.stream.is_some() {
|
||||
*datasets = r.stream;
|
||||
}
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
/// What a document whose form script ran away is told.
|
||||
pub(crate) const XFA_SCRIPTS_OFF: &str =
|
||||
"A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)";
|
||||
|
||||
/// Most XFA warnings kept per document.
|
||||
const MAX_XFA_WARNINGS: usize = 50;
|
||||
|
||||
/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]).
|
||||
pub(crate) fn note_warnings(list: &mut Vec<String>, new: &[String]) {
|
||||
for w in new {
|
||||
if !list.contains(w) && list.len() < MAX_XFA_WARNINGS {
|
||||
list.push(w.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or
|
||||
/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an
|
||||
/// encoding this can't read.
|
||||
pub(crate) fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
|
||||
if bytes.starts_with(b"%FDF") {
|
||||
return None;
|
||||
}
|
||||
let declared = |text: &str| -> Option<String> {
|
||||
let head = text.get(..text.len().min(256))?;
|
||||
let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("<?xml")?;
|
||||
let decl = decl.split_once("?>")?.0;
|
||||
let enc = decl.split_once("encoding=")?.1;
|
||||
let quote = enc.chars().next()?;
|
||||
Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase())
|
||||
};
|
||||
if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) {
|
||||
match declared(&text).as_deref() {
|
||||
None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)),
|
||||
Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => {
|
||||
// Each byte is the character it names (the 0x80–0x9F block of Windows-1252
|
||||
// differs, and is rare in form data).
|
||||
return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect()));
|
||||
}
|
||||
Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))),
|
||||
}
|
||||
}
|
||||
// Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused.
|
||||
let latin1: String = bytes.iter().map(|b| char::from(*b)).collect();
|
||||
match declared(&latin1).as_deref() {
|
||||
Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)),
|
||||
_ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
|
||||
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
|
||||
/// data (data PdfCraft could not read into them, now lost).
|
||||
pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
let fields = pdfcraft_forms::fields(doc);
|
||||
let data = xfa_field_data(doc);
|
||||
let differ = pdfcraft_xfa::read_values(doc, &data)
|
||||
.into_iter()
|
||||
.filter(|(name, value)| {
|
||||
let Some(f) = fields.iter().find(|f| f.name == *name) else { return false };
|
||||
match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
|
||||
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
|
||||
pdfcraft_xfa::FieldData::None => false,
|
||||
}
|
||||
})
|
||||
.count();
|
||||
let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
cx.xfa_removed.extend(r.notes());
|
||||
if differ > 0 {
|
||||
cx.xfa_removed.push(format!(
|
||||
"{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the document's XFA state in step with its file after an edit: a script may have laid
|
||||
/// the form out again (rows added, subforms shown), an edit may have taken the XFA out
|
||||
/// (flattened, or made static), or an undo put it back.
|
||||
pub(crate) fn sync_xfa_state(doc: &mut Document) {
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return };
|
||||
match pdfcraft_xfa::existing_layout(cos) {
|
||||
Some(report) => {
|
||||
if doc.xfa.is_none() {
|
||||
// Back after an undo: the XFA's own warnings apply again, the strip's don't.
|
||||
doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX));
|
||||
if doc.xfa_template.is_none() && !doc.xfa_scripts_off {
|
||||
doc.xfa_template = xfa::template(cos);
|
||||
}
|
||||
}
|
||||
doc.xfa = Some(report);
|
||||
}
|
||||
None => {
|
||||
doc.xfa = None;
|
||||
doc.xfa_template = None;
|
||||
// Only what the strip said stays (the XFA's own warnings no longer apply).
|
||||
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the warnings of a strip start, so they can be told from the XFA's own.
|
||||
pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: ";
|
||||
|
||||
/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document),
|
||||
/// rather than XFDF, Acrobat's `<fields>` XML or a table? By its root element.
|
||||
pub(crate) fn is_xfa_data_file(text: &str) -> bool {
|
||||
let text = text.get(..text.len().min(65_536)).unwrap_or(text);
|
||||
let mut rest = text.trim_start_matches('\u{feff}').trim_start();
|
||||
// Past the XML declaration, comments and processing instructions.
|
||||
let mut guard = 0;
|
||||
while guard < 100 {
|
||||
guard += 1;
|
||||
if let Some(r) = rest.strip_prefix("<?") {
|
||||
rest = r.split_once("?>").map_or("", |(_, r)| r).trim_start();
|
||||
} else if let Some(r) = rest.strip_prefix("<!--") {
|
||||
rest = r.split_once("-->").map_or("", |(_, r)| r).trim_start();
|
||||
} else if let Some(r) = rest.strip_prefix("<!") {
|
||||
rest = r.split_once('>').map_or("", |(_, r)| r).trim_start();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(tag) = rest.strip_prefix('<') else { return false };
|
||||
let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("");
|
||||
let local = name.rsplit(':').next().unwrap_or(name);
|
||||
!local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body")
|
||||
}
|
||||
|
||||
/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows.
|
||||
pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
match pdfcraft_xfa::image::kind(bytes)? {
|
||||
pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"),
|
||||
pdfcraft_xfa::image::Kind::Png => Some("image/png"),
|
||||
pdfcraft_xfa::image::Kind::Gif => Some("image/gif"),
|
||||
pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"),
|
||||
pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"),
|
||||
}
|
||||
}
|
||||
|
||||
/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is
|
||||
/// laid out again from the data (then, when `calculate`, its calculations run), and the
|
||||
/// fields take the data's values.
|
||||
pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> {
|
||||
if let Some(tpl) = cx.xfa.clone() {
|
||||
let mut run =
|
||||
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
|
||||
let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run))
|
||||
.unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})")));
|
||||
cx.xfa_datasets = run.datasets;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
if let Err(e) = r {
|
||||
cx.xfa_out.errors.push(e);
|
||||
}
|
||||
}
|
||||
let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?;
|
||||
if !taken.is_empty() {
|
||||
cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len()));
|
||||
}
|
||||
// Not laid out again (a static form, or scripts off): fields the data no longer holds are
|
||||
// emptied, so the data replaced is the data shown.
|
||||
if cx.xfa.is_none() && calculate {
|
||||
let data = xfa_field_data(doc);
|
||||
let present: std::collections::HashSet<String> = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect();
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if present.contains(&f.name) || f.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
let empty = match f.kind {
|
||||
K::Text => FieldValue::Text(String::new()),
|
||||
K::CheckBox => FieldValue::Check(false),
|
||||
K::Radio => FieldValue::Radio(None),
|
||||
K::Combo | K::List => FieldValue::Choice(Vec::new()),
|
||||
K::PushButton | K::Signature => continue,
|
||||
};
|
||||
if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) {
|
||||
cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns
|
||||
/// the names of the fields that changed.
|
||||
pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
let fields = pdfcraft_forms::fields(doc);
|
||||
// The first field of each name, as a lookup by name would find it.
|
||||
let mut by_name = std::collections::HashMap::new();
|
||||
for f in &fields {
|
||||
by_name.entry(f.name.as_str()).or_insert(f);
|
||||
}
|
||||
let mut updates = Vec::new();
|
||||
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
|
||||
let Some(f) = by_name.get(name.as_str()) else { continue };
|
||||
let new = match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => {
|
||||
// Saved values, one per line for a multi-select list; shown text stands for
|
||||
// its saved value (an older viewer may have written it).
|
||||
let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone());
|
||||
let picked: Vec<String> = if f.has(pdfcraft_forms::flags::MULTI_SELECT) {
|
||||
t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect()
|
||||
} else {
|
||||
vec![saved(t.trim())]
|
||||
};
|
||||
(f.value != picked).then_some(FieldValue::Choice(picked))
|
||||
}
|
||||
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
|
||||
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
|
||||
pdfcraft_xfa::FieldData::None => None,
|
||||
};
|
||||
if let Some(v) = new {
|
||||
updates.push((name, v));
|
||||
}
|
||||
}
|
||||
// All at once: one by one, each value would read the whole form again, which took minutes
|
||||
// on a form with thousands of fields.
|
||||
let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) };
|
||||
Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect())
|
||||
}
|
||||
@@ -11,6 +11,12 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs).
|
||||
craft-menu = { workspace = true }
|
||||
craft-keymap = { workspace = true }
|
||||
# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window
|
||||
# close also quits through `terminate:` (the Touch Bar crash, #1136).
|
||||
craft-quit = { workspace = true, features = ["egui"] }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
@@ -55,9 +61,6 @@ pollster = "1.0"
|
||||
fontconfig-parser = "0.5.8"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op.
|
||||
# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only.
|
||||
craft-quit = { workspace = true, features = ["egui"] }
|
||||
# Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding.
|
||||
# The Objective-C wrappers and block support are already in the tree through arboard and rfd.
|
||||
objc2 = "0.6"
|
||||
|
||||
@@ -311,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Both theme entry points use the same choices and command path.
|
||||
fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
for (preference, command, label) in [
|
||||
(ThemePreference::System, "view.theme.system", "Use system setting"),
|
||||
(ThemePreference::Light, "view.theme.light", "Light gray"),
|
||||
(ThemePreference::Dark, "view.theme.dark", "Dark gray"),
|
||||
] {
|
||||
if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() {
|
||||
app.execute(command);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Appearance settings…")).clicked() {
|
||||
app.dialog = Some(Dialog::Preferences);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-click menu on the title bar's theme button.
|
||||
fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if ui.button(tl!("Appearance settings…")).clicked() {
|
||||
@@ -497,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
main_menu(app, ui);
|
||||
ui.add_space(6.0);
|
||||
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
|
||||
ui.add_space(10.0);
|
||||
// The menus, unless the native macOS menu bar has them.
|
||||
if app.native_menu.is_none() {
|
||||
app.menu_bar_ui(ui);
|
||||
ui.add_space(6.0);
|
||||
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
// Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
|
||||
// in use. Read mode (the panes hidden) stays a View command and a default workspace.
|
||||
for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
|
||||
@@ -540,103 +524,6 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep menus usable when their contents are taller than the window.
|
||||
pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
|
||||
egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
|
||||
}
|
||||
|
||||
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
scroll_menu(ui, |ui| {
|
||||
ui.set_min_width(230.0);
|
||||
ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
|
||||
ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
|
||||
ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
|
||||
ui.menu_button(tl!("View"), |ui| {
|
||||
scroll_menu(ui, |ui| {
|
||||
if let Some(i) = app.active {
|
||||
let v = &mut app.views[i];
|
||||
let ctx = ui.ctx().clone();
|
||||
let key = |s| crate::commands::shortcut_label(&ctx, s);
|
||||
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
|
||||
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
|
||||
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
|
||||
v.set_zoom(1.0);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
|
||||
v.fit = Fit::Page;
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
|
||||
v.fit = Fit::Width;
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
|
||||
v.fit = Fit::Height;
|
||||
v.goto = Some((v.current, 0.0));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
|
||||
ui.close();
|
||||
app.execute("view.fit_visible");
|
||||
return;
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
|
||||
v.zoom_step(true);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
|
||||
v.zoom_step(false);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
|
||||
v.rotate_view(true);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
|
||||
v.rotate_view(false);
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
|
||||
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
|
||||
v.view_history(false);
|
||||
}
|
||||
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
|
||||
v.view_history(true);
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(id) = page_display_menu(ui, &app.views[i]) {
|
||||
app.execute(id);
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
crate::commands::registry_menu(app, ui, "View");
|
||||
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
|
||||
ui.menu_button(tl!("Side panels"), |ui| {
|
||||
scroll_menu(ui, |ui| {
|
||||
for (p, label) in [
|
||||
(RightPanel::Comments, tl!("Comments")),
|
||||
(RightPanel::Bookmarks, tl!("Bookmarks")),
|
||||
(RightPanel::Pages, tl!("Pages")),
|
||||
(RightPanel::Fields, tl!("Fields")),
|
||||
(RightPanel::Layers, tl!("Layers")),
|
||||
(RightPanel::Attachments, tl!("Attachments")),
|
||||
(RightPanel::Signatures, tl!("Signatures")),
|
||||
(RightPanel::Accessibility, tl!("Accessibility Checker")),
|
||||
(RightPanel::Tags, tl!("Tags")),
|
||||
(RightPanel::Search, tl!("Search")),
|
||||
(RightPanel::Compare, tl!("Compare")),
|
||||
] {
|
||||
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
|
||||
app.choose_right_panel(Some(p));
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
});
|
||||
ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the
|
||||
/// active view.
|
||||
pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
@@ -822,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String {
|
||||
if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") }
|
||||
}
|
||||
|
||||
/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page.
|
||||
/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display).
|
||||
/// Returns the command a click asks for, for the caller to run.
|
||||
fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut};
|
||||
use crate::{
|
||||
Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget,
|
||||
theme::{ThemeKind, ThemePreference},
|
||||
widgets,
|
||||
};
|
||||
|
||||
/// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and
|
||||
@@ -724,11 +723,6 @@ impl PdfCraftApp {
|
||||
// Buttons also own keyboard focus for Tab/Enter activation, but they must not
|
||||
// suppress document shortcuts. Text editors retain their own undo/redo handling.
|
||||
let typing = ctx.text_edit_focused();
|
||||
// A form field's editor is open on the page: its text isn't in the document until
|
||||
// committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that,
|
||||
// while the field has yet to see the Escape and discard its draft.)
|
||||
let active = self.active_ids();
|
||||
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
|
||||
let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect();
|
||||
specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0)));
|
||||
for spec in specs {
|
||||
@@ -752,23 +746,29 @@ impl PdfCraftApp {
|
||||
m |= Modifiers::CTRL;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) {
|
||||
if form_typing {
|
||||
// A form field has the keyboard: let it take this frame's typing (and
|
||||
// Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for
|
||||
// this document only.
|
||||
self.deferred_commands.push((spec.id, active.map(|(_, id)| id)));
|
||||
ctx.request_repaint();
|
||||
} else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
|
||||
// macOS ⌘W closes the last window when no document tabs remain (#869).
|
||||
// Route through the normal viewport-close path (and its quit guard),
|
||||
// not the disabled document-only `file.close` command.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else {
|
||||
self.execute(spec.id);
|
||||
}
|
||||
self.dispatch_command(ctx, spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a registry command from its shortcut or a menu, which share this path.
|
||||
pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) {
|
||||
let active = self.active_ids();
|
||||
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
|
||||
if form_typing {
|
||||
// A form field has the keyboard: let it take this frame's typing (and Escape) first,
|
||||
// so ⌘S saves what's on screen (#166). Runs next frame, for this document only.
|
||||
self.deferred_commands.push((id, active.map(|(_, id)| id)));
|
||||
ctx.request_repaint();
|
||||
} else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
|
||||
// macOS ⌘W closes the last window when no document tabs remain (#869). Route through
|
||||
// the normal viewport-close path (and its quit guard), not the disabled document-only
|
||||
// `file.close` command.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else {
|
||||
self.execute(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
|
||||
@@ -789,57 +789,6 @@ fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usiz
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
|
||||
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
|
||||
for spec in commands::menu(menu) {
|
||||
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
|
||||
let label = crate::i18n::menu_label(spec.id, &label);
|
||||
// Open Recent is a submenu of the live recent list, not one action: disabled while the
|
||||
// list is empty, otherwise each entry opens its file (or focuses the tab showing it), and
|
||||
// Clear Recent Files at the foot empties the list (#430).
|
||||
if spec.id == "file.open_recent" {
|
||||
if app.recent.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new(label));
|
||||
continue;
|
||||
}
|
||||
let mut open: Option<String> = None;
|
||||
let mut clear = false;
|
||||
ui.menu_button(label, |ui| {
|
||||
crate::chrome::scroll_menu(ui, |ui| {
|
||||
for r in &app.recent {
|
||||
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
|
||||
open = Some(r.path.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Clear Recent Files")).clicked() {
|
||||
clear = true;
|
||||
ui.close();
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(p) = open {
|
||||
app.open_recent(&p);
|
||||
ui.close();
|
||||
}
|
||||
if clear {
|
||||
app.execute("file.clear_recent");
|
||||
ui.close();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default();
|
||||
let enabled = app.command_enabled(spec);
|
||||
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut));
|
||||
if resp.clicked() {
|
||||
app.execute(spec.id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let _ = widgets::menu_item;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -65,6 +65,7 @@ mod js_ui;
|
||||
mod macos_association;
|
||||
mod marks_ui;
|
||||
mod measure_ui;
|
||||
mod menus;
|
||||
mod ocr_ui;
|
||||
mod optimize_ui;
|
||||
mod search_ui;
|
||||
@@ -939,6 +940,10 @@ pub struct PdfCraftApp {
|
||||
/// Shortcuts pressed while a text field had the keyboard, run on the next frame (see
|
||||
/// `registry_shortcuts`).
|
||||
deferred_commands: Vec<(&'static str, Option<DocId>)>,
|
||||
/// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise.
|
||||
pub(crate) native_menu: Option<craft_menu::NativeMenu>,
|
||||
/// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`.
|
||||
pub(crate) menu_input: Vec<egui::Event>,
|
||||
/// The dialog seen at the last check, and a counter bumped whenever it changes (see
|
||||
/// [`Self::dialog_epoch`]).
|
||||
dialog_seen: Option<Dialog>,
|
||||
@@ -1277,6 +1282,8 @@ impl PdfCraftApp {
|
||||
last_autosave: None,
|
||||
allow_quit: false,
|
||||
deferred_commands: Vec::new(),
|
||||
native_menu: None,
|
||||
menu_input: Vec::new(),
|
||||
dialog_seen: None,
|
||||
dialog_epoch: 0,
|
||||
ctx: None,
|
||||
@@ -3368,7 +3375,12 @@ impl PdfCraftApp {
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.registry_shortcuts(ctx);
|
||||
// The in-window menu bar holds the keys while one of its menus is open or it has the
|
||||
// keyboard (Alt, F10); a command shortcut leaves it and runs.
|
||||
craft_menu::bar::leave_for_shortcut(ctx);
|
||||
if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) {
|
||||
self.registry_shortcuts(ctx);
|
||||
}
|
||||
// (Esc closes a Combine file shown large first.)
|
||||
if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
self.set_full_screen(ctx, false);
|
||||
@@ -3627,6 +3639,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
// Before egui reads this frame's wheel input.
|
||||
self.scroll_speed.apply(ctx, raw);
|
||||
// The native menu's key equivalents.
|
||||
if let Some(native) = self.native_menu.as_mut() {
|
||||
native.raw_input(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3798,6 +3814,12 @@ impl eframe::App for PdfCraftApp {
|
||||
self.poll_print();
|
||||
// Shortcuts deferred last frame: the text field has taken that frame's typing since.
|
||||
let deferred = std::mem::take(&mut self.deferred_commands);
|
||||
self.native_menu_clicks(ctx);
|
||||
// The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys.
|
||||
if !self.menu_input.is_empty() {
|
||||
let events = std::mem::take(&mut self.menu_input);
|
||||
ctx.input_mut(|i| i.events.extend(events));
|
||||
}
|
||||
self.shortcuts(ctx);
|
||||
// Scrolling and group-drag previews are transient: never resume after changing tabs,
|
||||
// opening a modal/palette, or returning to a window that lost focus.
|
||||
@@ -3828,6 +3850,7 @@ impl eframe::App for PdfCraftApp {
|
||||
self.process_picked();
|
||||
// Last, after everything that can open or answer the "save changes?" question.
|
||||
self.attend_save_prompt(ctx);
|
||||
self.sync_native_menu(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
//! The menus: one definition ([`PdfCraftApp::menu_bar`]) for the native macOS menu bar and the
|
||||
//! in-window bar on Windows, Linux and the web, laid out for each platform by craft-menu.
|
||||
//!
|
||||
//! Items run registry commands (`pdfcraft_engine::commands`) by id, with the registry's label and
|
||||
//! shortcut, so a menu click and its shortcut run the same command. A few items have menu-only
|
||||
//! ids: the zoom keys the document view handles itself, the side panel choices, the clipboard
|
||||
//! keys, the recent files and the open documents. [`PdfCraftApp::run_menu_command`] runs every
|
||||
//! one of them, once per click, whichever bar it came from.
|
||||
|
||||
use craft_keymap::{Key, Mods};
|
||||
use craft_menu::{
|
||||
Builtin, Chord, CommandRef, Document, Gate, Item, ItemRole, ItemState, Labeled, LayoutOptions, Menu, MenuBar, MenuRole, Node, Target,
|
||||
};
|
||||
use pdfcraft_engine::catalog::{Availability, TOOL_GROUPS};
|
||||
use pdfcraft_engine::commands::{self, Shortcut};
|
||||
|
||||
use crate::canvas::{Fit, PageLayout};
|
||||
use crate::theme::ThemePreference;
|
||||
use crate::{PdfCraftApp, RightPanel};
|
||||
|
||||
/// The app's name in the menus the layouts write (About, Hide, Quit).
|
||||
pub(crate) const APP_NAME: &str = "PdfCraft";
|
||||
|
||||
/// The top bar's width after the menus: the mode tabs, the search box and the buttons.
|
||||
const ROW_AFTER_MENUS: f32 = 720.0;
|
||||
/// The narrowest the menu bar gets: one "Menu" title.
|
||||
const MIN_MENUS_WIDTH: f32 = 80.0;
|
||||
|
||||
/// Menu-only commands. The registry's commands keep their own ids.
|
||||
pub(crate) const CUT: &str = "edit.cut";
|
||||
pub(crate) const PASTE: &str = "edit.paste";
|
||||
pub(crate) const DELETE: &str = "edit.delete";
|
||||
pub(crate) const SELECT_ALL: &str = "edit.select_all";
|
||||
pub(crate) const NO_RECENT: &str = "file.no_recent";
|
||||
/// An open document in the Window menu, `{"index": n}` in the tab strip.
|
||||
pub(crate) const DOCUMENT: &str = "window.document";
|
||||
/// A side panel, `{"panel": name}` ([`PANELS`]).
|
||||
pub(crate) const PANEL: &str = "view.panel";
|
||||
const ACTUAL_SIZE: &str = "view.zoom.actual_size";
|
||||
const PAGE_LEVEL: &str = "view.zoom.page_level";
|
||||
const FIT_WIDTH: &str = "view.zoom.fit_width";
|
||||
const FIT_HEIGHT: &str = "view.zoom.fit_height";
|
||||
const ZOOM_IN: &str = "view.zoom.in";
|
||||
const ZOOM_OUT: &str = "view.zoom.out";
|
||||
const ROTATE_CW: &str = "view.rotate.cw";
|
||||
const ROTATE_CCW: &str = "view.rotate.ccw";
|
||||
|
||||
/// The menu-only items: id, English label and the shortcut shown (the keys stay with the view).
|
||||
const EXTRA: &[(&str, &str, Option<Shortcut>)] = &[
|
||||
(CUT, "Cut", Some(Shortcut::cmd("X"))),
|
||||
(PASTE, "Paste", Some(Shortcut::cmd("V"))),
|
||||
(DELETE, "Delete", Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "Delete" })),
|
||||
(SELECT_ALL, "Select all", Some(crate::commands::SELECT_ALL)),
|
||||
(NO_RECENT, "No recent files", None),
|
||||
(ACTUAL_SIZE, "Actual size", Some(crate::commands::ACTUAL_SIZE)),
|
||||
(PAGE_LEVEL, "Zoom to page level", Some(crate::commands::PAGE_LEVEL)),
|
||||
(FIT_WIDTH, "Fit to width", Some(crate::commands::FIT_WIDTH)),
|
||||
(FIT_HEIGHT, "Fit to height", None),
|
||||
(ZOOM_IN, "Zoom in", Some(crate::commands::ZOOM_IN)),
|
||||
(ZOOM_OUT, "Zoom out", Some(crate::commands::ZOOM_OUT)),
|
||||
(ROTATE_CW, "Rotate view clockwise", Some(crate::commands::ROTATE_CW)),
|
||||
(ROTATE_CCW, "Rotate view counterclockwise", Some(crate::commands::ROTATE_CCW)),
|
||||
];
|
||||
|
||||
/// View ▸ Side panels: the panel, its id in [`PANEL`] commands and its English label.
|
||||
const PANELS: &[(RightPanel, &str, &str)] = &[
|
||||
(RightPanel::Comments, "comments", "Comments"),
|
||||
(RightPanel::Bookmarks, "bookmarks", "Bookmarks"),
|
||||
(RightPanel::Pages, "pages", "Pages"),
|
||||
(RightPanel::Fields, "fields", "Fields"),
|
||||
(RightPanel::Layers, "layers", "Layers"),
|
||||
(RightPanel::Attachments, "attachments", "Attachments"),
|
||||
(RightPanel::Signatures, "signatures", "Signatures"),
|
||||
(RightPanel::Accessibility, "accessibility", "Accessibility Checker"),
|
||||
(RightPanel::Tags, "tags", "Tags"),
|
||||
(RightPanel::Search, "search", "Search"),
|
||||
(RightPanel::Compare, "compare", "Compare"),
|
||||
];
|
||||
|
||||
/// The English titles of the menus and submenus, for the bar's mnemonics in languages whose
|
||||
/// labels have no ASCII letter.
|
||||
const TITLES: &[&str] = &[
|
||||
"File",
|
||||
"Edit",
|
||||
"View",
|
||||
"Pages",
|
||||
"Sign",
|
||||
"Window",
|
||||
"Help",
|
||||
"Open Recent",
|
||||
"Create",
|
||||
"Export",
|
||||
"Protect",
|
||||
"Zoom",
|
||||
"Rotate view",
|
||||
"Page navigation",
|
||||
"Page display",
|
||||
"Side panels",
|
||||
"Display theme",
|
||||
"Tools",
|
||||
"Fill & Sign",
|
||||
];
|
||||
|
||||
/// Separator.
|
||||
const SEP: &str = "---";
|
||||
|
||||
const FILE: &[&str] = &[
|
||||
"create.blank",
|
||||
"file.open",
|
||||
"file.open_recent",
|
||||
"file.pin_folder",
|
||||
SEP,
|
||||
"page.combine",
|
||||
SEP,
|
||||
"file.save",
|
||||
"file.save_as",
|
||||
"file.revert",
|
||||
SEP,
|
||||
"file.close",
|
||||
"file.close_all",
|
||||
SEP,
|
||||
"optimize.reduce",
|
||||
"optimize.advanced",
|
||||
SEP,
|
||||
"file.properties",
|
||||
"file.word_count",
|
||||
SEP,
|
||||
"print.dialog",
|
||||
];
|
||||
const CREATE: &[&str] = &["create.file", "create.multiple", "create.multiple_separate", "create.images", "create.clipboard"];
|
||||
const EXPORT: &[&str] = &["export.docx", "export.pptx", "export.rtf", "export.html", "export.text", SEP, "export.image", "export.all_images"];
|
||||
const PROTECT: &[&str] = &["protect.password", "protect.remove", "protect.unlock", SEP, "protect.properties"];
|
||||
const EDIT: &[&str] = &[
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
SEP,
|
||||
CUT,
|
||||
"edit.copy",
|
||||
PASTE,
|
||||
DELETE,
|
||||
SEP,
|
||||
SELECT_ALL,
|
||||
SEP,
|
||||
"edit.find",
|
||||
"edit.advanced_search",
|
||||
SEP,
|
||||
"edit.column_select",
|
||||
"edit.snapshot",
|
||||
SEP,
|
||||
"edit.attach",
|
||||
"form.clear",
|
||||
SEP,
|
||||
"app.preferences",
|
||||
];
|
||||
const ZOOM: &[&str] = &[ACTUAL_SIZE, PAGE_LEVEL, FIT_WIDTH, FIT_HEIGHT, "view.fit_visible", SEP, ZOOM_IN, ZOOM_OUT, SEP, "view.marquee_zoom"];
|
||||
const NAVIGATION: &[&str] = &["view.focus_page_input", SEP, "view.previous_view", "view.next_view"];
|
||||
const DISPLAY: &[&str] = &[
|
||||
"view.layout.single",
|
||||
"view.layout.two_up",
|
||||
SEP,
|
||||
"view.continuous",
|
||||
"view.page_gaps",
|
||||
"view.layout.cover",
|
||||
SEP,
|
||||
"view.fit_width_scrolling",
|
||||
"view.fit_one_page",
|
||||
];
|
||||
const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"];
|
||||
const PANES: &[&str] = &["view.side_panel", "view.tools_pane"];
|
||||
const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"];
|
||||
const MODES: &[&str] = &["view.full_screen", "view.read_mode"];
|
||||
const PAGES: &[&str] = &[
|
||||
"page.organize",
|
||||
SEP,
|
||||
"page.insert_blank",
|
||||
"page.insert",
|
||||
"page.replace",
|
||||
"page.extract",
|
||||
"page.split",
|
||||
"page.duplicate",
|
||||
"page.delete",
|
||||
SEP,
|
||||
"page.rotate",
|
||||
"page.rotate_ccw",
|
||||
"page.rotate_dialog",
|
||||
"page.reverse",
|
||||
SEP,
|
||||
"page.crop",
|
||||
"page.boxes",
|
||||
"page.number",
|
||||
SEP,
|
||||
"bookmark.add",
|
||||
"bookmark.from_structure",
|
||||
];
|
||||
/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar.
|
||||
const FILL: &[(&str, &str)] = &[
|
||||
("sign.fill.text", "Add text"),
|
||||
("sign.fill.check", "Checkmark"),
|
||||
("sign.fill.cross", "Cross"),
|
||||
("sign.fill.dot", "Dot"),
|
||||
("sign.fill.line", "Line"),
|
||||
("sign.fill.date", "Date"),
|
||||
("sign.fill.signature", "Signature"),
|
||||
("sign.fill.initials", "Initials"),
|
||||
];
|
||||
const SIGN: &[&str] = &[
|
||||
SEP,
|
||||
"sign.fill.signature.change",
|
||||
"sign.fill.initials.change",
|
||||
"sign.fill.signature.remove",
|
||||
"sign.fill.initials.remove",
|
||||
SEP,
|
||||
"sign.digital",
|
||||
"sign.certify",
|
||||
"sign.certify_invisible",
|
||||
"form.add.signature",
|
||||
SEP,
|
||||
"sign.validate",
|
||||
"sign.panel",
|
||||
];
|
||||
const WINDOW: &[&str] = &["view.next_tab", "view.previous_tab"];
|
||||
const HELP: &[&str] =
|
||||
&["help.shortcuts", SEP, "help.app_page", "help.github", "help.website", "help.discord", SEP, "help.check_updates", SEP, "help.about"];
|
||||
|
||||
/// A registry shortcut as a craft-keymap chord: `command` is ⌘ (Ctrl elsewhere) and `mac_ctrl`
|
||||
/// the Control key on macOS. `None` for a key craft-keymap doesn't name.
|
||||
pub(crate) fn chord(s: Shortcut) -> Option<Chord> {
|
||||
let key = Key::parse(s.key)?;
|
||||
Some(Chord::new(Mods { cmd: s.command, ctrl: s.mac_ctrl, alt: false, shift: s.shift }, key))
|
||||
}
|
||||
|
||||
fn extra(id: &str) -> Option<&'static (&'static str, &'static str, Option<Shortcut>)> {
|
||||
EXTRA.iter().find(|(e, ..)| *e == id)
|
||||
}
|
||||
|
||||
/// The English label of an item's command, for the bar's mnemonics.
|
||||
fn english_label(command: &CommandRef) -> Option<String> {
|
||||
if let Some(spec) = commands::command(&command.id) {
|
||||
return Some(spec.label.to_owned());
|
||||
}
|
||||
if let Some((_, label, _)) = extra(&command.id) {
|
||||
return Some((*label).to_owned());
|
||||
}
|
||||
FILL.iter().find(|(id, _)| *id == command.id).map(|(_, label)| (*label).to_owned())
|
||||
}
|
||||
|
||||
fn english(labeled: Labeled<'_>) -> Option<String> {
|
||||
match labeled {
|
||||
Labeled::Command(command) => english_label(command),
|
||||
Labeled::Title(shown) => TITLES.iter().find(|t| tl!(t) == shown).map(|t| (*t).to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn translate(english: &'static str) -> String {
|
||||
tl!(english).to_owned()
|
||||
}
|
||||
|
||||
/// The layouts' own labels (About, Quit, Window…) through the app's translations.
|
||||
pub(crate) fn layout_options() -> LayoutOptions<'static> {
|
||||
LayoutOptions { label: &translate, ..LayoutOptions::new(APP_NAME) }
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// The app's menus for this frame, before the platform layout: every item's label, shortcut,
|
||||
/// enabled and checked state as the app is now. A modal dialog doesn't grey anything here;
|
||||
/// that is the [`Gate`]'s job ([`PdfCraftApp::menu_gate`]).
|
||||
pub fn menu_bar(&self, ctx: &egui::Context) -> MenuBar {
|
||||
let mut used: Vec<&str> = Vec::new();
|
||||
let list = |ids: &[&'static str], used: &mut Vec<&'static str>| -> Vec<Node> {
|
||||
ids.iter()
|
||||
.map(|&id| {
|
||||
if id == SEP {
|
||||
return Node::Separator;
|
||||
}
|
||||
used.push(id);
|
||||
self.menu_item(id, None)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let file = {
|
||||
let mut nodes = Vec::new();
|
||||
for node in list(FILE, &mut used) {
|
||||
// Open Recent is the live recent list; Create, Export and Protect follow Combine,
|
||||
// Optimize and Close.
|
||||
let recent = matches!(&node, Node::Item(it) if it.command.id == "file.open_recent");
|
||||
if recent {
|
||||
nodes.push(self.recent_menu());
|
||||
continue;
|
||||
}
|
||||
let after = match &node {
|
||||
Node::Item(it) => it.command.id.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
nodes.push(node);
|
||||
match after.as_str() {
|
||||
"page.combine" => nodes.push(Node::submenu(tl!("Create"), list(CREATE, &mut used))),
|
||||
"optimize.advanced" => {
|
||||
nodes.push(Node::submenu(tl!("Export"), list(EXPORT, &mut used)));
|
||||
nodes.push(Node::Separator);
|
||||
nodes.push(Node::submenu(tl!("Protect"), list(PROTECT, &mut used)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
nodes
|
||||
};
|
||||
// Edit ▸ Pages follows Select All.
|
||||
let mut edit = Vec::new();
|
||||
for node in list(EDIT, &mut used) {
|
||||
let select_all = matches!(&node, Node::Item(it) if it.command.id == SELECT_ALL);
|
||||
edit.push(node);
|
||||
if select_all {
|
||||
edit.push(Node::submenu(tl!("Pages"), list(PAGES, &mut used)));
|
||||
}
|
||||
}
|
||||
let mut sign = vec![Node::submenu(
|
||||
tl!("Fill & Sign"),
|
||||
FILL.iter()
|
||||
.map(|&(id, label)| {
|
||||
used.push(id);
|
||||
self.menu_item(id, Some(tl!(label)))
|
||||
})
|
||||
.collect(),
|
||||
)];
|
||||
sign.extend(list(SIGN, &mut used));
|
||||
let window = list(WINDOW, &mut used);
|
||||
let help = list(HELP, &mut used);
|
||||
let mut view = vec![
|
||||
Node::submenu(tl!("Zoom"), list(ZOOM, &mut used)),
|
||||
Node::submenu(tl!("Rotate view"), list(&[ROTATE_CW, ROTATE_CCW], &mut used)),
|
||||
Node::submenu(tl!("Page navigation"), list(NAVIGATION, &mut used)),
|
||||
Node::submenu(tl!("Page display"), list(DISPLAY, &mut used)),
|
||||
Node::Separator,
|
||||
];
|
||||
view.extend(list(PANES, &mut used));
|
||||
view.push(Node::submenu(
|
||||
tl!("Side panels"),
|
||||
PANELS
|
||||
.iter()
|
||||
.map(|(_, name, label)| {
|
||||
Item::new(CommandRef::with_params(PANEL, serde_json::json!({ "panel": name })), tl!(label)).radio(false).into()
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
view.extend(list(PANE_COMMANDS, &mut used));
|
||||
view.push(Node::Separator);
|
||||
view.extend(list(MODES, &mut used));
|
||||
view.push(Node::submenu(tl!("Display theme"), list(THEMES, &mut used)));
|
||||
view.push(Node::Separator);
|
||||
view.push(Node::submenu(tl!("Tools"), self.tools_menu(&used)));
|
||||
view.extend(list(&["view.palette"], &mut used));
|
||||
let mut bar = MenuBar {
|
||||
menus: vec![
|
||||
Menu::new(tl!("File"), craft_menu::tidy(file)).role(MenuRole::File),
|
||||
Menu::new(tl!("Edit"), craft_menu::tidy(edit)).role(MenuRole::Edit),
|
||||
Menu::new(tl!("View"), craft_menu::tidy(view)).role(MenuRole::View),
|
||||
Menu::new(tl!("Sign"), craft_menu::tidy(sign)),
|
||||
Menu::new(tl!("Window"), window).role(MenuRole::Window),
|
||||
Menu::new(tl!("Help"), craft_menu::tidy(help)).role(MenuRole::Help),
|
||||
],
|
||||
documents: self.menu_documents(),
|
||||
};
|
||||
bar.tag_item(&CommandRef::new("help.about"), ItemRole::About);
|
||||
bar.tag_item(&CommandRef::new("app.preferences"), ItemRole::Settings);
|
||||
craft_menu::apply_state(&mut bar, |command| self.menu_state(ctx, command));
|
||||
bar
|
||||
}
|
||||
|
||||
/// One item: a registry command with its live label (Undo names the edit) and shortcut, or a
|
||||
/// menu-only one. Radio and check items get their kind here, their state from
|
||||
/// [`PdfCraftApp::menu_state`].
|
||||
fn menu_item(&self, id: &'static str, label: Option<&str>) -> Node {
|
||||
let (english, shortcut) = match commands::command(id) {
|
||||
Some(spec) => {
|
||||
let current = commands::current_label(spec, &self.session, self.active_ids().map(|(_, doc)| doc));
|
||||
(crate::i18n::menu_label(id, ¤t), spec.shortcut.or((id == "edit.copy").then_some(crate::commands::COPY)))
|
||||
}
|
||||
None => match extra(id) {
|
||||
Some((_, label, shortcut)) => (tl!(label).to_owned(), *shortcut),
|
||||
None => (id.to_owned(), None),
|
||||
},
|
||||
};
|
||||
let mut item = Item::new(id, label.map_or(english, str::to_owned));
|
||||
if let Some(c) = shortcut.and_then(chord) {
|
||||
item = item.shortcut(c);
|
||||
}
|
||||
if matches!(id, "view.layout.single" | "view.layout.two_up") || THEMES.contains(&id) {
|
||||
item = item.radio(false);
|
||||
}
|
||||
item.into()
|
||||
}
|
||||
|
||||
/// File ▸ Open Recent: the recent files, newest first, then Clear Recent Files.
|
||||
fn recent_menu(&self) -> Node {
|
||||
let mut items: Vec<Node> = self
|
||||
.recent
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, r)| Item::new(CommandRef::with_params("file.open_recent", serde_json::json!({ "index": index })), r.name.clone()).into())
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
items.push(self.menu_item(NO_RECENT, None));
|
||||
} else {
|
||||
items.push(Node::Separator);
|
||||
items.push(self.menu_item("file.clear_recent", Some(tl!("Clear Recent Files"))));
|
||||
}
|
||||
let label = commands::command("file.open_recent").map_or("Open Recent", |c| c.label);
|
||||
Node::submenu(crate::i18n::menu_label("file.open_recent", label), items)
|
||||
}
|
||||
|
||||
/// View ▸ Tools: the All tools groups, each a submenu of its ready tools that no other menu
|
||||
/// lists, sections apart.
|
||||
fn tools_menu(&self, used: &[&str]) -> Vec<Node> {
|
||||
TOOL_GROUPS
|
||||
.iter()
|
||||
.filter(|g| g.availability == Availability::Ready)
|
||||
.map(|group| {
|
||||
let mut nodes = Vec::new();
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for section in group.sections {
|
||||
nodes.push(Node::Separator);
|
||||
for tool in section.items.iter().filter(|t| t.availability == Availability::Ready) {
|
||||
let actions: &[&'static str] = if tool.actions.is_empty() { std::slice::from_ref(&tool.command) } else { tool.actions };
|
||||
for &id in actions {
|
||||
if commands::command(id).is_none() || used.contains(&id) || seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
seen.push(id);
|
||||
let label = if tool.actions.is_empty() { Some(tl!(tool.label)) } else { None };
|
||||
nodes.push(self.menu_item(id, label));
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::submenu(tl!(group.label), nodes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The open documents, in tab order, for the Window menu.
|
||||
fn menu_documents(&self) -> Vec<Document> {
|
||||
(0..self.views.len())
|
||||
.filter_map(|index| {
|
||||
let name = crate::canvas::tab_label(self, index)?;
|
||||
Some(Document::new(CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": index })), name).active(self.active == Some(index)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What may run from the menus now: a dialog, a prompt or the command palette holds the
|
||||
/// window. Edit's text items stay with a focused text field (craft-menu's gate).
|
||||
pub fn menu_gate(&self, ctx: &egui::Context) -> Gate {
|
||||
Gate { modal: self.modal_open() || self.palette_open, recording_shortcut: false, text_editing: ctx.text_edit_focused() }
|
||||
}
|
||||
|
||||
/// Whether a menu item's command can run now, and its checked or selected state.
|
||||
pub fn menu_state(&self, ctx: &egui::Context, command: &CommandRef) -> ItemState {
|
||||
let view = self.active.and_then(|i| self.views.get(i));
|
||||
let organize = view.is_some_and(|v| v.organize);
|
||||
let has_doc = view.is_some();
|
||||
let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s));
|
||||
let index = param_index(command);
|
||||
let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL);
|
||||
let enabled = text
|
||||
|| match command.id.as_str() {
|
||||
NO_RECENT => false,
|
||||
"file.open_recent" => index.is_none_or(|i| i < self.recent.len()),
|
||||
"file.clear_recent" => !self.recent.is_empty(),
|
||||
DOCUMENT => index.is_some_and(|i| i < self.views.len()),
|
||||
// macOS ⌘W closes the window when no document is open (#869).
|
||||
"file.close" if !has_doc && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") => true,
|
||||
"edit.copy" if organize => enabled_id("page.copy"),
|
||||
"edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => {
|
||||
self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying())
|
||||
}
|
||||
CUT => organize && enabled_id("page.cut"),
|
||||
PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"),
|
||||
SELECT_ALL | DELETE => has_doc,
|
||||
ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => has_doc,
|
||||
PANEL => panel(command).is_some(),
|
||||
id => enabled_id(id),
|
||||
};
|
||||
let checked = match command.id.as_str() {
|
||||
"view.layout.single" => Some(view.is_some_and(|v| v.layout == PageLayout::Single)),
|
||||
"view.layout.two_up" => Some(view.is_some_and(|v| v.layout == PageLayout::TwoUp)),
|
||||
"view.continuous" => Some(view.is_some_and(|v| v.continuous())),
|
||||
"view.page_gaps" => Some(view.is_some_and(|v| v.show_page_gaps)),
|
||||
"view.layout.cover" => Some(view.is_some_and(|v| v.cover)),
|
||||
"view.full_screen" => Some(self.full_screen),
|
||||
"view.read_mode" => Some(self.mode == crate::Mode::Read),
|
||||
"view.side_panel" => Some(self.right.is_some()),
|
||||
"view.tools_pane" => Some(self.left_open),
|
||||
"view.theme.system" => Some(self.theme_preference == ThemePreference::System),
|
||||
"view.theme.light" => Some(self.theme_preference == ThemePreference::Light),
|
||||
"view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark),
|
||||
PANEL => Some(panel(command).is_some_and(|p| self.right == Some(p))),
|
||||
_ => None,
|
||||
};
|
||||
ItemState { enabled, checked }
|
||||
}
|
||||
|
||||
/// Run a command chosen in either menu bar. Registry commands go through
|
||||
/// [`PdfCraftApp::dispatch_command`], as their shortcuts do; the clipboard items act as
|
||||
/// their keys would (⌘C is a Copy event, Delete a key press), so every place that handles the
|
||||
/// key handles the click. A command that can't run now (a dialog opened since the menu was
|
||||
/// drawn) does nothing.
|
||||
pub fn run_menu_command(&mut self, ctx: &egui::Context, command: &CommandRef) {
|
||||
// Both menu bars run Quit and Minimize themselves; Hide and Hide Others are AppKit's.
|
||||
if Builtin::of(command).is_some() {
|
||||
return;
|
||||
}
|
||||
if !self.menu_gate(ctx).allows_command(command) || !self.menu_state(ctx, command).enabled {
|
||||
return;
|
||||
}
|
||||
let index = param_index(command);
|
||||
let key = |key: egui::Key, modifiers: egui::Modifiers| {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })
|
||||
};
|
||||
match command.id.as_str() {
|
||||
"file.open_recent" if index.is_some() => {
|
||||
if let Some(path) = index.and_then(|i| self.recent.get(i)).map(|r| r.path.clone()) {
|
||||
self.open_recent(&path);
|
||||
}
|
||||
}
|
||||
DOCUMENT => {
|
||||
if let Some(i) = index.filter(|&i| i < self.views.len()) {
|
||||
self.active = Some(i);
|
||||
}
|
||||
}
|
||||
"edit.copy" => self.menu_input.push(egui::Event::Copy),
|
||||
CUT => self.menu_input.push(egui::Event::Cut),
|
||||
PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||
SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)),
|
||||
DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)),
|
||||
PANEL => self.choose_right_panel(panel(command)),
|
||||
ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => {
|
||||
let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) else { return };
|
||||
match command.id.as_str() {
|
||||
ACTUAL_SIZE => v.set_zoom(1.0),
|
||||
PAGE_LEVEL => v.fit = Fit::Page,
|
||||
FIT_WIDTH => v.fit = Fit::Width,
|
||||
FIT_HEIGHT => {
|
||||
v.fit = Fit::Height;
|
||||
v.goto = Some((v.current, 0.0));
|
||||
}
|
||||
ZOOM_IN => v.zoom_step(true),
|
||||
ZOOM_OUT => v.zoom_step(false),
|
||||
ROTATE_CW => v.rotate_view(true),
|
||||
_ => v.rotate_view(false),
|
||||
}
|
||||
}
|
||||
id => {
|
||||
if let Some(spec) = commands::command(id) {
|
||||
self.dispatch_command(ctx, spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The menu bar drawn in the window (Windows, Linux, the web, and macOS when the native one
|
||||
/// couldn't be installed), where the top bar starts. Runs the commands chosen.
|
||||
pub(crate) fn menu_bar_ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let bar = craft_menu::layout(&self.menu_bar(&ctx), Target::current(), &layout_options()).bar;
|
||||
let gate = self.menu_gate(&ctx);
|
||||
// The mode tabs, search and buttons follow the bar in the same row: it takes its titles'
|
||||
// width, less when the row is short, where the menus that don't fit move into its "⋯".
|
||||
let fit = (ui.available_width() - ROW_AFTER_MENUS).max(MIN_MENUS_WIDTH);
|
||||
let chosen = craft_menu::MenuBarUi::new(&bar)
|
||||
.gate(gate)
|
||||
.label(&translate)
|
||||
.english(&english)
|
||||
.id(egui::Id::unique("pdfcraft-menu-bar"))
|
||||
.shrink_to_fit(fit)
|
||||
.show(ui);
|
||||
for command in chosen {
|
||||
self.run_menu_command(&ctx, &command);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the native menu bar (macOS), once, from the eframe app creator. `false` when
|
||||
/// AppKit refused it: the in-window bar stays.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install_native_menu(&mut self, ctx: &egui::Context) -> bool {
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.language));
|
||||
let bar = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()).bar;
|
||||
// The Window menu lists the open documents itself, so AppKit's window list stays off.
|
||||
self.native_menu = craft_menu::mac::install_with(ctx, &bar, craft_menu::mac::MacOptions::default().window_list(false));
|
||||
self.native_menu.is_some()
|
||||
}
|
||||
|
||||
/// The native menu's clicks since the last frame, run like in-window ones.
|
||||
pub(crate) fn native_menu_clicks(&mut self, ctx: &egui::Context) {
|
||||
let gate = self.menu_gate(ctx);
|
||||
let Some(native) = self.native_menu.as_mut() else { return };
|
||||
native.set_gate(gate);
|
||||
for command in native.take_clicks(ctx) {
|
||||
self.run_menu_command(ctx, &command);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the native menu in step with the app, after this frame's changes.
|
||||
pub(crate) fn sync_native_menu(&mut self, ctx: &egui::Context) {
|
||||
if self.native_menu.is_none() {
|
||||
return;
|
||||
}
|
||||
let layout = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options());
|
||||
let gate = self.menu_gate(ctx);
|
||||
if let Some(native) = self.native_menu.as_mut() {
|
||||
native.set_gate(gate);
|
||||
for clash in native.sync(layout) {
|
||||
log::info!("menu shortcut {:?} kept by {} over {} ({})", clash.shortcut, clash.command.id, clash.system, clash.resolution);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's `{"index": n}` parameter.
|
||||
fn param_index(command: &CommandRef) -> Option<usize> {
|
||||
command.params.get("index").and_then(serde_json::Value::as_u64).and_then(|i| usize::try_from(i).ok())
|
||||
}
|
||||
|
||||
/// The side panel a [`PANEL`] command names.
|
||||
fn panel(command: &CommandRef) -> Option<RightPanel> {
|
||||
let name = command.params.get("panel").and_then(serde_json::Value::as_str)?;
|
||||
PANELS.iter().find(|(_, n, _)| *n == name).map(|(p, ..)| *p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_menu::{Backend, Dispatch, Event, ItemKind, NativeMenu};
|
||||
|
||||
use super::*;
|
||||
|
||||
const PDF: &[u8] = include_bytes!("../tests/data/form.pdf");
|
||||
|
||||
fn app(docs: &[&str]) -> (PdfCraftApp, egui::Context) {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
for name in docs {
|
||||
app.open_bytes(name, None, PDF.to_vec()).unwrap();
|
||||
}
|
||||
(app, egui::Context::default())
|
||||
}
|
||||
|
||||
fn linux(app: &PdfCraftApp, ctx: &egui::Context) -> MenuBar {
|
||||
craft_menu::layout(&app.menu_bar(ctx), Target::Linux, &layout_options()).bar
|
||||
}
|
||||
|
||||
fn item<'b>(bar: &'b MenuBar, command: &CommandRef) -> &'b Item {
|
||||
bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_command_is_listed_once_with_its_shortcut() {
|
||||
let (app, ctx) = app(&["a.pdf"]);
|
||||
let bar = linux(&app, &ctx);
|
||||
let items = bar.items();
|
||||
let mut keys: Vec<String> = items.iter().map(|it| it.command.key()).collect();
|
||||
keys.sort();
|
||||
let count = keys.len();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), count, "no command is listed twice");
|
||||
for spec in commands::COMMANDS.iter().filter(|s| s.menu.is_some()) {
|
||||
if spec.id == "file.open_recent" {
|
||||
continue; // A submenu of the recent files.
|
||||
}
|
||||
let it = item(&bar, &CommandRef::new(spec.id));
|
||||
let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(crate::commands::COPY));
|
||||
assert_eq!(it.shortcut, shortcut.and_then(chord), "{} has its registry shortcut", spec.id);
|
||||
}
|
||||
// Every item runs a registry command or one of the menu's own.
|
||||
for it in items {
|
||||
let id = it.command.id.as_str();
|
||||
assert!(
|
||||
commands::command(id).is_some() || extra(id).is_some() || [PANEL, DOCUMENT].contains(&id) || Builtin::of(&it.command).is_some(),
|
||||
"{id} runs something"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_and_radio_items_follow_the_app() {
|
||||
let (mut app, ctx) = app(&["a.pdf"]);
|
||||
let kind = |app: &PdfCraftApp, id: &str| item(&linux(app, &ctx), &CommandRef::new(id)).kind;
|
||||
assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(true));
|
||||
assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(false));
|
||||
let continuous = app.views[0].continuous();
|
||||
assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(continuous));
|
||||
app.run_menu_command(&ctx, &CommandRef::new("view.layout.two_up"));
|
||||
app.run_menu_command(&ctx, &CommandRef::new("view.continuous"));
|
||||
assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(false));
|
||||
assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(true));
|
||||
assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(!continuous));
|
||||
let left = app.left_open;
|
||||
assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(left));
|
||||
app.run_menu_command(&ctx, &CommandRef::new("view.tools_pane"));
|
||||
assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(!left));
|
||||
let bookmarks = CommandRef::with_params(PANEL, serde_json::json!({ "panel": "bookmarks" }));
|
||||
app.run_menu_command(&ctx, &bookmarks);
|
||||
assert_eq!(app.right, Some(RightPanel::Bookmarks));
|
||||
assert_eq!(item(&linux(&app, &ctx), &bookmarks).kind, ItemKind::Radio(true));
|
||||
assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_greys_document_commands_and_drops_their_clicks() {
|
||||
let (mut app, ctx) = app(&["a.pdf"]);
|
||||
let rotate = CommandRef::new("page.rotate");
|
||||
assert!(app.menu_gate(&ctx).allows_command(&rotate));
|
||||
app.dialog = Some(crate::Dialog::About);
|
||||
assert!(!app.menu_gate(&ctx).allows_command(&rotate), "the gate greys the menus under a dialog");
|
||||
let doc = app.views[0].id;
|
||||
let before = app.session.get(doc).unwrap().info.pages[0].rotation;
|
||||
app.run_menu_command(&ctx, &rotate);
|
||||
assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, before, "a click that arrives anyway does nothing");
|
||||
// Quit still asks the window to close, which asks about unsaved changes.
|
||||
assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_and_open_documents_are_listed_once_each() {
|
||||
let (mut app, ctx) = app(&["a.pdf", "b.pdf"]);
|
||||
app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into();
|
||||
let bar = linux(&app, &ctx);
|
||||
for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() {
|
||||
assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name);
|
||||
}
|
||||
let window = bar.menu(MenuRole::Window).unwrap();
|
||||
let documents: Vec<&str> = window
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
Node::Item(it) if it.command.id == DOCUMENT => Some(it.label.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(documents, ["a.pdf", "b.pdf"], "each open document once, in tab order");
|
||||
app.run_menu_command(&ctx, &CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": 0 })));
|
||||
assert_eq!(app.active, Some(0));
|
||||
// The macOS layout lists them once too.
|
||||
let mac = craft_menu::mac_layout(&app.menu_bar(&ctx), &layout_options()).bar;
|
||||
let listed = mac.items().iter().filter(|it| it.command.id == DOCUMENT).count();
|
||||
assert_eq!(listed, 2);
|
||||
app.recent.clear();
|
||||
let bar = linux(&app, &ctx);
|
||||
assert!(!item(&bar, &CommandRef::new(NO_RECENT)).enabled, "an empty list shows a greyed placeholder");
|
||||
assert!(bar.find(&CommandRef::new("file.clear_recent")).is_none());
|
||||
}
|
||||
|
||||
/// A native backend that reports what the test queues, through the driver's dispatch as the
|
||||
/// macOS backend does.
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeMenu(Rc<RefCell<(Option<Dispatch>, Vec<Event>)>>);
|
||||
|
||||
impl FakeMenu {
|
||||
fn click(&self, command: &CommandRef) {
|
||||
let mut state = self.0.borrow_mut();
|
||||
let event = state.0.as_ref().map(|d| d.chosen(command, None, false, None));
|
||||
state.1.extend(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for FakeMenu {
|
||||
fn sync(&mut self, _bar: &MenuBar) {}
|
||||
fn drain(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.0.borrow_mut().1)
|
||||
}
|
||||
fn attach(&mut self, dispatch: Dispatch) {
|
||||
self.0.borrow_mut().0 = Some(dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_menu_click_runs_its_command_once() {
|
||||
let (mut app, ctx) = app(&["a.pdf"]);
|
||||
let fake = FakeMenu::default();
|
||||
app.native_menu = Some(NativeMenu::new(Box::new(fake.clone())));
|
||||
let doc = app.views[0].id;
|
||||
let before = app.session.get(doc).unwrap().info.pages[0].rotation;
|
||||
fake.click(&CommandRef::new("page.rotate"));
|
||||
let mut raw = egui::RawInput::default();
|
||||
app.prepare_raw_input(&ctx, &mut raw);
|
||||
app.native_menu_clicks(&ctx);
|
||||
app.native_menu_clicks(&ctx);
|
||||
assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, (before + 90) % 360, "rotated once");
|
||||
}
|
||||
}
|
||||
@@ -213,10 +213,6 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response
|
||||
if elided { resp.on_hover_text(placeholder) } else { resp }
|
||||
}
|
||||
|
||||
pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response {
|
||||
ui.add(egui::Button::new(label).shortcut_text(shortcut))
|
||||
}
|
||||
|
||||
/// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px
|
||||
/// box is a circle, the shape of a radio button, and View ▸ Page display has both.
|
||||
const CHECKBOX_RADIUS: u8 = 3;
|
||||
|
||||
@@ -293,15 +293,14 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() {
|
||||
assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270);
|
||||
}
|
||||
|
||||
/// Check that choosing Delete pages from the Pages menu reduces the document page count.
|
||||
/// Check that choosing Edit ▸ Pages ▸ Delete pages in the menu bar reduces the page count once.
|
||||
#[test]
|
||||
fn the_pages_menu_comes_from_the_registry() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Pages").hover();
|
||||
support::open_menu(&mut h, "Edit");
|
||||
support::menu_row(&h, "Pages").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Delete pages").click();
|
||||
support::menu_row(&h, "Delete pages").click();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2);
|
||||
@@ -327,14 +326,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
|
||||
}
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Open Recent").hover();
|
||||
support::open_menu(&mut h, "File");
|
||||
support::menu_row(&h, "Open Recent").hover();
|
||||
h.run_steps(3);
|
||||
let original = h.state().views[0].id;
|
||||
h.get_by_label_contains("recent.pdf").click();
|
||||
support::menu_row(&h, "recent.pdf").click();
|
||||
support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none());
|
||||
let app = h.state();
|
||||
let active = app.active.unwrap();
|
||||
@@ -348,10 +344,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
|
||||
#[test]
|
||||
fn the_open_recent_menu_is_disabled_while_the_list_is_empty() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File").hover();
|
||||
h.run_steps(3);
|
||||
support::open_menu(&mut h, "File");
|
||||
assert!(
|
||||
h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(),
|
||||
"Open Recent is disabled while no file has been opened"
|
||||
@@ -373,19 +366,13 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() {
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File").hover();
|
||||
support::open_menu(&mut h, "File");
|
||||
support::menu_row(&h, "Open Recent").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Open Recent").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Clear Recent Files").click();
|
||||
support::menu_row(&h, "Clear Recent Files").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().recent.is_empty(), "the list is empty");
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File").hover();
|
||||
h.run_steps(3);
|
||||
support::open_menu(&mut h, "File");
|
||||
assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled");
|
||||
}
|
||||
|
||||
@@ -458,13 +445,13 @@ fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings(
|
||||
assert!(!doc.dirty);
|
||||
}
|
||||
|
||||
/// Menu ▸ View open, with egui told the app runs on `os`.
|
||||
/// View ▸ Zoom open in the menu bar, with egui told the app runs on `os`.
|
||||
fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = harness();
|
||||
h.ctx.set_os(os);
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("View").hover();
|
||||
support::open_menu(&mut h, "View");
|
||||
support::menu_row(&h, "Zoom").hover();
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
@@ -473,21 +460,14 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> {
|
||||
fn view_menu_and_tooltips_write_shortcuts_for_the_platform() {
|
||||
// On Linux, View ▸ Zoom and Page navigation showed ⌘1, ⌘[ … (hard-coded) while the
|
||||
// registered items under them showed Ctrl+K; tooltips showed ⌘ too.
|
||||
// The menu bar writes its shortcuts for the build's platform (craft-menu tests that).
|
||||
let h = view_menu(OperatingSystem::Nix);
|
||||
h.get_by_label("Actual size Ctrl+1");
|
||||
h.get_by_label("Zoom to page level Ctrl+0");
|
||||
h.get_by_label("Rotate view counterclockwise Ctrl+Shift+−");
|
||||
h.get_by_label("Previous view Ctrl+[");
|
||||
h.get_by_label("Find tools and commands… Ctrl+K");
|
||||
h.get_by_label("Zoom in (Ctrl++)");
|
||||
h.get_by_label("Print (Ctrl+P)");
|
||||
assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS");
|
||||
assert_eq!(h.query_all_by_label_contains("Fit visible").count(), 1, "View lists Fit visible once");
|
||||
h.get_by_label("Fit visible Ctrl+3");
|
||||
|
||||
let h = view_menu(OperatingSystem::Mac);
|
||||
h.get_by_label("Actual size ⌘1");
|
||||
h.get_by_label("Fit visible ⌘3");
|
||||
h.get_by_label("Rotate view clockwise (⇧⌘+)");
|
||||
assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS");
|
||||
}
|
||||
|
||||
@@ -515,12 +515,16 @@ fn language_switch_preserves_document_and_command_ids() {
|
||||
assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands);
|
||||
|
||||
let lang = i18n::Lang::from_code(code).unwrap();
|
||||
h.get_by_label(i18n::tr(lang, "Menu")).click();
|
||||
// A title without an ASCII letter shows its mnemonic after it ("ファイル(F)").
|
||||
let file = i18n::tr(lang, "File");
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(file))).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(i18n::tr(lang, "File")).hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains(i18n::tr(lang, "Open…"));
|
||||
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
|
||||
// The mnemonic goes before an ellipsis: "開く(O)…".
|
||||
let open = i18n::tr(lang, "Open…").trim_end_matches('…');
|
||||
let open_row = |n: &egui_kittest::kittest::AccessKitNode<'_>| {
|
||||
n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(open))
|
||||
};
|
||||
assert!(h.query_all_by(open_row).next().is_some(), "File lists Open… in {code}");
|
||||
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
|
||||
h.run_steps(2);
|
||||
}
|
||||
@@ -1013,18 +1017,17 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
|
||||
}
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" }));
|
||||
// The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic
|
||||
// ("編集(E)", "環境設定(P)…"), so match by the start of the label.
|
||||
let menu_row = |text: &'static str| {
|
||||
move |n: &egui_kittest::kittest::AccessKitNode<'_>| {
|
||||
n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text))
|
||||
}
|
||||
};
|
||||
h.get_by(menu_row("編集")).click();
|
||||
h.run_steps(2);
|
||||
// The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick
|
||||
// the popup entry by its id, not by its visible text.
|
||||
let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" }));
|
||||
// The tool button reports its toggle state; the popup's menu button does not.
|
||||
let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry");
|
||||
ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] }));
|
||||
h.get_by(menu_row("環境設定")).click();
|
||||
h.run_steps(2);
|
||||
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" }));
|
||||
let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item");
|
||||
ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] }));
|
||||
for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")]
|
||||
{
|
||||
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current }));
|
||||
|
||||
@@ -1371,13 +1371,8 @@ fn edit_menu_names_the_step_to_undo() {
|
||||
let mut h = harness(2, |app| {
|
||||
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
});
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
// The popup's Edit entry and the left panel's Edit tool carry the same visible label. The
|
||||
// tool reports a toggle state; the menu entry does not.
|
||||
h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut
|
||||
support::open_menu(&mut h, "Edit");
|
||||
support::menu_row(&h, "Undo Rotate page").click();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
@@ -3706,7 +3701,7 @@ fn editing_existing_text_in_place() {
|
||||
/// paragraph editor and retypes it as "Chapter One", leaving the editor open.
|
||||
fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
use pdfcraft_ui_egui::{Mode, QuickTool};
|
||||
h.get_by_label("Edit").click();
|
||||
support::outside_menus(h, "Edit").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText));
|
||||
open_the_first_line(h);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Header & footer, watermark and background dialogs in the real shell (egui_kittest).
|
||||
|
||||
use crate::support;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_engine::MarkKind;
|
||||
@@ -127,7 +128,7 @@ fn backgrounds_and_watermarks_from_a_file() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("edit.background");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File").click();
|
||||
support::outside_menus(&h, "File").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("No file chosen");
|
||||
assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file");
|
||||
|
||||
@@ -813,16 +813,16 @@ fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {
|
||||
};
|
||||
open(&mut h);
|
||||
h.get_by_label("Run a JavaScript, then 1 more action");
|
||||
// The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur).
|
||||
assert_eq!(h.get_all_by_label("Edit").count(), 3);
|
||||
h.get_all_by_label("Edit").nth(1).unwrap().click();
|
||||
// The menu bar's Edit menu and the Edit tab come first; the rows' follow (Mouse Up, On Blur).
|
||||
assert_eq!(h.get_all_by_label("Edit").count(), 4);
|
||||
h.get_all_by_label("Edit").nth(2).unwrap().click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp));
|
||||
// While one script is open, no other Edit can overwrite it.
|
||||
assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled()));
|
||||
assert!(h.get_all_by_label("Edit").skip(2).all(|n| n.accesskit_node().is_disabled()));
|
||||
h.get_by_label("Discard").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled()));
|
||||
assert!(h.get_all_by_label("Edit").skip(2).all(|n| !n.accesskit_node().is_disabled()));
|
||||
// Replace the Mouse Up script with a link: the chained link stays after it.
|
||||
{
|
||||
let d = h.state_mut().field_props.as_mut().unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate.
|
||||
|
||||
use crate::support;
|
||||
use egui::{Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
@@ -128,14 +129,14 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() {
|
||||
field(&h, "Digital ID password").click();
|
||||
h.run_steps(1);
|
||||
field(&h, "Digital ID password").type_text("nope");
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("The password is incorrect.");
|
||||
h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into();
|
||||
field(&h, "Reason").click();
|
||||
h.run_steps(1);
|
||||
field(&h, "Reason").type_text("I approve");
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
|
||||
let s = h.state();
|
||||
@@ -275,7 +276,7 @@ fn certifying_without_a_visible_signature() {
|
||||
h.get_by_label("Certify as \"Test Signer EC\"");
|
||||
h.get_by_label("Permitted actions after certifying");
|
||||
h.state_mut().sign_draft.as_mut().unwrap().password = "test".into();
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
|
||||
@@ -361,7 +362,7 @@ fn clicking_an_empty_signature_field_signs_it() {
|
||||
d.password = "test".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
|
||||
@@ -398,7 +399,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() {
|
||||
h.get_by_label("Fill & Sign signature").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign);
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
|
||||
@@ -418,7 +419,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() {
|
||||
h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Choose image…");
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
|
||||
@@ -534,7 +535,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() {
|
||||
h.get_by_label("Sign as \"Store signer\"");
|
||||
assert_eq!(h.query_all_by_label("Digital ID password").count(), 0);
|
||||
h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.");
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Sign));
|
||||
assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store"));
|
||||
@@ -679,7 +680,7 @@ fn signing_preserves_destination_opening_attributes() {
|
||||
let d = h.state_mut().sign_draft.as_mut().unwrap();
|
||||
(d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed));
|
||||
assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec()));
|
||||
|
||||
@@ -51,6 +51,27 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the in-window menu bar's `title` menu (File, Edit, View…).
|
||||
pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(title)).click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The menu row labelled `label` in an open menu.
|
||||
pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let label = label.to_owned();
|
||||
h.get_by(move |n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str()))
|
||||
}
|
||||
|
||||
/// The widget labelled `label` outside the menu bar, whose titles (File, Edit, Sign…) may share it.
|
||||
pub fn outside_menus<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let label = label.to_owned();
|
||||
h.get_by(move |n| n.role() != egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str()))
|
||||
}
|
||||
|
||||
/// Whether the test `name` must skip because the interface fonts lack a glyph for a character it
|
||||
/// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses
|
||||
/// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional
|
||||
|
||||
@@ -192,17 +192,14 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool {
|
||||
/// Check that both the Edit menu and the context menu copy the selected page text.
|
||||
#[test]
|
||||
fn selected_text_copy_edit_and_context_menus() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut h, at) = harness(|_| {});
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand;
|
||||
h.run_steps(2); // Hide floating Copy so this test can only reach menu entries.
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Copy").click();
|
||||
crate::support::open_menu(&mut h, "Edit");
|
||||
crate::support::menu_row(&h, "Copy").click();
|
||||
let mut did_copy = false;
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
@@ -229,15 +226,13 @@ fn selected_text_copy_edit_and_context_menus() {
|
||||
|
||||
#[test]
|
||||
fn escape_closes_an_open_menu_before_it_clears_the_selection() {
|
||||
// #3535: one Escape closed the main menu and dropped the selection with it.
|
||||
use egui_kittest::kittest::Queryable;
|
||||
// #3535: one Escape closed the menu and dropped the selection with it.
|
||||
let (mut h, at) = harness(|_| {});
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
crate::support::open_menu(&mut h, "View");
|
||||
assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
|
||||
@@ -36,12 +36,9 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
|
||||
}
|
||||
|
||||
fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("View").hover();
|
||||
h.run_steps(3);
|
||||
crate::support::open_menu(h, "View");
|
||||
assert!(h.query_by_label("Switch light / dark theme").is_none());
|
||||
h.get_by_label("Display theme").hover();
|
||||
crate::support::menu_row(h, "Display theme").hover();
|
||||
h.run_steps(3);
|
||||
for choice in ["Use system setting", "Light gray", "Dark gray"] {
|
||||
h.get_by_label(choice);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the
|
||||
//! self-signed root) differs from its `certificate` (the signer); the click must trust the signer.
|
||||
|
||||
use crate::support;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_engine::SignatureStatus;
|
||||
@@ -58,7 +59,7 @@ fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() {
|
||||
d.password = "test".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Sign").click();
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
|
||||
// The signature validates as Unknown and carries a two-certificate chain whose top (the root)
|
||||
|
||||
@@ -279,7 +279,7 @@ fn expanding_all_tools_from_read_mode_restores_the_right_panel() {
|
||||
fn expanding_all_tools_from_edit_puts_edit_text_down() {
|
||||
use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool};
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
h.get_by_label("Edit").click();
|
||||
support::outside_menus(&h, "Edit").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::EditText);
|
||||
h.state_mut().set_option("tools", "full").unwrap();
|
||||
@@ -1640,7 +1640,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
|
||||
use pdfcraft_ui_egui::{Mode, QuickTool};
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
h.get_by_label("Edit").click();
|
||||
support::outside_menus(&h, "Edit").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().mode, Mode::Edit);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::EditText);
|
||||
@@ -1649,7 +1649,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
|
||||
assert_eq!(h.state().mode, Mode::AllTools);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
// Another tool picked inside Edit is not taken away by leaving it.
|
||||
h.get_by_label("Edit").click();
|
||||
support::outside_menus(&h, "Edit").click();
|
||||
h.run_steps(3);
|
||||
h.state_mut().quick_tool = QuickTool::Hand;
|
||||
h.get_by_label("Convert").click();
|
||||
@@ -1663,7 +1663,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
|
||||
fn organize_pages_from_the_edit_panel_opens_its_panel() {
|
||||
use pdfcraft_ui_egui::LeftPanel;
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
h.get_by_label("Edit").click();
|
||||
support::outside_menus(&h, "Edit").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().left, LeftPanel::Tool("edit"));
|
||||
assert!(
|
||||
@@ -1681,7 +1681,7 @@ fn organize_pages_from_the_edit_panel_opens_its_panel() {
|
||||
#[test]
|
||||
fn the_edit_panel_keeps_the_add_update_remove_menu() {
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
h.get_by_label("Edit").click();
|
||||
support::outside_menus(&h, "Edit").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Watermark").click();
|
||||
h.run_steps(3);
|
||||
|
||||
@@ -1033,9 +1033,8 @@ fn view_menu_page_display_uses_the_same_commands() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = harness(&[]);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("View").hover();
|
||||
crate::support::open_menu(&mut h, "View");
|
||||
crate::support::menu_row(&h, "Page display").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Single page").click();
|
||||
h.run_steps(4);
|
||||
@@ -2011,17 +2010,14 @@ fn long_view_menu_fits_a_short_window() {
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Menu").click();
|
||||
crate::support::open_menu(&mut h, "View");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("View").hover();
|
||||
h.run_steps(4);
|
||||
let before = h.get_by_label("Side panels").rect().bottom();
|
||||
h.get_by_label("Fit to height ").hover();
|
||||
crate::support::menu_row(&h, "Rotate view").hover();
|
||||
h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move));
|
||||
h.run_steps(6);
|
||||
let after = h.get_by_label("Side panels").rect().bottom();
|
||||
assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}");
|
||||
h.get_by_label("Side panels").hover();
|
||||
let after = crate::support::menu_row(&h, "Side panels").rect().bottom();
|
||||
assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}");
|
||||
crate::support::menu_row(&h, "Side panels").hover();
|
||||
h.run_steps(4);
|
||||
h.get_all_by_label("Bookmarks").last().unwrap().click();
|
||||
h.run_steps(4);
|
||||
|
||||
@@ -8,8 +8,9 @@ not fresh measurements on upstream main. No private document is needed for the b
|
||||
|
||||
- `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox
|
||||
toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API.
|
||||
- `crates/engine/src/lib.rs::Session::apply` runs edits on a copy, preserving rollback and undo.
|
||||
`run_edit` calls `pdfcraft_forms::set_value_with` with an engine `JsRunner`.
|
||||
- `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and
|
||||
undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an
|
||||
engine `JsRunner`.
|
||||
- `crates/forms/src/lib.rs` validates and writes the edited value, then runs one calculation
|
||||
pass. `/CO` precedes calculated fields omitted from `/CO`. Each script sees prior writes.
|
||||
There is no dependency graph or dependency-based filtering: dynamic references and script
|
||||
@@ -265,6 +266,8 @@ checks, not hosted CI or a new performance speedup claim.
|
||||
|
||||
## Revision history
|
||||
|
||||
- 2026-10-25: point the architecture notes at `session.rs` and `edit.rs`, where `Session::apply`
|
||||
and `run_edit` moved when the engine's `lib.rs` was split.
|
||||
- 2026-10-19: refresh PR #3318 onto upstream `e397efa8`, retain upstream roadmap additions
|
||||
and all performance changes, and repeat focused, scoped, benchmark and headless validation.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user