Merge remote-tracking branch 'upstream/main' into main

This commit is contained in:
Combiler01 committed 2026-10-27 00:28:10 +05:30
commit 1d1d509331
42 files changed
+6790 -5984

No files matched your search

Generated
+36 -13
View File
@@ -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
View File
@@ -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).
-3
View File
@@ -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
+5 -4
View File
@@ -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 {
+61
View File
@@ -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
}
+163
View File
@@ -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
}
+713
View File
@@ -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
+80
View File
@@ -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())
}
}
+47
View File
@@ -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))
}
+233
View File
@@ -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,
}
}
+60
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+355
View File
@@ -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(),
}
}
}
+41
View File
@@ -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()
}
+59
View File
@@ -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 })
}
}
+158
View File
@@ -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
}
+53
View File
@@ -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
+51
View File
@@ -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()
}
+42
View File
@@ -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));
});
}
+3
View File
@@ -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> {
+297
View File
@@ -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())
}
+6 -3
View File
@@ -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"
+8 -121
View File
@@ -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());
+20 -71
View File
@@ -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::*;
+24 -1
View File
@@ -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) {
+791
View File
@@ -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, &current), 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");
}
}
-4
View File
@@ -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;
+16 -36
View File
@@ -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");
}
+18 -15
View File
@@ -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 }));
+3 -8
View File
@@ -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);
+2 -1
View File
@@ -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");
+5 -5
View 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();
+9 -8
View File
@@ -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()));
+21
View File
@@ -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
+5 -10
View File
@@ -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);
+2 -5
View File
@@ -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);
+2 -1
View File
@@ -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)
+5 -5
View File
@@ -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);
+7 -11
View File
@@ -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);
+5 -2
View File
@@ -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.