From fdf545b864ee7fe87249a8cb27a66e4667fbadc0 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:27:38 -0500 Subject: [PATCH 01/38] chore(github): add a bug report issue form The form uses the field ids PhotoCraft's prefilled report uses (version, os, system-info, document-info), so a Report an Issue item can fill it in and one maintainer can read reports from any Craft app. Related to #1619. --- .github/ISSUE_TEMPLATE/bug_report.yml | 69 +++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ 2 files changed, 74 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..33a3c9a5d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Something in PdfCraft doesn't work as it should. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. Search [open and closed issues](https://github.com/storytold/pdfcraft/issues?q=is%3Aissue) first, and add details to an existing report if it describes the same problem. + + If you opened this from PdfCraft's Help > Report an Issue… item, the version and system fields are already filled in: check them before you submit. Nothing is sent until you submit this form. + + **Reports and attachments are public.** Use made-up data only: don't attach confidential PDFs, and remove personal details, passwords and full local file paths from anything you paste. A small synthetic PDF is often enough. + - type: input + id: version + attributes: + label: PdfCraft version + description: The exact version from Help > About PdfCraft. For a source build, include the commit hash. + placeholder: "Exact version, and build or commit if available" + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + description: For the web build, also give your browser and its version. Add the OS version (for Linux, the distribution) if you know it. + placeholder: "For example: Windows 11, macOS 26, or Ubuntu 24.04 / Wayland" + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Numbered steps with the exact tools, menu items and settings. Say whether it happens every time or only sometimes. + placeholder: | + 1. Open a PDF. + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should happen after these steps? If you are comparing with another PDF application, give its name and version. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happens instead? Include the exact error text, or say whether the app froze, closed or lost changes. + validations: + required: true + - type: textarea + id: document-info + attributes: + label: Document details (optional) + description: File type and how the PDF was made (for example, scanned, exported from Word, or created in PdfCraft), page count, and whether a new blank document also reproduces it. No confidential content. + - type: textarea + id: system-info + attributes: + label: System information (optional) + description: Report an Issue fills this in. Otherwise add what looks relevant, such as the graphics card and driver for display problems. Remove usernames and full paths. + render: text + - type: textarea + id: diagnostics + attributes: + label: Screenshots, sample files or logs (optional) + description: Attach a cropped screenshot or a small synthetic PDF that reproduces the problem. Only share files you have permission to publish. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..9bdaec8b05 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: ArtCraft Discord + url: https://discord.gg/artcraft + about: Questions and discussion with the community. From cca1cd439c7efadef91cc1e3dd86022fca726db2 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:51:01 -0500 Subject: [PATCH 02/38] =?UTF-8?q?feat(ui):=20Help=20>=20Report=20an=20Issu?= =?UTF-8?q?e=E2=80=A6=20opens=20a=20prefilled=20bug=20report=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens GitHub's new-issue page with the version, OS, graphics adapter, interface language and the open document's type (never its name or contents) filled in through the bug report form's field ids. The user reads and submits the form; nothing is sent. When no browser opens, a dialog keeps the address and the details with Copy Details. The desktop app opens the address through webbrowser and the web build through window.open, so each can say whether it worked. Strings are in every catalog. Related to #1619. --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 ++ Cargo.lock | 1 + ROADMAP.md | 1 + apps/pdfcraft-web/src/main.rs | 17 +++ apps/pdfcraft/Cargo.toml | 3 + apps/pdfcraft/src/main.rs | 3 + assets/icons/message-square-warning.svg | 15 ++ crates/engine/src/commands.rs | 1 + crates/engine/src/links.rs | 3 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 43 ++++++ crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/ckb.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fa.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/kk.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/lo.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 4 + crates/ui-egui/src/i18n/nb.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ro.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/sq.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/th.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/icons.rs | 2 +- crates/ui-egui/src/lib.rs | 50 ++++++ crates/ui-egui/src/menus.rs | 24 ++- crates/ui-egui/src/report_issue.rs | 192 ++++++++++++++++++++++++ crates/ui-egui/tests/it/links.rs | 107 +++++++++++++ docs/ui-parity.md | 3 +- 53 files changed, 612 insertions(+), 5 deletions(-) create mode 100644 assets/icons/message-square-warning.svg create mode 100644 crates/ui-egui/src/report_issue.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c5be9d7bc1..64e2cd9900 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (289) +## In this repository (290) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -144,6 +144,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-warning.svg` | Lucide icon "message-square-warning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-warning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 8b2186d5b4..f44fdbb3b4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -1479,6 +1479,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "5812a3be783fdcd73efd2b6026e03131e4b8c6649e7d52aa2dc7bf2e2023a59d" +[[asset]] +path = "assets/icons/message-square-warning.svg" +title = "Lucide icon \"message-square-warning\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-warning.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "66e83a1ad0a584bc5faa5bd38fdaa4060789d5912813da77dbec900eb01c1bda" + [[asset]] path = "assets/icons/messages-square.svg" title = "Lucide icon \"messages-square\"" diff --git a/Cargo.lock b/Cargo.lock index 3c713ba5cc..d2835138b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4070,6 +4070,7 @@ dependencies = [ "uds_windows", "wayland-client", "wayland-protocols", + "webbrowser", "windows", "winit", "winresource", diff --git a/ROADMAP.md b/ROADMAP.md index 165fccc5e8..1d6c22da92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-26 (M14, #1619 first step):** Help ▸ Report an Issue… (`help.report_issue`; also in the command palette and the macOS menu bar) opens the new bug report form (`.github/ISSUE_TEMPLATE/bug_report.yml`) with the version, OS, graphics adapter, interface language and "Document type: pdf" filled in through its field ids. The user reads and submits the form on GitHub; PdfCraft sends nothing and puts no file name or content in the address. When no browser opens, a dialog keeps the address and the details with Copy Details. Needs the desktop app's direct `webbrowser` dependency (already in the tree through eframe) to learn whether a browser opened. Automatic reports stay open in #1619. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 73d14f4a6c..76cc4be0c6 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -54,6 +54,8 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); + app.url_opener = Some(Box::new(open_in_new_tab)); + app.graphics = Some("WebGL (glow, web)".to_owned()); // `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such // as the Nextcloud app) passes files in and takes saves back (`host` below). if query_param("host").as_deref() == Some("parent") @@ -104,6 +106,21 @@ fn warn_before_unload(unsaved: std::sync::Arc) { } } +/// Opens `url` in a new tab. Without `noopener` the call returns the new window, or nothing when a +/// pop-up blocker stopped it, which is how Report an Issue… learns it must show the details. +#[cfg(target_arch = "wasm32")] +fn open_in_new_tab(url: &str) -> Result<(), String> { + let window = web_sys::window().ok_or("no window")?; + match window.open_with_url_and_target(url, "_blank").map_err(|e| format!("{e:?}"))? { + Some(tab) => { + // What `noopener` would have done: the new page gets no handle back to this one. + let _ = tab.set_opener(&eframe::wasm_bindgen::JsValue::NULL); // best effort: the tab is already open + Ok(()) + } + None => Err("the browser blocked the new tab".to_owned()), + } +} + #[cfg(target_arch = "wasm32")] fn query_param(key: &str) -> Option { let search = web_sys::window()?.location().search().ok()?; diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 966af354d1..f4a9bfea5a 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -23,6 +23,9 @@ serde_json = { workspace = true } log = { workspace = true } # The log timestamp goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } +# Help ▸ Report an Issue… opens the issue form and needs to know whether a browser opened, which +# egui's `open_url` can't say. The same crate eframe already uses for links; no new crate in the tree. +webbrowser = "1" [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ee8ae152cc..81c39bbc03 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -447,8 +447,10 @@ fn app_creator<'a>( if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() { app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir)); } + app.url_opener = Some(Box::new(|url| webbrowser::open(url).map_err(|e| e.to_string()))); if let Some(state) = &cc.wgpu_render_state { let adapter = state.adapter.get_info(); + app.graphics = Some(format!("{} ({:?}, {:?})", adapter.name, adapter.backend, adapter.device_type)); let max_texture_dimension_2d = state.device.limits().max_texture_dimension_2d; log::info!( "renderer: wgpu; adapter: {} ({:?}, {:?}); max_texture_dimension_2d: {max_texture_dimension_2d}", @@ -458,6 +460,7 @@ fn app_creator<'a>( ); notify_software_renderer(&mut app, adapter.device_type); } else { + app.graphics = Some("OpenGL (glow)".to_owned()); log::info!("renderer: OpenGL (glow)"); } // A portable marker whose data folder can't be written (#157): say where settings went. diff --git a/assets/icons/message-square-warning.svg b/assets/icons/message-square-warning.svg new file mode 100644 index 0000000000..ce0b6a44b3 --- /dev/null +++ b/assets/icons/message-square-warning.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1f25c23c98..bc50f05be9 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -394,6 +394,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), + c("help.report_issue", "Report an Issue…", HELP, None, Nothing, "message-square-warning"), c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"), c("help.github", "PdfCraft on GitHub", HELP, None, Nothing, "code-xml"), c("help.website", "ArtCraft website", HELP, None, Nothing, "external-link"), diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 9b1c49729f..8691cb0072 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -11,6 +11,9 @@ pub const DISCORD: &str = "https://discord.gg/artcraft"; pub const WEBSITE: &str = "https://getartcraft.com"; pub const APP_PAGE: &str = "https://getartcraft.com/apps/pdfcraft"; pub const GITHUB: &str = "https://github.com/storytold/pdfcraft"; +/// Where Help ▸ Report an Issue… sends people: GitHub's new-issue page, which opens the bug report +/// form (`.github/ISSUE_TEMPLATE/bug_report.yml`) with the fields the app fills in. +pub const NEW_ISSUE: &str = "https://github.com/storytold/pdfcraft/issues/new"; /// A link and the registry command that opens it. #[derive(Clone, Copy, Debug)] diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 26fad6f77c..4b32c0efb3 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -698,6 +698,7 @@ impl PdfCraftApp { "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), "help.about" => self.dialog = Some(Dialog::About), "help.check_updates" => self.check_for_updates(), + "help.report_issue" => self.report_issue(), _ => return false, } true diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b3437246a0..8384fd10df 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); link_prompt(app, ctx); + failed_link_prompt(app, ctx); remote_link_prompt(app, ctx); full_screen_prompt(app, ctx); crate::updates::dialog(app, ctx); @@ -1857,6 +1858,48 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { } } +/// Report an Issue… when no web browser opened (`failedLink`): the address and the details the +/// report would have filled in, in read-only fields to copy from. It stays until Close or Escape, +/// and the details are kept, so nothing the user would have sent is lost. +fn failed_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { + let Some(failed) = app.failed_link.clone() else { return }; + let t = Tokens::get(ctx); + let (mut copy, mut close) = (false, false); + let modal = widgets::show_modal(ctx, "failed_link", |ui| { + ui.set_width(480.0); + ui.horizontal(|ui| { + ui.add(crate::icons::image("message-square-warning", 22.0, t.accent)); + ui.label(egui::RichText::new(tl!("Couldn't open your web browser")).font(theme::semibold(16.0))); + }); + ui.add(egui::Label::new(egui::RichText::new(&failed.error).color(t.text_muted).small()).wrap()); + ui.add_space(8.0); + let label = ui.label(egui::RichText::new(tl!("Address")).color(t.text_muted)); + ui.add(egui::TextEdit::singleline(&mut failed.url.as_str()).id_salt("failedLink.url").desired_width(f32::INFINITY)).labelled_by(label.id); + ui.add_space(8.0); + let label = ui.label(egui::RichText::new(tl!("Details")).color(t.text_muted)); + ui.add( + egui::TextEdit::multiline(&mut failed.details.as_str()) + .id_salt("failedLink.details") + .desired_rows(6) + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ) + .labelled_by(label.id); + ui.add_space(14.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + close = widgets::pill_button(ui, tl!("Close"), true).clicked(); + copy = widgets::pill_button(ui, tl!("Copy Details"), false).clicked(); + }); + }); + if copy { + // The details, then where to paste them. + ctx.copy_text(format!("{}\n\n{}", failed.details, failed.url)); + } + if close || modal.should_close() { + app.failed_link = None; + } +} + /// A document that asks to open in Full Screen mode (`/PageMode /FullScreen`) is asked about /// first, as Acrobat does: full screen hides every control, so it is the person's choice. fn full_screen_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3e6d735523..cd83469e81 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2833,3 +2833,7 @@ certificate Version الإصدار Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة Extract the selected pages… استخراج الصفحات المحددة… Delete the selected pages حذف الصفحات المحددة + Report an Issue… الإبلاغ عن مشكلة… + Couldn't open your web browser تعذّر فتح متصفح الويب + Copy Details نسخ التفاصيل + Address العنوان diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ec0339f18a..a0c5113e0d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2520,3 +2520,7 @@ animation effects Off Изключени Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка Extract the selected pages… Извличане на избраните страници… Delete the selected pages Изтриване на избраните страници + Report an Issue… Съобщаване за проблем… + Couldn't open your web browser Уеб браузърът не можа да бъде отворен + Copy Details Копирай подробностите + Address Адрес diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0cacae3c2c..796d1a3848 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2818,3 +2818,7 @@ theme Appearance Aspecte Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari Extract the selected pages… Extreu les pàgines seleccionades… Delete the selected pages Suprimeix les pàgines seleccionades + Report an Issue… Informa d'un problema… + Couldn't open your web browser No s'ha pogut obrir el navegador web + Copy Details Copia els detalls + Address Adreça diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 71c0c62869..577c7f9c3b 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2749,3 +2749,7 @@ certificate Version وەشان Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە + Report an Issue… ڕاپۆرتکردنی کێشەیەک… + Couldn't open your web browser نەتوانرا وێبگەڕەکەت بکرێتەوە + Copy Details کۆپیکردنی وردەکارییەکان + Address ناونیشان diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ed6d051b57..b176469647 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2864,3 +2864,7 @@ certificate Version Verze Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček Extract the selected pages… Extrahovat vybrané stránky… Delete the selected pages Odstranit vybrané stránky + Report an Issue… Nahlásit problém… + Couldn't open your web browser Nepodařilo se otevřít webový prohlížeč + Copy Details Kopírovat podrobnosti + Address Adresa diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..9978029b02 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2558,3 +2558,7 @@ animation effects Off Aus Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen Extract the selected pages… Ausgewählte Seiten extrahieren… Delete the selected pages Ausgewählte Seiten löschen + Report an Issue… Problem melden… + Couldn't open your web browser Der Webbrowser konnte nicht geöffnet werden + Copy Details Details kopieren + Address Adresse diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e3eac77caa..8a5fb1e8ee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2563,3 +2563,7 @@ animation effects Off Ανενεργά Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… Delete the selected pages Διαγραφή των επιλεγμένων σελίδων + Report an Issue… Αναφορά προβλήματος… + Couldn't open your web browser Δεν ήταν δυνατό το άνοιγμα του προγράμματος περιήγησης + Copy Details Αντιγραφή λεπτομερειών + Address Διεύθυνση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0d4e505a70..c4ea210931 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2880,3 +2880,7 @@ theme Appearance Aspecto Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario Extract the selected pages… Extraer las páginas seleccionadas… Delete the selected pages Eliminar las páginas seleccionadas + Report an Issue… Informar de un problema… + Couldn't open your web browser No se pudo abrir el navegador web + Copy Details Copiar detalles + Address Dirección diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 5cfc217533..7891af82f7 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -2804,3 +2804,7 @@ theme Appearance ظاهر two cells would both be named {name} دو خانه هر دو با نام {name} نام‌گذاری می‌شوند a field named {name} already exists فیلدی با نام {name} از قبل وجود دارد Today امروز + Report an Issue… گزارش مشکل… + Couldn't open your web browser مرورگر وب باز نشد + Copy Details کپی جزئیات + Address نشانی diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a542ee9b91..43ed7ff5f0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2827,3 +2827,7 @@ theme Appearance Teema Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään Extract the selected pages… Pura valitut sivut… Delete the selected pages Poista valitut sivut + Report an Issue… Ilmoita ongelmasta… + Couldn't open your web browser Verkkoselainta ei voitu avata + Copy Details Kopioi tiedot + Address Osoite diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 61c8b5171d..2712f08c10 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2823,3 +2823,7 @@ certificate Version Version Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire Extract the selected pages… Extraire les pages sélectionnées… Delete the selected pages Supprimer les pages sélectionnées + Report an Issue… Signaler un problème… + Couldn't open your web browser Impossible d'ouvrir votre navigateur web + Copy Details Copier les détails + Address Adresse diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e9d627aca9..2cc364123e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2794,3 +2794,7 @@ theme Appearance स्वरूप Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ Extract the selected pages… चयनित पृष्ठ निकालें… Delete the selected pages चयनित पृष्ठ हटाएँ + Report an Issue… समस्या की रिपोर्ट करें… + Couldn't open your web browser आपका वेब ब्राउज़र नहीं खुल सका + Copy Details विवरण कॉपी करें + Address पता diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6b37b9169b..db3cc4ff69 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2556,3 +2556,7 @@ animation effects Off Ki Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően Extract the selected pages… A kijelölt oldalak kibontása… Delete the selected pages A kijelölt oldalak törlése + Report an Issue… Hiba bejelentése… + Couldn't open your web browser Nem sikerült megnyitni a webböngészőt + Copy Details Részletek másolása + Address Cím diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 292b336e10..fb988632b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2795,3 +2795,7 @@ theme Appearance Tampilan Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam Extract the selected pages… Ekstrak halaman yang dipilih… Delete the selected pages Hapus halaman yang dipilih + Report an Issue… Laporkan Masalah… + Couldn't open your web browser Tidak dapat membuka peramban web Anda + Copy Details Salin Detail + Address Alamat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 20729bae6a..3b61304d8b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2789,3 +2789,7 @@ certificate SHA-256 digest Impronta SHA-256 Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario Extract the selected pages… Estrai le pagine selezionate… Delete the selected pages Elimina le pagine selezionate + Report an Issue… Segnala un problema… + Couldn't open your web browser Impossibile aprire il browser web + Copy Details Copia dettagli + Address Indirizzo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ed4d43b6c..0ad60bf154 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2557,3 +2557,7 @@ animation effects Off オフ Rotate the selected pages clockwise 選択したページを時計回りに回転 Extract the selected pages… 選択したページを抽出… Delete the selected pages 選択したページを削除 + Report an Issue… 問題を報告… + Couldn't open your web browser ウェブブラウザーを開けませんでした + Copy Details 詳細をコピー + Address アドレス diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9022c3591c..77fc71f905 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2555,3 +2555,7 @@ animation effects Off Өшірулі Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру Extract the selected pages… Таңдалған беттерді шығарып алу… Delete the selected pages Таңдалған беттерді жою + Report an Issue… Мәселе туралы хабарлау… + Couldn't open your web browser Веб-браузерді ашу мүмкін болмады + Copy Details Мәліметтерді көшіру + Address Мекенжай diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 09b9cad9fa..a0a4d3c630 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2794,3 +2794,7 @@ theme Appearance 모양 Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 Extract the selected pages… 선택한 페이지 추출… Delete the selected pages 선택한 페이지 삭제 + Report an Issue… 문제 신고… + Couldn't open your web browser 웹 브라우저를 열 수 없습니다 + Copy Details 세부 정보 복사 + Address 주소 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 964467d823..30bda311fb 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2465,3 +2465,7 @@ animation effects Off ປິດ Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… Delete the selected pages ລຶບໜ້າທີ່ເລືອກ + Report an Issue… ລາຍງານບັນຫາ… + Couldn't open your web browser ບໍ່ສາມາດເປີດເວັບບຼາວເຊີຂອງທ່ານໄດ້ + Copy Details ສຳເນົາລາຍລະອຽດ + Address ທີ່ຢູ່ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 43749f4ec8..a043c17ac4 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2818,3 +2818,7 @@ theme Appearance Išvaizda Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę Extract the selected pages… Išskleisti pasirinktus puslapius… Delete the selected pages Ištrinti pasirinktus puslapius + Report an Issue… Pranešti apie problemą… + Couldn't open your web browser Nepavyko atidaryti žiniatinklio naršyklės + Copy Details Kopijuoti išsamią informaciją + Address Adresas diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 6f1739974a..38d31a9161 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2733,3 +2733,7 @@ key Space Mellomrom Rotate the selected pages clockwise Roter de valgte sidene med klokken Extract the selected pages… Trekk ut de valgte sidene… Delete the selected pages Slett de valgte sidene + Report an Issue… Rapporter et problem… + Couldn't open your web browser Kunne ikke åpne nettleseren din + Copy Details Kopier detaljer + Address Adresse diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 963d717584..388b4f1cd5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2653,3 +2653,7 @@ animation effects Off Wyłączone Rotate the selected pages clockwise Obróć zaznaczone strony w prawo Extract the selected pages… Wyodrębnij zaznaczone strony… Delete the selected pages Usuń zaznaczone strony + Report an Issue… Zgłoś problem… + Couldn't open your web browser Nie można otworzyć przeglądarki internetowej + Copy Details Kopiuj szczegóły + Address Adres diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c34ae83bc5..dcf50f41d2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2842,3 +2842,7 @@ theme Appearance Aparência Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário Extract the selected pages… Extrair as páginas selecionadas… Delete the selected pages Excluir as páginas selecionadas + Report an Issue… Relatar um problema… + Couldn't open your web browser Não foi possível abrir o navegador da web + Copy Details Copiar detalhes + Address Endereço diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 78d2298b93..935d43fffb 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2818,3 +2818,7 @@ theme Appearance Aspect Rotate the selected pages clockwise Rotește paginile selectate în sens orar Extract the selected pages… Extrage paginile selectate… Delete the selected pages Șterge paginile selectate + Report an Issue… Raportează o problemă… + Couldn't open your web browser Nu s-a putut deschide browserul web + Copy Details Copiere detalii + Address Adresă diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 274232d586..a4aa90b8ab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2470,3 +2470,7 @@ animation effects Off Выкл Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке Extract the selected pages… Извлечь выбранные страницы… Delete the selected pages Удалить выбранные страницы + Report an Issue… Сообщить о проблеме… + Couldn't open your web browser Не удалось открыть веб-браузер + Copy Details Копировать подробности + Address Адрес diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 420e6a5996..7af682adf5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2562,3 +2562,7 @@ animation effects Off Izklopljeni Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca Extract the selected pages… Izvleci izbrane strani… Delete the selected pages Izbriši izbrane strani + Report an Issue… Prijavi težavo… + Couldn't open your web browser Spletnega brskalnika ni bilo mogoče odpreti + Copy Details Kopiraj podrobnosti + Address Naslov diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index ba7c9dc5b3..9798090320 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2544,3 +2544,7 @@ animation effects Off Joaktiv Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës Extract the selected pages… Nxirr faqet e përzgjedhura… Delete the selected pages Fshi faqet e përzgjedhura + Report an Issue… Raporto një problem… + Couldn't open your web browser Shfletuesi i uebit nuk u hap dot + Copy Details Kopjo detajet + Address Adresa diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 7f339989ee..b2ce436e2c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2466,3 +2466,7 @@ animation effects Off ఆఫ్ Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు + Report an Issue… సమస్యను నివేదించండి… + Couldn't open your web browser మీ వెబ్ బ్రౌజర్‌ను తెరవలేకపోయాము + Copy Details వివరాలను కాపీ చేయి + Address చిరునామా diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 2c9b892ed7..30f98228f6 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2720,3 +2720,7 @@ theme Appearance ลักษณะที่ปรากฏ Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา Extract the selected pages… แยกหน้าที่เลือกออก… Delete the selected pages ลบหน้าที่เลือก + Report an Issue… รายงานปัญหา… + Couldn't open your web browser ไม่สามารถเปิดเว็บเบราว์เซอร์ของคุณได้ + Copy Details คัดลอกรายละเอียด + Address ที่อยู่ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a2ecaea179..c56a6f5d34 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2553,3 +2553,7 @@ animation effects Off Kapalı Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür Extract the selected pages… Seçili sayfaları ayıkla… Delete the selected pages Seçili sayfaları sil + Report an Issue… Bir Sorun Bildirin… + Couldn't open your web browser Web tarayıcınız açılamadı + Copy Details Ayrıntıları Kopyala + Address Adres diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c76868839..ba9acbd02c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2579,3 +2579,7 @@ animation effects Off Вимкнено Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою Extract the selected pages… Витягнути вибрані сторінки… Delete the selected pages Видалити вибрані сторінки + Report an Issue… Повідомити про проблему… + Couldn't open your web browser Не вдалося відкрити веб-браузер + Copy Details Копіювати відомості + Address Адреса diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 973ba870ea..c70cc180a9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2824,3 +2824,7 @@ key Space Space Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ Extract the selected pages… Trích xuất các trang đã chọn… Delete the selected pages Xóa các trang đã chọn + Report an Issue… Báo cáo vấn đề… + Couldn't open your web browser Không thể mở trình duyệt web của bạn + Copy Details Sao chép chi tiết + Address Địa chỉ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d77c8f6425..e4ee45d7b9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2521,3 +2521,7 @@ animation effects Off 关 Rotate the selected pages clockwise 顺时针旋转所选页面 Extract the selected pages… 提取所选页面… Delete the selected pages 删除所选页面 + Report an Issue… 报告问题… + Couldn't open your web browser 无法打开网页浏览器 + Copy Details 复制详情 + Address 地址 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ce3b939f1d..1bf2da1f3a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2507,3 +2507,7 @@ animation effects Off 關 Rotate the selected pages clockwise 順時針旋轉所選頁面 Extract the selected pages… 擷取所選頁面… Delete the selected pages 刪除所選頁面 + Report an Issue… 回報問題… + Couldn't open your web browser 無法開啟網頁瀏覽器 + Copy Details 複製詳情 + Address 地址 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index f986cd6bf4..921574c706 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -113,6 +113,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("message-square-plus", include_bytes!("../../../assets/icons/message-square-plus.svg")), ("message-square-quote", include_bytes!("../../../assets/icons/message-square-quote.svg")), ("message-square-text", include_bytes!("../../../assets/icons/message-square-text.svg")), + ("message-square-warning", include_bytes!("../../../assets/icons/message-square-warning.svg")), ("messages-square", include_bytes!("../../../assets/icons/messages-square.svg")), ("mic", include_bytes!("../../../assets/icons/mic.svg")), ("minimize", include_bytes!("../../../assets/icons/minimize.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5ad5425e8d..13f50a0d6c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn shared_catalog_preserves_existing_icons_and_fallback_uri() { - assert_eq!(ICONS.len(), 190); + assert_eq!(ICONS.len(), 191); assert!(exists("minimize-2")); for (name, original) in ICONS { let expected = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3446bcd26e..a81d36a0f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -145,6 +145,7 @@ mod organize_panel; pub mod portable; mod protect; mod recovery; +mod report_issue; pub mod scroll_speed; #[cfg(not(target_arch = "wasm32"))] mod system_fonts; @@ -551,6 +552,10 @@ pub type OsEventsFn = Box Vec>; /// for saves; without it the browser downloads the file. pub type HostSaveFn = Box Result<(), String>>; +/// Opens an address in the user's web browser and says whether it could (set by the host app). +/// Without one, links go through egui, which can't tell the app that no browser opened. +pub type UrlOpenerFn = Box Result<(), String>>; + /// Told every frame whether any tab has unsaved work, so a host page can warn before leaving. pub type HostDirtyFn = Box; @@ -998,6 +1003,13 @@ pub struct PdfCraftApp { pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, + /// How the host opens a web address and finds out whether it worked ([`UrlOpenerFn`]). + pub url_opener: Option, + /// The graphics adapter the window is drawn with, as the host names it. Reported by Help ▸ + /// Report an Issue… + pub graphics: Option, + /// Report an Issue… couldn't open a browser: the dialog that shows what to paste instead. + pub failed_link: Option, /// The home screen's community card was closed; it comes back next launch (not remembered). pub community_card_closed: bool, /// A document asked to open this address; the user hasn't answered yet (#90, #91). @@ -1030,6 +1042,17 @@ impl LinkOrigin { } } +/// A report address the system couldn't open, kept with the details so the user can copy them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FailedLink { + /// Where to report the issue by hand. + pub url: String, + /// The version and system details the report would have filled in. + pub details: String, + /// Why the browser didn't open. + pub error: String, +} + /// An address a document asked to open, waiting for the user to allow or cancel it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PendingLink { @@ -1279,6 +1302,9 @@ impl PdfCraftApp { control: None, bookmark_rename: None, last_opened_url: None, + url_opener: None, + graphics: None, + failed_link: None, community_card_closed: false, pending_link: None, pending_full_screen: None, @@ -2289,6 +2315,7 @@ impl PdfCraftApp { || self.unlock_prompt.is_some() || self.pending_link.is_some() || self.pending_remote_link.is_some() + || self.failed_link.is_some() || self.updates.open } @@ -2309,6 +2336,28 @@ impl PdfCraftApp { self.last_opened_url = Some(url.to_string()); } + /// Help ▸ Report an Issue…: opens GitHub's bug report form with the version and system details + /// filled in. Nothing is sent: the user reads the form and submits it. When no browser opens, + /// a dialog keeps the address and the details so they can be copied instead. + pub fn report_issue(&mut self) { + let facts = + report_issue::Facts::new(self.graphics.as_deref(), self.ctx.as_ref().map_or(1.0, |c| c.pixels_per_point()), self.active.is_some()); + let url = facts.url(); + self.last_opened_url = Some(url.clone()); + let opened = match (&self.url_opener, &self.ctx) { + (Some(open), _) => open(&url), + (None, Some(ctx)) => { + ctx.open_url(egui::OpenUrl::new_tab(&url)); + Ok(()) + } + (None, None) => Err("no window".to_owned()), + }; + if let Err(error) = opened { + log::warn!("couldn't open the report form: {error}"); + self.failed_link = Some(FailedLink { url: pdfcraft_engine::links::NEW_ISSUE.to_owned(), details: facts.details(), error }); + } + } + /// A document asks to open `url` (a link, a button's URI action or `app.launchURL`). Web and /// email addresses wait for the user to allow them; anything else is refused with a notice /// (#90, #91). While one request is waiting, further ones are dropped, so a script can't @@ -3279,6 +3328,7 @@ impl PdfCraftApp { // Don't let Escape or Return resolve unsaved work underneath one. let covered = self.pending_link.is_some() || self.pending_remote_link.is_some() + || self.failed_link.is_some() || self.pending_full_screen.is_some() || self.updates.open || self.dialog.is_some() diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 786840b650..7400a0eb7b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -219,8 +219,19 @@ const SIGN: &[&str] = &[ "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"]; +const HELP: &[&str] = &[ + "help.shortcuts", + SEP, + "help.app_page", + "help.github", + "help.website", + "help.discord", + "help.report_issue", + 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. @@ -682,6 +693,15 @@ mod tests { } } + #[test] + fn report_an_issue_is_in_the_help_menu_after_discord() { + let at = |id: &str| HELP.iter().position(|h| *h == id).unwrap_or_else(|| panic!("{id} is in Help")); + assert_eq!(at("help.report_issue"), at("help.discord") + 1); + let (app, ctx) = app(&[]); + let bar = linux(&app, &ctx); + assert!(bar.find(&CommandRef::new("help.report_issue")).is_some(), "it is in the built bar, which the native menu installs too"); + } + #[test] fn check_and_radio_items_follow_the_app() { let (mut app, ctx) = app(&["a.pdf"]); diff --git a/crates/ui-egui/src/report_issue.rs b/crates/ui-egui/src/report_issue.rs new file mode 100644 index 0000000000..bc972c34d0 --- /dev/null +++ b/crates/ui-egui/src/report_issue.rs @@ -0,0 +1,192 @@ +//! Help ▸ Report an Issue…: opens GitHub's new-issue form with the version and system details +//! filled in. The user reads every field there and submits it; PdfCraft sends nothing itself. +//! +//! The address uses the same form field ids as the other ArtCraft apps (`version`, `os`, +//! `system-info`, `document-info`), so one maintainer can read a report from any of them. What goes +//! in it is limited to facts about the app and the machine plus the open document's *type*. Never a +//! file name, a path, or anything from the document. + +use pdfcraft_engine::links::NEW_ISSUE; +use std::fmt::Write as _; + +/// The longest any one prefilled value, other than the system block, may be, in UTF-8 bytes. +const VALUE_CAP: usize = 80; +/// The longest the system block may be, in UTF-8 bytes. +const SYSTEM_INFO_CAP: usize = 400; + +/// What the report says about the app, the machine and the open document. +pub(crate) struct Facts { + pub version: String, + pub os: String, + pub system_info: String, + pub document_info: String, +} + +impl Facts { + /// `graphics` is the adapter the window is drawn with (`None` when the host didn't say). + /// `has_document` is whether a PDF is open; its name and contents never enter a report. + pub fn new(graphics: Option<&str>, pixels_per_point: f32, has_document: bool) -> Self { + let build = if cfg!(debug_assertions) { "debug" } else { "release" }; + let graphics = bounded(graphics.unwrap_or("unknown"), VALUE_CAP); + let system_info = format!( + "Build: {build}\nGraphics: {graphics}\nInterface language: {}\nDisplay scale: {pixels_per_point:.2}", + crate::i18n::current().code() + ); + Self { + version: bounded(&format!("PdfCraft {}", pdfcraft_engine::build_info::version()), VALUE_CAP).to_owned(), + os: bounded(&operating_system(), VALUE_CAP).to_owned(), + system_info: bounded(&system_info, SYSTEM_INFO_CAP).to_owned(), + document_info: format!("Document type: {}", if has_document { "pdf" } else { "none" }), + } + } + + /// The address that opens the prefilled form. + pub fn url(&self) -> String { + let fields = [ + ("version", self.version.as_str()), + ("os", self.os.as_str()), + ("system-info", self.system_info.as_str()), + ("document-info", self.document_info.as_str()), + ]; + let mut url = format!("{NEW_ISSUE}?template=bug_report.yml"); + for (id, value) in fields { + url.push('&'); + url.push_str(id); + url.push('='); + encode(value, &mut url); + } + url + } + + /// The same facts as plain text, for pasting into the form by hand. + pub fn details(&self) -> String { + format!("Version: {}\nOS: {}\n{}\n{}", self.version, self.os, self.system_info, self.document_info) + } +} + +/// `Windows x86_64`, `macOS aarch64`, or `Web` for the browser build. No OS version: that would +/// take a dependency, and the form asks for it. +fn operating_system() -> String { + if cfg!(target_arch = "wasm32") { + return "Web".to_owned(); + } + let os = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + "netbsd" => "NetBSD", + other => other, + }; + format!("{os} {}", std::env::consts::ARCH) +} + +/// `value` cut to at most `limit` bytes, at a character boundary. +fn bounded(value: &str, limit: usize) -> &str { + if value.len() <= limit { + return value; + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + value.get(..end).unwrap_or_default() +} + +/// Appends `value` percent-encoded, keeping only the RFC 3986 unreserved characters. +fn encode(value: &str, out: &mut String) { + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(char::from(byte)); + } else { + let _ = write!(out, "%{byte:02X}"); // writing to a String cannot fail + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(url: &str) -> Vec<(&str, &str)> { + let (_, query) = url.split_once('?').expect("a query"); + query.split('&').map(|p| p.split_once('=').expect("name=value")).collect() + } + + #[test] + fn the_address_is_the_new_issue_form_with_the_four_fields() { + let url = Facts::new(Some("Test GPU (Vulkan, DiscreteGpu)"), 1.5, true).url(); + assert!(url.starts_with("https://github.com/storytold/pdfcraft/issues/new?"), "{url}"); + let names: Vec<_> = params(&url).iter().map(|(n, _)| *n).collect(); + assert_eq!(names, ["template", "version", "os", "system-info", "document-info"]); + assert!(url.contains("template=bug_report.yml&")); + assert!(url.ends_with("&document-info=Document%20type%3A%20pdf"), "{url}"); + } + + #[test] + fn the_system_block_has_a_line_per_fact_in_a_fixed_order() { + let facts = Facts::new(Some("Test GPU (Vulkan, DiscreteGpu)"), 1.5, false); + let keys: Vec<_> = facts.system_info.lines().filter_map(|l| l.split_once(": ").map(|(k, _)| k)).collect(); + assert_eq!(keys, ["Build", "Graphics", "Interface language", "Display scale"]); + assert!(facts.system_info.contains("Graphics: Test GPU (Vulkan, DiscreteGpu)")); + assert!(facts.system_info.contains("Display scale: 1.50")); + assert_eq!(facts.document_info, "Document type: none"); + assert_eq!(Facts::new(None, 1.0, false).system_info.lines().nth(1), Some("Graphics: unknown")); + } + + #[test] + fn hostile_characters_stay_inside_their_own_parameter() { + let hostile = "a&b=c?d#e%f\nline\u{4e2d}\u{6587} \"q\" "; + let mut facts = Facts::new(Some(hostile), 1.0, true); + facts.version = hostile.to_owned(); + facts.os = hostile.to_owned(); + let url = facts.url(); + let query = url.split_once('?').expect("a query").1; + assert_eq!(query.matches('&').count(), 4, "{url}"); + assert_eq!(query.matches('=').count(), 5, "{url}"); + assert!(!url.contains('#') && !url.contains(' ') && !url.contains('\n') && !url.contains('<') && !url.contains('"'), "{url}"); + // Only unreserved characters and percent escapes survive in a value. + for (_, value) in params(&url).into_iter().skip(1) { + assert!(value.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.~%".contains(&b)), "{value}"); + } + } + + fn huge_version() -> String { + "\u{4e2d}".repeat(10_000) + } + + #[test] + fn long_values_are_cut_on_a_character_boundary_and_the_address_stays_short() { + let huge = "\u{4e2d}".repeat(10_000); + let facts = Facts::new(Some(&huge), 1.0, true); + assert!(facts.version.len() <= VALUE_CAP && facts.os.len() <= VALUE_CAP); + assert!(facts.system_info.len() <= SYSTEM_INFO_CAP); + // The cut graphics name is still whole characters. + assert!(facts.system_info.contains('\u{4e2d}')); + let mut facts = facts; + facts.version = bounded(&huge_version(), VALUE_CAP).to_owned(); + facts.os = facts.version.clone(); + let url = facts.url(); + assert!(url.len() < 2048, "{} bytes", url.len()); + } + + #[test] + fn bounded_never_splits_a_character() { + assert_eq!(bounded("abc", 5), "abc"); + assert_eq!(bounded("abc", 2), "ab"); + assert_eq!(bounded("\u{e9}\u{e9}", 3), "\u{e9}"); + assert_eq!(bounded("\u{4e2d}", 2), ""); + assert_eq!(bounded("abc", 0), ""); + } + + #[test] + fn the_details_text_repeats_what_the_form_gets() { + let facts = Facts::new(Some("GPU"), 2.0, true); + let text = facts.details(); + for part in [&facts.version, &facts.os, &facts.system_info, &facts.document_info] { + assert!(text.contains(part.as_str()), "{text}"); + } + assert!(text.starts_with("Version: PdfCraft ")); + } +} diff --git a/crates/ui-egui/tests/it/links.rs b/crates/ui-egui/tests/it/links.rs index f08e9d4356..59d4c838a3 100644 --- a/crates/ui-egui/tests/it/links.rs +++ b/crates/ui-egui/tests/it/links.rs @@ -113,3 +113,110 @@ fn about_dialog_has_contributors_and_models_tabs() { h.run_steps(4); assert!(h.query_all_by_label("Anthropic").count() >= 1); } + +/// The parameter names of a Report an Issue… address, in order. +fn report_param_names(url: &str) -> Vec<&str> { + url.split_once('?').expect("a query").1.split('&').map(|p| p.split_once('=').expect("name=value").0).collect() +} + +/// The text copied by the last frames, if any (a click copies on the frame after it). +fn copied(h: &mut Harness<'static, PdfCraftApp>) -> Option { + for _ in 0..4 { + h.run_steps(1); + let found = h.output().platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }); + if found.is_some() { + return found; + } + } + None +} + +#[test] +fn report_an_issue_opens_the_prefilled_form_through_the_host_opener() { + let opened = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let seen = opened.clone(); + let mut h = harness(move |app| { + app.url_opener = Some(Box::new(move |url| { + seen.borrow_mut().push(url.to_owned()); + Ok(()) + })) + }); + assert!(h.state_mut().execute("help.report_issue")); + h.run_steps(2); + let opened = opened.borrow(); + assert_eq!(opened.len(), 1, "one address is opened, once"); + let url = &opened[0]; + assert!(url.starts_with(&format!("{}?template=bug_report.yml&", links::NEW_ISSUE)), "{url}"); + assert_eq!(report_param_names(url), ["template", "version", "os", "system-info", "document-info"]); + assert_eq!(h.state().last_opened_url.as_deref(), Some(url.as_str())); + assert!(h.state().failed_link.is_none(), "nothing extra shows when the browser opens"); + assert!(h.query_by_label("Couldn't open your web browser").is_none()); + // No document is open. + assert!(url.ends_with("document-info=Document%20type%3A%20none"), "{url}"); +} + +#[test] +fn report_an_issue_without_a_host_opener_asks_egui_to_open_it() { + let mut h = harness(|_| {}); + assert!(h.state_mut().execute("help.report_issue")); + let url = h.state().last_opened_url.clone().expect("opened"); + assert!(url.starts_with(links::NEW_ISSUE), "{url}"); + assert!(h.state().failed_link.is_none()); +} + +#[test] +fn report_an_issue_never_includes_the_document_name_or_path() { + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let mut h = harness(move |app| { + app.open_bytes("Secret Budget.pdf", Some("/home/ann/Secret Budget.pdf".to_owned()), pdf.to_vec()).unwrap(); + }); + h.state_mut().execute("help.report_issue"); + let url = h.state().last_opened_url.clone().expect("opened"); + assert!(url.ends_with("document-info=Document%20type%3A%20pdf"), "{url}"); + let decoded = url.replace("%20", " "); + for private in ["Secret", "Budget", "ann", "/home"] { + assert!(!decoded.contains(private), "{private} is in {url}"); + } +} + +#[test] +fn a_browser_that_does_not_open_shows_the_address_and_details_to_copy() { + // With a document open, so the home screen's own Close button is not on screen. + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let mut h = harness(move |app| { + app.open_bytes("one.pdf", None, pdf.to_vec()).unwrap(); + app.url_opener = Some(Box::new(|_| Err("no browser is installed".to_owned()))); + }); + h.state_mut().execute("help.report_issue"); + h.run_steps(3); + h.get_by_label("Couldn't open your web browser"); + h.get_by_label("no browser is installed"); + // Read-only fields, named by their labels for assistive technology. + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Address"); + h.get_by_role_and_label(egui::accesskit::Role::MultilineTextInput, "Details"); + let failed = h.state().failed_link.clone().expect("the dialog's data"); + assert_eq!(failed.url, links::NEW_ISSUE); + assert!(failed.details.starts_with("Version: PdfCraft ") && failed.details.contains("Document type: pdf"), "{}", failed.details); + h.get_by_label("Copy Details").click(); + let text = copied(&mut h).expect("Copy Details copies"); + assert_eq!(text, format!("{}\n\n{}", failed.details, links::NEW_ISSUE)); + assert!(h.state().failed_link.is_some(), "copying keeps the dialog open"); + h.get_by_label("Close").click(); + h.run_steps(3); + assert!(h.state().failed_link.is_none()); + assert!(h.query_by_label("Couldn't open your web browser").is_none()); +} + +#[test] +fn escape_closes_the_failed_browser_dialog() { + let mut h = harness(|app| app.url_opener = Some(Box::new(|_| Err("no browser".to_owned())))); + h.state_mut().execute("help.report_issue"); + h.run_steps(3); + h.get_by_label("Couldn't open your web browser"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().failed_link.is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 703f1e9d8b..ae2c649dc1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-26 · **Change:** minor (Help ▸ Report an Issue… opens a prefilled GitHub issue form, #1619; revision-history note only, no counts changed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -81,6 +81,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Help ▸ Report an Issue… (also in the command palette and the macOS menu bar) opens GitHub's bug report form with the version, OS, graphics adapter, interface language and "Document type: pdf" filled in; nothing is sent, and no file name or content is included. If no browser opens, a dialog keeps the address and the details to copy (#1619). Not a parity row: Acrobat's Help has no such item, so no counts changed. | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | From 7f0385f66e95727361a6d4874637dcb2b4d38b4c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:12:29 -0500 Subject: [PATCH 03/38] fix(ui): the os parameter carries the OS alone --- crates/engine/src/links.rs | 7 ++++--- crates/engine/src/links/tests.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 99dee60d88..9e2e3f8f21 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -37,14 +37,15 @@ pub const LINKS: &[Link] = &[ pub fn report_issue_url(version: &str, os: &str, document_ext: Option<&str>) -> String { let short = |s: &str| s.chars().filter(|c| !c.is_control()).take(16).collect::(); let ext: String = document_ext.unwrap_or("none").chars().filter(char::is_ascii_alphanumeric).take(8).collect(); - let (version, environment) = (short(version), format!("{}; document: {ext}", short(os))); + let (version, os) = (short(version), short(os)); + let environment = format!("{os}; document: {ext}"); let encode = |s: &str| { let keep = |b: &u8| b.is_ascii_alphanumeric() || b"-_.~".contains(b); s.bytes().map(|b| if keep(&b) { char::from(b).to_string() } else { format!("%{b:02X}") }).collect::() }; let body = format!("PdfCraft {version}\n{environment}"); - let (v, e, b) = (encode(&version), encode(&environment), encode(&body)); - format!("{GITHUB}/issues/new?template=bug_report.yml&version={v}&environment={e}&os={e}&body={b}") + let (v, e, o, b) = (encode(&version), encode(&environment), encode(&os), encode(&body)); + format!("{GITHUB}/issues/new?template=bug_report.yml&version={v}&environment={e}&os={o}&body={b}") } pub fn for_command(id: &str) -> Option<&'static Link> { diff --git a/crates/engine/src/links/tests.rs b/crates/engine/src/links/tests.rs index f06dd01eae..f429425b1e 100644 --- a/crates/engine/src/links/tests.rs +++ b/crates/engine/src/links/tests.rs @@ -330,7 +330,7 @@ fn hosts_are_shown_as_the_browser_receives_them() { fn the_report_address_carries_version_and_system_only_and_stays_short() { use super::report_issue_url as url; let u = url("0.7.1", "linux x86_64", Some("pdf")); - assert!(u.starts_with("https://github.com/storytold/pdfcraft/issues/new?template=bug_report.yml&version=0.7.1&environment=linux%20x86_64%3B%20document%3A%20pdf&os="), "{u}"); + assert!(u.starts_with("https://github.com/storytold/pdfcraft/issues/new?template=bug_report.yml&version=0.7.1&environment=linux%20x86_64%3B%20document%3A%20pdf&os=linux%20x86_64&body="), "{u}"); // Hostile text and an unusual extension can't add a parameter, leak, or grow the address. let u = url(&"\u{1F600}".repeat(99), &"&#=".repeat(99), Some("x.y/Z")); assert_eq!(u.matches('?').count() + u.matches('#').count(), 1, "{u}"); From 7d1ed99f51b25c08107be4b2495a5e6a22c56058 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:50 +0000 Subject: [PATCH 04/38] Refactor annot for strict Clippy checks --- crates/annot/src/appearance.rs | 1092 +++++++++++++----------- crates/annot/src/appearance/symbols.rs | 2 +- crates/annot/src/create.rs | 90 +- crates/annot/src/draw.rs | 2 +- crates/annot/src/info.rs | 2 +- crates/annot/src/lookup.rs | 2 +- crates/annot/src/modify.rs | 2 +- crates/annot/src/types.rs | 2 +- 8 files changed, 654 insertions(+), 540 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 508d2bee45..abc746c0dd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -373,16 +373,60 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) } +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -390,520 +434,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - // Content after `c` that isn't UTF-8 (WinAnsi text). - let mut tail: Vec = Vec::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, + } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + // PdfCraft measurement captions are kept separate from the comment's free-form text. + // A restyle regenerates the path and its value together. + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { + let value = value.to_text(); + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { + let size = 10.0; + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); + out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); + // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. + out.extend(literal(&win_ansi(&value))); + out.extend_from_slice(b" Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; + } + Some(form(context.rect, &out, drawing.resources)) +} + +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); } - let fill = interior_color(d, Some(col)).unwrap_or(col); - let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); - let [p, q] = layout.line; - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { - c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); } - c.push_str("[] 0 d\n"); - for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { - if !line_end(&mut c, end, tip, from, w) { - return None; + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); } - } - if let Some((text, m)) = layout.caption { - // The caption is black, in the standard font the measurement label uses. - tail.extend( - format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) - .bytes(), - ); - // WinAnsi bytes go into the stream as-is, not through UTF-8. - tail.extend(literal(&win_ansi(&text))); - tail.extend_from_slice(b" Tj ET Q\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + c.push_str("S\n"); } } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { + } + Some(()) +} + +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); + } + Some(()) +} + +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); + } + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); + } + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { + return None; + } + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; + } + } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { return None; } + Some(names) } } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let (caret, pilcrow) = caret_layout(d, rect); - let [x0, y0, x1, y1] = caret; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - if let Some((x, y, size)) = pilcrow { - // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. - // 0xB6 is the pilcrow in WinAnsiEncoding. - let mut out = c.into_bytes(); - out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); - out.push(0xB6); - out.extend_from_slice(b") Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - return Some(form(rect, &out, res)); - } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); + } + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - // `/BS` gives an ink stroke its width and dash pattern (Table 182). - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } + if closed { + c.push_str("h\n"); } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - // A mark or stamp placed on a turned page is drawn upright in its displayed box, then - // mapped into `/Rect` the way the page maps display space into user space. Viewers - // fit that box to `/Rect`; the page rotation then shows it the right way up. - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let turned = |mut stream: Stream| { - if turn != 0 { - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - stream - }; - let [x0, y0, x1, y1] = draw; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), - b"PCDot" => { - let r = w.min(h) * 0.3; - c.push_str(&rg(col)); - c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); - c.push_str("f\n"); - } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let sign_here = kind.group() == crate::StampGroup::SignHere; - return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); - } - } - if turn != 0 { - return Some(turned(form(draw, c.as_bytes(), res))); - } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; } - b"FreeText" => { - let (text_color, size) = parse_da(d); - let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; - // A callout: the leader line (arrowhead at its first point), and the text box inside - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { - return None; - } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - return None; - } - } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); } - let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); - let pad = 2.0 + bw; - let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; + if v.len() >= 6 { + c.push_str("h\n"); } - out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), + n(y0 + h / 2.0), + n(x1), + n(y0 + h / 2.0) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; } - return Some(stream); + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); } - _ => return None, } - // PdfCraft measurement captions are kept separate from the comment's free-form text. - // A restyle regenerates the path and its value together. + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + let bg = stroke; + let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // A callout: the leader line (arrowhead at its first point), and the text box inside + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } + } + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; + c.push_str(&format!( + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(text_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) + )); + } + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let pad = 2.0 + bw; + let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); + let q = d.int(b"Q").unwrap_or(0); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); let mut out = c.into_bytes(); - out.extend(tail); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { - let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { - let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); - // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. - out.extend(literal(&win_ansi(&value))); - out.extend_from_slice(b" Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); + out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; + } + out.extend_from_slice(b"ET\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); } - Some(form(rect, &out, res)) + Some(stream) +} + +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index d49224cd9f..3b33662524 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; /// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp /// another program wrote without an appearance. Names are matched byte for byte, as PDF names diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index bedd1c6f15..a6db5b940b 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,8 +1,10 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; use super::modify::{n, rich_text}; -use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style}; -use super::*; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; pub(crate) fn num_array(v: &[f64]) -> Object { Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) @@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { let page = page_ref(doc, new.page)?; let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } + validate_style(style)?; let rect = rect_for(&new.shape, style)?; let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); let opacity = style.opacity.clamp(0.0, 1.0); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } + set_non_opaque_style(&mut d, opacity); let border = |d: &mut Dict| { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); @@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), Shape::Rectangle { .. } | Shape::Oval { .. } => { d.set(b"C".to_vec(), rgb(style.color)); if let Some(f) = style.fill { @@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let mut list = annots(doc, page); let index = list.len(); list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } + add_popup(doc, r, popup, &mut list)?; set_annots(doc, page, list)?; Ok(index) } +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { + if opacity < 1.0 { + dict.set(b"CA".to_vec(), Object::Real(opacity)); + } +} + +fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { + let Some(rect) = rect else { return Ok(()) }; + let mut popup = Dict::new(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + /// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. /// The picture may be an image or a PDF page. The normal appearance's transformed bounding /// box is fitted to `/Rect` by PDF viewers. diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 1898454415..5e698332da 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -2,7 +2,7 @@ use super::create::{finite, num_array}; use super::lookup::{annot_dict, annots, page_refs}; use super::modify::refuse_foreign_text; use super::types::NOTE_SIZE; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; /// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its /// text (a restyle, a new size or colour). diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index bea14d9ee2..b56e0de20d 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -3,7 +3,7 @@ use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; use super::modify::{fillable, touch, unlocked}; use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; // ── reading ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 00ce23c37f..c81ec78ef4 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,6 +1,6 @@ use super::info::text_value; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; // ── pages and /Annots ─────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 992b3d0c51..7872a6c817 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -7,7 +7,7 @@ use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, s use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, }; -use super::*; +use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; /// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would /// replace those characters with `?`, so the edit is refused before the dictionary changes. diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 632e990fea..40ed744df3 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -1,5 +1,5 @@ use super::create::normalize; -use super::*; +use super::{ObjRef, appearance}; pub type Rgb = [f64; 3]; From 9d97546081c9e83acdd81b46c801ccfa8ff86cdd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 05/38] Refactor automation for strict Clippy checks --- crates/automation/src/a11y/documents.rs | 2 +- crates/automation/src/a11y/form_tools.rs | 2 +- crates/automation/src/a11y/ocr.rs | 2 +- crates/automation/src/assemble.rs | 2 +- crates/automation/src/dispatch.rs | 2 +- crates/automation/src/documents.rs | 2 +- crates/automation/src/json.rs | 2 +- crates/automation/src/pages.rs | 2 +- crates/automation/src/paths.rs | 2 +- crates/automation/tests/it/automation.rs | 192 +++++++++++++++-------- 10 files changed, 137 insertions(+), 73 deletions(-) diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index f623ef714e..f2c9f6b77d 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5fb14f136e..7272a8d669 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic}; /// A script's request to the viewer as the JSON that running a script reports; a side panel /// goes by its fixed id. diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index afc671a8c7..82e521ddd5 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; impl Automation { /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 40ae65ea87..deb2efa18a 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based, summary}; use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; -use super::*; +use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json}; impl Automation { pub(crate) fn doc_create(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 26e1f95365..9ccd18c558 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; use super::paths::{read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools}; /// One piece of a tool's result. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 3ce312f395..476c451028 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, nest_rows, security, summary}; use super::paths::{child, read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c8710aa509..6f0884226a 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -1,5 +1,5 @@ use super::error::{Result, ToolError}; -use super::*; +use super::{Document, Value, comments, json}; /// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at /// most 14 400 across). diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index d9ea9e5c4f..647a72e1d4 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -1,7 +1,7 @@ use super::dispatch::Content; use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based}; -use super::*; +use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; /// Default and maximum resolution for `page_render`. pub(crate) const DEFAULT_DPI: f64 = 96.0; diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs index fb514df69f..e13d0a1452 100644 --- a/crates/automation/src/paths.rs +++ b/crates/automation/src/paths.rs @@ -1,5 +1,5 @@ use super::error::{Result, failed}; -use super::*; +use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; impl Automation { // ---- paths ------------------------------------------------------------------------------- diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..78038d8ebd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2030,21 +2030,33 @@ fn comments_through_tools() { let dir = workdir("comments"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = add_comment_examples(&mut a, doc); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = verify_comment_listing(&list, &id); + edit_rectangle_comment(&mut a, doc, &rect); + edit_text_comment(&mut a, doc, &list); + verify_comment_errors(&mut a, doc, &id); + save_and_reopen_comments(&dir, &mut a, doc); +} + +fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. - let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); + let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); assert_eq!(hl["comment"]["lines"], 1); let id = hl["comment"]["id"].as_str().unwrap().to_string(); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); - ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); - ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); + ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); + ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); + ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + id +} - let list = ok(&mut a, "comment_list", json!({ "doc": doc })); +fn verify_comment_listing(list: &Value, id: &str) -> Value { assert_eq!(list["count"], 6, "{list}"); - let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone(); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check"))); assert_eq!(c["status"], "Accepted"); assert_eq!(c["replies"].as_array().unwrap().len(), 1); @@ -2057,33 +2069,38 @@ fn comments_through_tools() { assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0])); let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone(); assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point"); + rect +} +fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) { // Edit by page + index; several changes are one undo step. - let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap()); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap()); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone(); assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved"))); assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0])); - let undo = ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undo = ok(a, "edit_undo", json!({ "doc": doc })); assert_eq!(undo["undone"], "Edit comment"); - ok(&mut a, "edit_redo", json!({ "doc": doc })); + ok(a, "edit_redo", json!({ "doc": doc })); // Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none. - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_)))); - let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); + let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); assert!(matches!( a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })), Err(ToolError::Failed(_)) )); +} +fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) { // Editing a text box re-fits its rectangle to the new text: the wrap width and top edge // stay, the height follows the wrapped lines. - let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); + let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let long = "the quick brown fox jumps over the lazy dog ".repeat(3); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let r: Vec = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}"); @@ -2092,9 +2109,11 @@ fn comments_through_tools() { // The rendered page shows the rectangle's border. let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); assert!(matches!(png[0], Content::Png { .. })); +} - ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id })); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5); +fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) { + ok(a, "comment_delete", json!({ "doc": doc, "id": id })); + assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5); assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_)))); @@ -2103,10 +2122,11 @@ fn comments_through_tools() { Err(ToolError::InvalidArgs(_)) )); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_)))); +} - // Comments survive a save and reopen. - ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); - let mut b = auto(&dir); +fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) { + ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); + let mut b = auto(dir); let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } @@ -2809,6 +2829,12 @@ fn accessibility_check_report_and_fixes_through_tools() { let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap(); assert!(manual_html.contains("

Needs manual check: 1

")); assert!(!manual_html.contains("no problems")); +} + +#[test] +fn accessibility_figure_tags_and_alternative_text_through_tools() { + let dir = workdir("a11y-figures"); + let mut a = auto(&dir); // Figures: list, describe, mark decorative. std::fs::write( dir.join("figures.pdf"), @@ -5260,6 +5286,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { + let root = local_gotor_fixtures(); + let mut a = auto(&root); + assert_relative_gotor_destination(&mut a); + assert_absolute_gotor_policy(&mut a); + assert_unapplied_gotor_destinations(&mut a); + assert_encrypted_gotor_passwords(&mut a); + assert_unsupported_gotor_targets(&mut a); + assert_gotor_root_boundaries(&mut a); + assert_url_links_cannot_open_as_gotor(&mut a); +} + +fn local_gotor_fixtures() -> PathBuf { let (base, root) = sandbox("gotor-open"); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write(root.join("target.pdf"), fixture(3)).unwrap(); @@ -5277,12 +5315,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap(); link_dir(&root, "outside-link", &base.join("outside")); std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap(); + root +} - let mut a = auto(&root); - let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); - let listed = ok(&mut a, "link_list", json!({ "doc": source })); +fn assert_relative_gotor_destination(a: &mut Automation) { + let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(a, "link_list", json!({ "doc": source })); assert_eq!(listed["links"][0]["other"], "GoToR"); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination_applied"], true); assert_eq!(opened["destination"]["page"], 3); @@ -5290,51 +5330,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_eq!(opened["document"]["pages"], 3); let target_id = opened["document"]["doc"].as_u64().unwrap(); assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3); +} - let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); +fn assert_absolute_gotor_policy(a: &mut Automation) { + let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); let before = a.session().docs().len(); for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] { - assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); + assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); assert_eq!(a.session().docs().len(), before); } - let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); + let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination"]["page"], 2); +} +fn assert_unapplied_gotor_destinations(a: &mut Automation) { for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["destination_applied"], false); assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}"); } +} - let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); +fn assert_encrypted_gotor_passwords(a: &mut Automation) { + let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); + let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); assert_eq!(opened["document"]["encrypted"], true); +} +fn assert_unsupported_gotor_targets(a: &mut Automation) { for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}"); } +} +fn assert_gotor_root_boundaries(a: &mut Automation) { for source_path in ["sub/escape.pdf", "symlink.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened"); } +} - let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); +fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) { + let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); assert!( - matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) + matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) ); } @@ -5887,8 +5939,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); let mut a = auto(&dir); + create_signing_identity(&mut a); + let doc = sign_document_and_verify_trust(&mut a, &dir); + verify_signature_revisions_and_bundle(&mut a, doc, &dir); +} + +fn create_signing_identity(a: &mut Automation) { let id = ok( - &mut a, + a, "sign_id_create", json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); @@ -5897,13 +5955,15 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); // Eight characters at least (#1847): seven is refused. assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); +} - let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); +fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 { + let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0); // A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere). assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_)))); - let store = ok(&mut a, "sign_windows_ids", json!({})); + let store = ok(a, "sign_windows_ids", json!({})); let ids = store["ids"].as_array().unwrap(); assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); @@ -5917,7 +5977,7 @@ fn digital_ids_signing_and_validation_through_tools() { Err(ToolError::InvalidArgs(_)) )); let r = ok( - &mut a, + a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); @@ -5929,33 +5989,37 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); + let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); - let list = ok(&mut a, "sign_list", json!({ "doc": doc })); + let list = ok(a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); - let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]; + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); + let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]; assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed"))); + doc +} + +fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) { // A full rewrite is refused for a signed document. assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_)))); - ok(&mut a, "doc_save", json!({ "doc": doc })); + ok(a, "doc_save", json!({ "doc": doc })); // Three revisions: the original, the signature, the comment; the middle one is signed. - let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); + let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); assert_eq!(revs.len(), 3, "{revs:?}"); assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}"); - let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1); + let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); + assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1); assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); - assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. use pdfcraft_engine::sign::{cms, der, pkcs12}; let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); - assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); + assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. From 84c873d2e1af5348455f226cd770708d725a31c7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 06/38] Refactor cos for strict Clippy checks --- crates/cos/src/document/objects.rs | 2 +- crates/cos/src/document/open.rs | 2 +- crates/cos/src/document/reader.rs | 2 +- crates/cos/src/document/state.rs | 2 +- crates/cos/src/document/xref.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ff729f1fb..8744a3ea3e 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,7 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::*; +use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared}; impl Document { pub fn bytes(&self) -> &Arc> { diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index c90d5c0906..d41d564e33 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,7 +1,7 @@ use super::reader::{find, generated_id}; use super::state::Credentials; use super::xref::XrefIndex; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index ead09c36e5..66129c1423 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -3,7 +3,7 @@ use super::state::SECTION_READS; use super::xref::{ MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData, }; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared}; impl Document { #[allow(clippy::type_complexity)] diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 30f193e4e6..b4323f982e 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -1,5 +1,5 @@ use super::xref::XrefEntry; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs index 96a782c1a9..02390ff7da 100644 --- a/crates/cos/src/document/xref.rs +++ b/crates/cos/src/document/xref.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Lexer}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum XrefEntry { From de73c35e174aa84284a4c995ab2341c0a00e190f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 07/38] Refactor edit for strict Clippy checks --- crates/edit/src/added/draw.rs | 5 +- crates/edit/src/added/layout.rs | 5 +- crates/edit/src/added/store.rs | 2 +- crates/edit/src/added/types.rs | 2 +- crates/edit/src/apply.rs | 2 +- crates/edit/src/marks.rs | 2 +- crates/edit/src/place.rs | 2 +- crates/edit/src/tests.rs | 179 ++-- crates/edit/src/text/blocks.rs | 2 +- crates/edit/src/text/editor.rs | 2 +- crates/edit/src/text/fallback.rs | 4 +- crates/edit/src/text/interp.rs | 532 ++++++------ crates/edit/src/text/lines.rs | 2 +- crates/edit/src/text/rewrite.rs | 936 ++++++++++++-------- crates/edit/src/text_regions.rs | 7 +- crates/edit/src/text_regions/grouping.rs | 2 +- crates/edit/src/text_regions/styled.rs | 1010 +++++++++++++--------- crates/edit/src/text_session.rs | 5 +- 18 files changed, 1627 insertions(+), 1074 deletions(-) diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 0fc7aa510d..61297bcf24 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -2,7 +2,10 @@ use super::layout::{ Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped, }; use super::types::{AddedText, Align, Family}; -use super::*; +use super::{ + Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, + literal, matrix_number, n, shaped_glyph, win_ansi, +}; fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 0e971b92c7..67d80be405 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -1,5 +1,8 @@ use super::types::{AddedImage, AddedText}; -use super::*; +use super::{ + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, + shape_thai, +}; pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index b89d429000..5ce5e15d19 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -1,7 +1,7 @@ use super::draw::{draw, params}; use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; -use super::*; +use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged}; fn parse(doc: &Document, d: &Dict) -> Option { let r = nums(doc, d.get(b"Rect")); diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs index 0a9f92f92c..28b61d5869 100644 --- a/crates/edit/src/added/types.rs +++ b/crates/edit/src/added/types.rs @@ -1,5 +1,5 @@ use super::layout::norm; -use super::*; +use super::{Content, MarkKind, ObjRef, Std14}; pub(super) const TAG: &str = "Added"; diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 1461dc78ef..427f51f943 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -5,7 +5,7 @@ use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, }; -use super::*; +use super::{Document, EditError, Object, added, helvetica_width}; /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on /// those pages are removed first (Acrobat's Replace Existing). diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 507e2afa8e..00c53ec27a 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EditError, Object, Rgb, added}; /// The kinds of page marks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index b6d50a6462..2f2c24c862 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -1,5 +1,5 @@ use super::marks::{MarkKind, matrix_number, n}; -use super::*; +use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; pub(crate) fn page_list(doc: &Document) -> Vec { pdfcraft_model::pages(doc) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0f8adc6514..7c78d31fc2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6664,93 +6664,136 @@ fn actual_text_pairs_past_the_marked_content_cap() { } } -/// #2992: the colour a page is painted with before anything else is its background, for exports -/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes -/// behind; a page that paints anything else first, a fill that doesn't cover the page, a -/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. -#[test] -fn a_page_painted_first_with_a_full_page_fill_has_that_background() { - let mut doc = fixture(); - assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); - add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); - let doc = reopen(&doc); - let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); - assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); - assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); - assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); - assert!(page_background(&doc, 9).is_err()); - - let page = |content: &str, resources: &str| { - let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); - page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() - }; - let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; - assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); - assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); - assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); - assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); +fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref_offset = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap() +} + +fn assert_page_background_fill_cases(gs: &str) { + assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "one of the rectangles covers it" + ); // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the // other way under nonzero. - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); - assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); - assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); - assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); - assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); - assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); - assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); - assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); - assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), + Some([1.0, 0.0, 0.0]), + "nonzero, same winding: no hole" + ); + assert_eq!( + page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), + None, + "reversed by a mirroring cm" + ); + assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), + None, + "a page fill with a hole cut in it" + ); +} + +fn assert_page_background_transform_and_clip_cases() { // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop // box's corners are inside it; its bounding box covering the crop box isn't enough. This one // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but // 354 along the diagonals, short of the corners. One reaching 707 covers them. let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; - assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); - assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), + None, + "a turned fill short of the corners" + ); + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned fill over them" + ); // The same for a clip. - assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); assert_eq!( - page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + None, + "a turned clip" + ); + assert_eq!( + page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned clip over the corners" ); // Clips add up: two that each cover the page leave it covered, one that doesn't among them // doesn't. - assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); - assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "two covering clips" + ); + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + None, + "the second clip doesn't cover" + ); // Past 64 clipping rectangles the clip counts as unknown. let clips = "0 0 600 800 re W n ".repeat(65); - assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); // A rectangle drawn the other way round (negative width) covers the page all the same. - assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the // edge of that included. - assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); - assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); - assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); + assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +/// #2992: page background detection follows the first full-page fill under text and ignores +/// partial, translucent, clipped, or unsupported fills. +#[test] +fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; + let mut doc = fixture(); + assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); + add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); + let doc = reopen(&doc); + let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); + assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); + assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); + assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); + assert!(page_background(&doc, 9).is_err()); + + assert_page_background_fill_cases(gs); + assert_page_background_transform_and_clip_cases(); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 8d3d224da0..615e7699a2 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,7 +1,7 @@ use super::interp::{Carry, interpret}; use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; -use super::*; +use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions}; /// An editable text region: adjacent runs on visual rows, followed by aligned rows. /// A region may contain several source fonts, sizes, colours and weights. diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index b2bd2b9f97..f7aa1f47bd 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::*; +use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; /// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and /// are never installed, copied into assets, or used as a font for another document. diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 0c19a6680a..b8f45a221f 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,6 +1,8 @@ use super::interp::Ts; use super::lines::page_dict; -use super::*; +use super::{ + CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, +}; /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. pub(super) type Encoder = Box Option)>>>; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index ca0d63cef4..e4248aedea 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -1,6 +1,6 @@ use super::fallback::is_type3_fallback; use super::rewrite::vertical_advance; -use super::*; +use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc}; /// Text and graphics state captured for rewriting. #[derive(Clone, Debug, PartialEq)] @@ -472,6 +472,280 @@ type CachedFont = (Rc, Option, Result, String>); /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); +#[derive(Default)] +struct MarkedContent { + regions: Vec>, + layers: Vec>>, + actual_text: Vec>, +} + +impl MarkedContent { + fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) { + // Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and + // inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + self.layers + .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value { + Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()), + _ => None, + }); + self.actual_text.push(logical.map(|text| (shown_count, index, text))); + } + + fn end(&mut self, index: usize, shown: &mut Vec) { + self.regions.pop(); + self.layers.pop(); + if let Some(Some((start, open, logical))) = self.actual_text.pop() { + let tail = shown.split_off(start.min(shown.len())); + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for item in items { + first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); + first.extra_ops.push(item.op); + first.extra_ops.extend(item.extra_ops); + first.rect = [ + first.rect[0].min(item.rect[0]), + first.rect[1].min(item.rect[1]), + first.rect[2].max(item.rect[2]), + first.rect[3].max(item.rect[3]), + ]; + first.frame = union(first.frame, item.frame); + first.ink.extend(item.ink); + } + first.extra_ops.extend([open, index]); + // Only right-to-left ActualText is already in typing order and must stay out of + // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.end_y = first.baseline; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + shown.push(first); + } + } + } +} + +struct TextRunState<'a> { + ts: &'a mut Ts, + tm: &'a mut Matrix, + tlm: &'a mut Matrix, + at_bt: &'a (usize, Ts), + bt: usize, + marked: &'a MarkedContent, + carry: &'a mut Carry, + out: &'a mut Vec, +} + +fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { + let ts = &mut *state.ts; + let tm = &mut *state.tm; + let tlm = &mut *state.tlm; + let at_bt = state.at_bt; + let bt = state.bt; + let marked = state.marked; + let carry = &mut *state.carry; + let out = &mut *state.out; + if matches!(op.op.as_slice(), b"'" | b"\"") { + if op.is("\"") { + ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); + ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); + } + *tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm); + *tm = *tlm; + } + let Some((name, m)) = ts.font.clone() else { return Ok(()) }; + // Pieces: strings, and TJ number adjustments. + let pieces: Vec = match op.op.as_slice() { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569) -- not the first, which a reader counting operands + // from the front of a surplus list would otherwise take. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Tc is already included in the pen advance; TJ may encode tracking instead. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; + let trm0 = tm.then(&ts.ctm); + let start = trm0.apply(0.0, ts.rise); + let mut text = String::new(); + let mut decodable = true; + let mut x_text = 0.0; + // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred + // on it horizontally; `half` is half the widest glyph. + let vertical = ts.vertical; + let (mut y_text, mut half) = (0.0, 0.0_f64); + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; + let mut glyph_count = 0_usize; + for p in &pieces { + match p { + Object::String(s) => { + for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); + let shown = m.text_of(code); + let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { + Some(t) => { + carry.text_left = carry + .text_left + .checked_sub(t.len()) + .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + text.push_str(&t); + } + None => decodable = false, + } + let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + if let Some(w1) = vertical { + half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); + if !blank { + let ink_end = y_text + w1 * ts.size; + let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + y_text += w1 * ts.size + spacing; + continue; + } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + x_text += w * ts.scale; + } + } + other => { + if let Some(n) = other.as_f64() { + if vertical.is_some() { + // Down by n/1000 em, not scaled by `Tz`. + let dy = -n / 1000.0 * ts.size; + if -dy > ts.size * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + if dy.abs() > ts.size.abs() * 0.2 { + in_word = false; + } + y_text += dy; + continue; + } + let dx = -n / 1000.0 * ts.size * ts.scale; + // A large gap inside TJ reads as a space. + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } + x_text += dx; + } + } + } + } + *tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm); + let end = tm.then(&ts.ctm).apply(0.0, ts.rise); + // The box from `a` to `b` along the run: from descent to ascent across it, or for + // vertical text, the widest glyph's width centred on the pen. + let span = |a: f64, b: f64| match vertical { + Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)], + None => [ + trm0.apply(a, ts.rise + m.descent * ts.size), + trm0.apply(b, ts.rise + m.descent * ts.size), + trm0.apply(a, ts.rise + m.ascent * ts.size), + trm0.apply(b, ts.rise + m.ascent * ts.size), + ], + }; + let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); + let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); + let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; + // A numeric-only TJ adjusts the current text matrix but paints no glyphs. + // It must not create a phantom editable line or enlarge its neighbours' boxes. + if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + return Ok(()); + } + out.push(Shown { + op: i, + extra_ops: Vec::new(), + glyph_count, + // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the + // preview divides by size × `Tz`, so `Tz` is put back here. + advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, + tracking: 0.0, + tm: Matrix([ + tm.0[0], + tm.0[1], + tm.0[2], + tm.0[3], + tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], + tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], + ]), + state: ts.clone(), + tlm: *tlm, + bt_op: at_bt.0, + bt_state: at_bt.1.clone(), + text, + rect, + frame, + ink, + baseline: start.1, + start_x: start.0, + end_x: end.0, + end_y: end.1, + bt, + font: name, + base_font: m.base_font.clone(), + face: ts.face.clone(), + size: if size_user > 0.0 { size_user } else { ts.size }, + bold: m.bold, + italic: m.italic, + visual_order: m.visual_order, + decodable, + scope: marked.regions.last().copied().flatten(), + paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, + logical_order: false, + actual_text: false, + }); + Ok(()) +} + pub(super) fn interpret( doc: &Document, ops: &[Op], @@ -490,66 +764,17 @@ pub(super) fn interpret( let mut stack: Vec = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; - let mut marked = std::mem::take(&mut carry.marked); - let mut layers = std::mem::take(&mut carry.layers); - let mut actual: Vec> = Vec::new(); + let mut marked = + MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. b"BMC" | b"BDC" => { - // Marked text paragraphs/headings are authoritative boundaries. Ignore - // artifacts and inner spans, which inherit their enclosing region. - let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); - marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - // A layer named in the resources is told apart by its name, an inline one by where it opens. - let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); - layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec))); - // The property list is inline or named in the resources' /Properties (§14.6.2). - let list = logical_text.and_then(|properties| match op.operands.get(1) { - Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), - other => other.and_then(Object::as_dict).cloned(), - }); - let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { - Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), - _ => None, - }); - actual.push(logical.map(|s| (out.len(), i, s))); + marked.begin(doc, op, i, logical_text, out.len()); } b"EMC" => { - marked.pop(); - layers.pop(); - if let Some(Some((start, open, logical))) = actual.pop() { - let tail = out.split_off(start.min(out.len())); - let mut items = tail.into_iter(); - if let Some(mut first) = items.next() { - for shown in items { - first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); - first.extra_ops.push(shown.op); - first.extra_ops.extend(shown.extra_ops); - first.rect = [ - first.rect[0].min(shown.rect[0]), - first.rect[1].min(shown.rect[1]), - first.rect[2].max(shown.rect[2]), - first.rect[3].max(shown.rect[3]), - ]; - first.frame = union(first.frame, shown.frame); - first.ink.extend(shown.ink); - } - first.extra_ops.extend([open, i]); - // Only right-to-left ActualText is already in typing order and must stay - // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures - // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. - first.logical_order = logical.chars().any(crate::bidi::is_rtl); - first.actual_text = true; - first.text = logical; - first.start_x = first.rect[0]; - first.end_x = first.rect[2]; - first.end_y = first.baseline; - first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); - out.push(first); - } - } + marked.end(i, &mut out); } b"q" => stack.push(ts.clone()), b"Q" => { @@ -650,207 +875,16 @@ pub(super) fn interpret( tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { - if matches!(op.op.as_slice(), b"'" | b"\"") { - if op.is("\"") { - ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); - ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); - } - tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); - tm = tlm; - } - let Some((name, m)) = ts.font.clone() else { continue }; - // Pieces: strings, and TJ number adjustments. - let pieces: Vec = match op.op.as_slice() { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569) -- not the first, which a reader counting operands - // from the front of a surplus list would otherwise take. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Tc is already included in the pen advance; TJ may encode tracking instead. - let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; - let trm0 = tm.then(&ts.ctm); - let start = trm0.apply(0.0, ts.rise); - let mut text = String::new(); - let mut decodable = true; - let mut x_text = 0.0; - // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred - // on it horizontally; `half` is half the widest glyph. - let vertical = ts.vertical; - let (mut y_text, mut half) = (0.0, 0.0_f64); - // Where each word's glyphs start and end along the run (text space), and whether - // the next glyph continues the last word. - let mut words: Vec<(f64, f64)> = Vec::new(); - let mut in_word = false; - let mut glyph_count = 0_usize; - for p in &pieces { - match p { - Object::String(s) => { - for (code, len) in m.codes(&s.bytes) { - glyph_count = glyph_count.saturating_add(1); - let shown = m.text_of(code); - let blank = - m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); - match shown { - Some(t) => { - carry.text_left = carry - .text_left - .checked_sub(t.len()) - .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); - } - None => decodable = false, - } - let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - if let Some(w1) = vertical { - half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); - if !blank { - let ink_end = y_text + w1 * ts.size; - let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - y_text += w1 * ts.size + spacing; - continue; - } - if !blank { - let ink_end = x_text + m.width(code) * ts.size * ts.scale; - // A negative font size or `Tz` runs the glyphs backwards: - // the word is the union of its glyphs, whichever way they go. - let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - x_text += w * ts.scale; - } - } - other => { - if let Some(n) = other.as_f64() { - if vertical.is_some() { - // Down by n/1000 em, not scaled by `Tz`. - let dy = -n / 1000.0 * ts.size; - if -dy > ts.size * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - if dy.abs() > ts.size.abs() * 0.2 { - in_word = false; - } - y_text += dy; - continue; - } - let dx = -n / 1000.0 * ts.size * ts.scale; - // A large gap inside TJ reads as a space. - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - // Tracking and kerning keep a word together; a larger jump - // ends it (whatever the sign of the size or `Tz`). - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { - in_word = false; - } - x_text += dx; - } - } - } - } - tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm); - let end = tm.then(&ts.ctm).apply(0.0, ts.rise); - // The box from `a` to `b` along the run: from descent to ascent across it, or for - // vertical text, the widest glyph's width centred on the pen. - let span = |a: f64, b: f64| match vertical { - Some(_) => { - [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)] - } - None => [ - trm0.apply(a, ts.rise + m.descent * ts.size), - trm0.apply(b, ts.rise + m.descent * ts.size), - trm0.apply(a, ts.rise + m.ascent * ts.size), - trm0.apply(b, ts.rise + m.ascent * ts.size), - ], - }; - let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); - let rect = corners - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - // The same corners along and across the baseline: the run's own box, not its - // upright bounding box turned (which is wider by the box's height on a slant). - let dir = direction(&trm0); - let frame = corners - .iter() - .map(|&p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - let ink = words - .iter() - .map(|&(a, b)| { - span(a, b) - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) - }) - .collect(); - let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; - // A numeric-only TJ adjusts the current text matrix but paints no glyphs. - // It must not create a phantom editable line or enlarge its neighbours' boxes. - if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { - continue; - } - out.push(Shown { - op: i, - extra_ops: Vec::new(), - glyph_count, - // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the - // preview divides by size × `Tz`, so `Tz` is put back here. - advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, - tracking: 0.0, - tm: Matrix([ - tm.0[0], - tm.0[1], - tm.0[2], - tm.0[3], - tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], - tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], - ]), - state: ts.clone(), - tlm, - bt_op: at_bt.0, - bt_state: at_bt.1.clone(), - text, - rect, - frame, - ink, - baseline: start.1, - start_x: start.0, - end_x: end.0, - end_y: end.1, - bt, - font: name, - base_font: m.base_font.clone(), - face: ts.face.clone(), - size: if size_user > 0.0 { size_user } else { ts.size }, - bold: m.bold, - italic: m.italic, - visual_order: m.visual_order, - decodable, - scope: marked.last().copied().flatten(), - paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, - logical_order: false, - actual_text: false, - }); + let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out }; + show_text(op, i, &mut state)?; } _ => {} } } carry.ts = ts; carry.stack = stack; - carry.marked = marked; - carry.layers = layers; + carry.marked = marked.regions; + carry.layers = marked.layers; positioned_tracking(&mut out); Ok(out) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 7543adeb39..c48522984d 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; -use super::*; +use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { /// The baseline's height in user space. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index dd58c72898..513077a027 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,11 +1,14 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; use super::fallback::{ - Encoder, MAX_TYPE3_GLYPHS, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, source_program_completes, - source_show, source_space, source_width, type3_encode, type3_font, + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, + source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; -use super::interp::{Carry, interpret}; +use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; -use super::*; +use super::{ + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse, + rewrite_styled_block, +}; /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] @@ -206,6 +209,532 @@ pub(crate) fn rewrite_block_with_limit( TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) } +struct RewriteDispatch { + upright: bool, + use_styled_path: bool, +} + +fn choose_rewrite_path( + doc: &Document, + fonts: &Dict, + block: &TextBlock, + members: &[&TextLine], + first: &TextLine, + text: Option<&str>, + style: &BlockStyle, +) -> Result { + let registered_face = fonts.iter().any(|(name, _)| name.starts_with(b"PCFull")); + // Hebrew is written back in drawing order by the single-style path. Styled regions do not + // reorder right-to-left runs. + let hebrew = text.unwrap_or(&block.text).chars().any(crate::bidi::is_hebrew); + if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid( + "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), + )); + } + // Turned text is rewritten in its own text space by the single-style path. Styled layout uses + // page x-coordinates and cannot safely apply those operations to rotated runs. + let upright = members.iter().all(|line| line.upright()); + if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); + } + let visual_indic = members.iter().any(|line| line.visual_order); + let has_special_text = text.is_some_and(|draft| { + draft.contains(['\n', '\r']) + || pdfcraft_fonts::source_shaping::required(draft) + || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() + }); + let has_multiple_runs = block.rows.iter().any(|row| row.len() > 1) + || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color); + let source_requires_styled_path = + has_special_text || registered_face || text.is_some_and(|draft| source_program_completes(doc, fonts, first, draft)) || has_multiple_runs; + let explicit_styled_text = !style.ranges.is_empty() || style.resource.is_some(); + Ok(RewriteDispatch { + upright, + use_styled_path: upright + && !hebrew + && !visual_indic + && (explicit_styled_text || (source_requires_styled_path && style.family.is_none() && style.bold.is_none())), + }) +} + +/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed +/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are +/// breaks too; other white space is written as one space. +fn paragraph_text(text: &str) -> String { + text.replace("\r\n", "\n") + .chars() + .map(|c| match c { + '\r' => '\n', + c if c != '\n' && paragraph_separator(c) => ' ', + c => c, + }) + .collect() +} + +/// Where a paragraph sits on its page, for choosing the width it is laid out in. +struct ParaGeometry { + rect: [f64; 4], + frame: [f64; 4], + crop: [f64; 4], + upright: bool, + /// The text-to-user scale. + k: f64, + /// The size in text space. + size: f64, + /// The requested horizontal move, in points. + dx: f64, + single_line: bool, + anchored_right: bool, + centred: bool, +} + +impl ParaGeometry { + /// A paragraph keeps its width (or takes the one asked for); a single line grows to the right + /// (to the left when right-to-left), up to the page's margin. + fn width(&self, requested: Option) -> Result { + let [left, _, right, _] = self.rect; + let own = (right - left).max(self.size * self.k); + let width = match requested { + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((self.size * self.k).min(14_400.0), 14_400.0), + Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), + // Turned text: the page's margins are along another axis. A single line grows (below). + None if !self.upright => (self.frame[2] - self.frame[0]).max(self.size * self.k), + None if self.anchored_right => own.max(right + self.dx - (self.crop[0] + 36.0)), + // A centred line is not stretched to the margin: its own box is the frame it is centred in. + None if self.single_line && !self.centred => own.max(self.crop[2] - 36.0 - (left + self.dx)), + None => own, + }; + if width.is_finite() { Ok(width) } else { Err(EditError::Invalid("the paragraph's width must be a number".into())) } + } +} + +/// How far a baseline starting at `origin` and running along `direction` goes before it is 36 pt +/// from the page edge `low` or `high`. +fn margin_reach(origin: f64, direction: f64, low: f64, high: f64) -> f64 { + if direction > 1e-6 { + (high - 36.0 - origin) / direction + } else if direction < -1e-6 { + (low + 36.0 - origin) / direction + } else { + f64::INFINITY + } +} + +/// A turned single line grows to fit, up to where its baseline reaches the page's margin. +fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: f64, text: &str, advance: &Advance<'_>) -> f64 { + let (x0, y0) = (first.origin.x + offset[0], first.origin.baseline + offset[1]); + let (ux, uy) = first.direction(); + let tx = margin_reach(x0, ux, crop[0], crop[2]); + let ty = margin_reach(y0, uy, crop[1], crop[3]); + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(|line| advance.of(line)).fold(width, f64::max).min(cap) +} + +/// The widths of the paragraph's text in the font it will be written in, in user space. +struct Advance<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + family: crate::added::Family, + bold: bool, + italic: bool, + size: f64, + space: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + k: f64, +} + +impl Advance<'_> { + fn of(&self, s: &str) -> f64 { + let extra = s.chars().count() as f64 * self.char_spacing; + let t = match (self.source, self.type3) { + (Some(m), _) => { + let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; + source_width(m, &visual_s, self.size, self.space, self.char_spacing, self.word_spacing) + } + (None, Some(fallback)) => { + // Each character's count in `s`, from one pass over `s`. + let mut counts: HashMap = HashMap::new(); + for c in s.chars() { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + } + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + } + (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, + }; + t * self.scale * self.k + } +} + +/// The font a rewritten paragraph is shown in, and how its text is encoded. +struct FontChoice { + show_font: String, + encode: Encoder, + /// The stand-in face to tell the caller about. + substituted: Option, +} + +/// What [`choose_font`] needs to know about the paragraph. +struct FontInputs<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + font_name: &'a str, + flat: &'a str, + source_base_font: &'a str, + family: crate::added::Family, + bold: bool, + italic: bool, + family_requested: bool, +} + +fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result { + if let Some(m) = inputs.source.cloned() { + let name = inputs.font_name.to_owned(); + return Ok(FontChoice { + show_font: name.clone(), + encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])), + substituted: None, + }); + } + if let Some(fallback) = inputs.type3.cloned() { + let substituted = Some(format!("{} Type3", fallback.family)); + let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; + return Ok(FontChoice { show_font: first, encode: Box::new(move |s: &str| type3_encode(&fallback, s)), substituted }); + } + let flat = inputs.flat; + let win = pdfcraft_fonts::win_ansi(flat); + let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); + let base = inputs.family.base_font(inputs.bold, inputs.italic); + if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", inputs.source_base_font))); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + // Named after its face, as the line and region rewrites name theirs: one name for every + // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). + let name = format!("PCEd{}", base.replace('-', "")); + fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); + let substituted = (!inputs.family_requested).then(|| base.to_string()); + let shown = name.clone(); + Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted }) +} + +/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken +/// back through the CTM's linear part. Checked before the fallback fonts are written: a refused +/// paragraph leaves the document as it was. +fn drawn_movement(ctm: [f64; 6], dx: f64, dy: f64) -> Result, EditError> { + if dx == 0.0 && dy == 0.0 { + return Ok(None); + } + let back = Matrix([ctm[0], ctm[1], ctm[2], ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; + Ok(Some(back.apply(dx, dy))) +} + +/// The paragraph's first text matrix, taken from where its text starts to where it is written. +fn emit_text_matrix(first: &Origin, emit: &Origin) -> Result<[f64; 6], EditError> { + if first.ctm == emit.ctm { + return Ok(first.tm); + } + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Ok(Matrix(first.tm).then(&Matrix(first.ctm)).then(&emit_inv).0) +} + +/// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. +struct LeadScale { + k: f64, + size: f64, + old_size: f64, + leading: f64, +} + +fn line_lead(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, scale: &LeadScale) -> f64 { + if let Some(m) = style.line_spacing { + return scale.size * m.clamp(0.5, 5.0); + } + // From the first two rows (a row may hold several runs). + let rows = block.rows.first().zip(block.rows.get(1)).and_then(|(upper, lower)| upper.first().zip(lower.first())); + if let Some((upper, lower)) = rows.and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) { + return (upper.frame_origin().1 - lower.frame_origin().1) / scale.k * scale.size / scale.old_size; + } + if scale.leading > 0.0 { scale.leading } else { scale.size * 1.2 } +} + +/// The stroking twin of a fill colour operator. +fn stroke_twin(fill: &Op) -> Op { + let name = match fill.op.as_slice() { + b"g" => "G", + b"rg" => "RG", + b"k" => "K", + b"cs" => "CS", + b"sc" => "SC", + _ => "SCN", + }; + Op::new(name, fill.operands.clone()) +} + +/// Underlines in text space (the paragraph's text matrix), in its fill colour. +fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64, f64)]) -> Vec { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![]), Op::new("cm", tm.iter().map(|v| n(*v)).collect())]; + ops.extend(fill.iter().map(stroke_twin)); + ops.push(Op::new("w", vec![n(size * 0.06)])); + for (x0, x1, y) in underlines { + ops.push(Op::new("m", vec![n(*x0), n(*y)])); + ops.push(Op::new("l", vec![n(*x1), n(*y)])); + } + ops.push(Op::new("S", vec![])); + ops.push(Op::new("Q", vec![])); + ops +} + +/// Alignment: each line's offset from the left edge. +struct Alignment { + align: Option, + width: f64, + k: f64, + anchored_right: bool, + right_edge: f64, + rtl: bool, +} + +impl Alignment { + /// `visible` is the width the line is aligned on, in user space; the offset is in text space. + fn offset(&self, visible: f64) -> f64 { + let free = (self.width - visible).max(0.0) / self.k; + match self.align { + Some(crate::added::Align::Center) => free / 2.0, + // A right-to-left line anchored at its right edge may start left of the old box. + Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k, + Some(crate::added::Align::Right) => free, + // A justified right-to-left paragraph ends its last line at the right edge. + Some(crate::added::Align::Justify) if self.rtl => free, + _ => 0.0, + } + } +} + +/// Everything the wrapped lines of one paragraph are written with. +struct LineWriter<'a> { + advance: &'a Advance<'a>, + alignment: Alignment, + encode: &'a Encoder, + source: Option<&'a Metrics>, + justify: bool, + /// Whether `Tw` reaches every shown space. + word_spacing_reaches_spaces: bool, + width: f64, + scale: f64, + k: f64, + size: f64, + space: f64, + char_spacing: f64, + lead: f64, +} + +/// What carries over from one line to the next. +struct LineCursor { + x: f64, + tw_set: f64, + shown_font: String, +} + +impl LineWriter<'_> { + /// Justify: each line but the last fills the width. A stretched line shows only its words, as + /// before #4199: word spacing would reach the spaces before its first word and after its + /// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't + /// justified. + fn stretch(&self, body: &str, last: bool) -> f64 { + let interior = body.matches(' ').count(); + if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 { + Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0) + } else { + 0.0 + } + } + + /// The text to show for a line and the width it is aligned on. Other lines show every space + /// typed. A left-to-right one is aligned on its width up to its last word; a right-to-left + /// one on its whole width, since the spaces typed after its last word are drawn on its left + /// and the ones before its first on its right. + fn shown<'t>(&self, line: &'t str, body: &'t str, tw: f64) -> (&'t str, f64) { + if tw > 0.0 { + (body, self.advance.of(body)) + } else if crate::bidi::rtl_base(line) { + (line, self.advance.of(line)) + } else { + (line, self.advance.of(line.trim_end_matches(' '))) + } + } + + /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. + fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(f64, f64, f64), EditError> { + let n = pdfcraft_content::num; + let body = line.trim_matches(' '); + let interior = body.matches(' ').count(); + let tw = self.stretch(body, last); + let (shown, visible) = self.shown(line, body, tw); + let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; + if index > 0 || dx != 0.0 { + ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); + } + cursor.x = dx; + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3), so where a space is + // shown with another code (a composite font, a generated Type 3 font, a subset without a + // space) the extra space follows each space as a TJ adjustment instead. + let stretch = if self.word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; + if tw != cursor.tw_set { + ops.push(Op::new("Tw", vec![n(tw)])); + cursor.tw_set = tw; + } + // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). + let visual = crate::bidi::to_visual(shown); + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + self.show_piece(piece, line, tw, cursor, ops)?; + if stretch > 0.0 && piece.ends_with(' ') { + ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + } + } + let tw = tw + stretch; + // Underlined to the line's last word, over its stretched gaps. + let w = (visible + tw * interior as f64 * self.scale * self.k) / self.k; + Ok((dx, dx + w, -(index as f64) * self.lead - self.size * 0.12)) + } + + fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { + let n = pdfcraft_content::num; + if let Some(metrics) = self.source { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); + return Ok(()); + } + for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); + cursor.shown_font = font; + } + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + Ok(()) + } + + /// Writes every wrapped line, returning their underlines. + fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect() + } +} + +/// The operators that write a paragraph in its own graphics state, so a new colour (or anything +/// else) stops at the paragraph. +struct BlockPlan<'a> { + block: &'a TextBlock, + lines: &'a [TextLine], + style: &'a BlockStyle, + emit: &'a Origin, + after: &'a TextState, + movement: Option<(f64, f64)>, + state: TextState, + tm: [f64; 6], + size: f64, + underline: bool, +} + +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result, EditError> { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![])]; + ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); + // Moved: a translation inside that state. + if let Some((ax, ay)) = plan.movement { + ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + } + // Painted as the paragraph was, including what its text object set before the first show. + ops.extend(plan.state.paint_ops(plan.after)); + ops.push(Op::new("BT", vec![])); + let mut state = plan.state.clone(); + state.word_spacing = 0.0; + // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. + let mut cursor = LineCursor { x: 0.0, tw_set: 0.0, shown_font: show_font.clone() }; + state.font = Some((show_font, plan.size)); + if let Some([r, g, bl]) = plan.style.color { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; + } + ops.extend(state.ops()); + ops.push(Op::new("Tm", plan.tm.iter().map(|v| n(*v)).collect())); + let underlines = writer.write_all(wrapped, &mut cursor, &mut ops)?; + ops.push(Op::new("ET", vec![])); + if plan.underline { + ops.extend(underline_ops(&state.fill, &plan.tm, plan.size, &underlines)); + } + ops.push(Op::new("Q", vec![])); + Ok(ops) +} + +/// Whether text shown earlier in the same text object stays first: the paragraph then goes where +/// its last row was, after any backgrounds between the rows, or between two halves of the text +/// object. The paragraph's own operators (and another drawing on the same baseline: a fake-bold +/// second copy, an invisible text layer) go, so the new text is all that may show. +fn shows_before_paragraph(ops: &[Op], from: usize, to: usize, dropped: &std::collections::HashSet) -> bool { + ops.get(from..to) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !dropped.contains(&(from + i))) +} + +/// A paragraph's single style, with the text-space values read from where it starts. +struct ParaStyle { + state: TextState, + size: f64, + old_size: f64, + k: f64, +} + +/// Spacing and scaling (text state), with the chosen values. +fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> { + let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; + let k = o.k.max(1e-6); + // The size in text space: points ÷ the text-to-user scale. + let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + let mut state = o.state.clone(); + if let Some(c) = style.char_spacing { + state.char_spacing = c / k; + } + if let Some(sc) = style.scale { + state.scale = (sc / 100.0).clamp(0.1, 10.0); + } + Ok((font_name, ParaStyle { state, size, old_size, k })) +} + +/// The font the paragraph's existing runs use, for reusing its glyphs. +struct SourceFont { + metrics: Option, + reuse: bool, + space: f64, +} + +fn source_font(doc: &Document, fonts_res: &Dict, font_name: &str, first: &TextLine, flat: &str, style: &BlockStyle) -> SourceFont { + let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(flat) } else { flat.to_owned() }; + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); + let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, fonts_res, m)); + SourceFont { metrics, reuse, space } +} + /// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. pub(crate) fn rewrite_block_from( doc: &mut Document, @@ -228,86 +757,26 @@ pub(crate) fn rewrite_block_from( let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let registered_face = fonts_res.iter().any(|(name, _)| name.starts_with(b"PCFull")); - // Hebrew is written back in drawing order (and with the Hebrew fallback face) by the - // single-style path below; the styled region path does not reorder right-to-left runs. - let hebrew = text.unwrap_or(&b.text).chars().any(crate::bidi::is_hebrew); - if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid( - "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), - )); - } - // Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its - // own text space below; the styled region path lays rows out along the page's x axis. - let upright = members.iter().all(|line| line.upright()); - if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); - } - let visual_indic = members.iter().any(|line| line.visual_order); - if upright - && !hebrew - && !visual_indic - && (!style.ranges.is_empty() - || style.resource.is_some() - || (text.is_some_and(|draft| { - // A row break, `\n` or a carriage return, takes the path that keeps untouched rows. - draft.contains(['\n', '\r']) - || pdfcraft_fonts::source_shaping::required(draft) - || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() - }) || registered_face - || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) - || b.rows.iter().any(|row| row.len() > 1) - || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) - && style.family.is_none() - && style.bold.is_none()) - { + let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; + if dispatch.use_styled_path { return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } + let upright = dispatch.upright; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - // Explicit line breaks stay (#1443), and so does each typed U+0020 space: a run of them is - // not folded into one (#4199, #3797). `\r\n` and a lone `\r` are breaks too; other white - // space is written as one space. - let text: String = text - .unwrap_or(&b.text) - .replace("\r\n", "\n") - .chars() - .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', - c => c, - }) - .collect(); + let text = paragraph_text(text.unwrap_or(&b.text)); // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; let last = members.last().copied().unwrap_or(first); let emit = &last.origin; - let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; - let k = o.k.max(1e-6); - // The size in text space: points ÷ the text-to-user scale. - let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); - // Spacing and scaling (text state), with the chosen values. - let mut ts_state = o.state.clone(); - if let Some(c) = style.char_spacing { - ts_state.char_spacing = c / k; - } - if let Some(sc) = style.scale { - ts_state.scale = (sc / 100.0).clamp(0.1, 10.0); - } - let o_state = ts_state.clone(); - let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() }; - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); - let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?; + let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); + let reuse = source.reuse; // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); - // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. - let face = family.std14(bold, italic); - let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); if !(dx.is_finite() && dy.is_finite()) { return Err(EditError::Invalid("the paragraph can't be moved that far".into())); @@ -327,287 +796,79 @@ pub(crate) fn rewrite_block_from( // Turned text is left alone: its centre lives along its own baseline, not in page space. let crop = p.crop(doc); let centred = upright && !rtl && style.width.is_none() && style.align.is_none() && is_centred(&b, &lines, (crop[0] + crop[2]) / 2.0, first.size); - // A paragraph keeps its width (or takes the one asked for); a single line grows to the right - // (to the left when right-to-left), up to the page's margin. - let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that - // (`clamp` panics when its minimum is the larger, or NaN). - Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0), - Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), - // Turned text: the page's margins are along another axis. A single line grows (below). - None if !upright => (frame[2] - frame[0]).max(size * k), - None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (crop[0] + 36.0)), - // A centred line is not stretched to the margin: its own box is the frame it is centred in. - None if members.len() == 1 && !centred => (b.rect[2] - b.rect[0]).max(size * k).max(crop[2] - 36.0 - (b.rect[0] + dx)), - None => (b.rect[2] - b.rect[0]).max(size * k), - }; - if !width.is_finite() { - return Err(EditError::Invalid("the paragraph's width must be a number".into())); - } + let geometry = ParaGeometry { rect: b.rect, frame, crop, upright, k, size, dx, single_line: members.len() == 1, anchored_right, centred }; + let width = geometry.width(style.width)?; let align = if centred { Some(crate::added::Align::Center) } else { style.align.or(rtl.then_some(crate::added::Align::Right)) }; // Where a right-aligned line ends, from the paragraph's left edge (user space). let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; - // The move and the width are checked here, before the fallback fonts are written: a refused - // paragraph leaves the document as it was. The move is in page space, so it is taken back through - // the CTM's linear part into the space the paragraph is drawn in. - let movement = if dx != 0.0 || dy != 0.0 { - let c = emit.ctm; - let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; - Some(back.apply(dx, dy)) - } else { - None - }; - let tm = if o.ctm == emit.ctm { - o.tm - } else { - let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 - }; + let movement = drawn_movement(emit.ctm, dx, dy)?; + let tm = emit_text_matrix(o, emit)?; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; - let advance = |s: &str| -> f64 { - let t = match (&metrics, reuse) { - (Some(m), true) => { - let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; - source_width(m, &visual_s, size, space, o_state.char_spacing, o_state.word_spacing) - } - _ if let Some(fallback) = &type3 => { - // Each character's count in `s`, from one pass over `s`. - let mut counts: HashMap = HashMap::new(); - for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); - } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::() - + s.chars().count() as f64 * o_state.char_spacing - } - _ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing, - }; - t * o_state.scale * k + let source_metrics = source.metrics.as_ref().filter(|_| reuse); + let advance = Advance { + source: source_metrics, + type3: type3.as_ref(), + family, + bold, + italic, + size, + space: source.space, + char_spacing: o_state.char_spacing, + word_spacing: o_state.word_spacing, + scale: o_state.scale, + k, }; - // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - let crop = p.crop(doc); - let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); - let (ux, uy) = first.direction(); - let tx = if ux > 1e-6 { - (crop[2] - 36.0 - x0) / ux - } else if ux < -1e-6 { - (crop[0] + 36.0 - x0) / ux - } else { - f64::INFINITY - }; - let ty = if uy > 1e-6 { - (crop[3] - 36.0 - y0) / uy - } else if uy < -1e-6 { - (crop[1] + 36.0 - y0) / uy - } else { - f64::INFINITY - }; - let cap = width.max(tx.min(ty)).min(14_400.0); - text.split('\n').map(&advance).fold(width, f64::max).min(cap) + turned_line_width(first, crop, [dx, dy], width, &text, &advance) } else { width }; let right_edge = if anchored_right { right_edge } else { width }; - let wrapped = wrap(&text, width + 0.5, advance); - let mut substituted = None; - let new_font = !reuse; - let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) { - let name = font_name.clone(); - (font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)]))) - } else if let Some(fallback) = type3.clone() { - substituted = Some(format!("{} Type3", fallback.family)); - let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - (first, Box::new(move |s: &str| type3_encode(&fallback, s))) - } else { - let win = pdfcraft_fonts::win_ansi(&flat); - let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); - let base = family.base_font(bold, italic); - if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font))); - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - // Named after its face, as the line and region rewrites name theirs: one name for every - // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). - let name = format!("PCEd{}", base.replace('-', "")); - fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); - if style.family.is_none() { - substituted = Some(base.to_string()); - } - let shown = name.clone(); - (name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))]))) - }; - // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. - let lead = match style.line_spacing { - Some(m) => size * m.clamp(0.5, 5.0), - // From the first two rows (a row may hold several runs). - None if let Some((upper, lower)) = b - .rows - .first() - .zip(b.rows.get(1)) - .and_then(|(upper, lower)| upper.first().zip(lower.first())) - .and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) => - { - (upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size - } - None if o_state.leading > 0.0 => o_state.leading, - None => size * 1.2, - }; - let n = pdfcraft_content::num; - // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold - // second copy, an invisible text layer): the new text is all that may show. - let drop = dropped; - // Text shown earlier in the same text object stays first: the paragraph then goes where its - // last row was, after any backgrounds between the rows, or between two halves of the text object. + let wrapped = wrap(&text, width + 0.5, |s| advance.of(s)); + let font = choose_font( + &FontInputs { + source: source_metrics, + type3: type3.as_ref(), + font_name: &font_name, + flat: &flat, + source_base_font: &b.base_font, + family, + bold, + italic, + family_requested: style.family.is_some(), + }, + &mut fonts_res, + )?; + let lead = line_lead(&b, &lines, style, &LeadScale { k, size, old_size, leading: o_state.leading }); let start = last.ops.first().copied().unwrap_or(emit.bt_op); - let split = ops - .get(emit.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); + let split = shows_before_paragraph(&ops, emit.bt_op, start, &dropped); // What the text after the paragraph expects, and what is in force where the paragraph is // drawn: the state at its BT (or where it was split). let after = if split { &emit.state } else { &emit.bt_state }; - // In its own graphics state, so a new colour (or anything else) stops at the paragraph. - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); - // Moved: a translation inside that state. - if let Some((ax, ay)) = movement { - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); - } - // Painted as the paragraph was, including what its text object set before the first show. - block_ops.extend(o_state.paint_ops(after)); - block_ops.push(Op::new("BT", vec![])); - let mut state = o_state.clone(); - state.word_spacing = 0.0; - // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. - let mut shown_font = show_font.clone(); - state.font = Some((show_font, size)); - if let Some([r, g, bl]) = style.color { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; - } - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // Alignment: each line's offset from the left edge, in text space. - // `visible` is the width the line is aligned on (see the loop below). - let offset = |visible: f64| -> f64 { - let free = (width - visible).max(0.0) / k; - match align { - Some(crate::added::Align::Center) => free / 2.0, - // A right-to-left line anchored at its right edge may start left of the old box. - Some(crate::added::Align::Right) if anchored_right => (right_edge - visible) / k, - Some(crate::added::Align::Right) => free, - // A justified right-to-left paragraph ends its last line at the right edge. - Some(crate::added::Align::Justify) if rtl => free, - _ => 0.0, - } - }; - // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 - // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a - // generated Type 3 font, a subset without a space) the extra space follows each space as a - // TJ adjustment instead, and code 32, which may then be a letter, gets none. - let justify = align == Some(crate::added::Align::Justify); - let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3); see `write_line`. + let word_spacing_reaches_spaces = match source_metrics { Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), None => type3.is_none(), }; - let mut x = 0.0; - let mut tw_set = 0.0; - let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space - for (i, line) in wrapped.iter().enumerate() { - // A stretched (justified) line shows only its words, as before #4199: word spacing would - // reach the spaces before its first word and after its last, so they are left out, and - // the line fills the width from the left edge. Nothing to stretch over, or a paragraph - // drawn at `0 Tz`, isn't justified. - let body = line.trim_matches(' '); - let interior = body.matches(' ').count(); - let tw = if justify && i + 1 < wrapped.len() && interior > 0 && o_state.scale.abs() > 1e-6 { - Some((width - advance(body)).max(0.0) / k / o_state.scale / interior as f64).filter(|tw| tw.is_finite()) - } else { - None - } - .unwrap_or(0.0); - // Other lines show every space typed. A left-to-right one is aligned on its width up to - // its last word; a right-to-left one on its whole width, since the spaces typed after its - // last word are drawn on its left and the ones before its first on its right. - let (shown, visible) = if tw > 0.0 { - (body, advance(body)) - } else if crate::bidi::rtl_base(line) { - (line.as_str(), advance(line)) - } else { - (line.as_str(), advance(line.trim_end_matches(' '))) - }; - let dx = if tw > 0.0 { 0.0 } else { offset(visible) }; - if i > 0 || dx != 0.0 { - block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); - } - x = dx; - let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; - let tw = tw - stretch; - if tw != tw_set { - block_ops.push(Op::new("Tw", vec![n(tw)])); - tw_set = tw; - } - // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - let visual = crate::bidi::to_visual(shown); - // Stretched without word spacing: shown up to and including each space, then moved on. - let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; - for piece in pieces { - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; - } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - } - if stretch > 0.0 && piece.ends_with(' ') { - block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); - } - } - let tw = tw + stretch; - // Underlined to the line's last word, over its stretched gaps. - let w = (visible + tw * interior as f64 * o_state.scale * k) / k; - underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); - } - block_ops.push(Op::new("ET", vec![])); - if style.underline == Some(true) { - // In text space (the paragraph's text matrix), in its fill colour. - let colour: Vec = state - .fill - .iter() - .map(|f| match f.op.as_slice() { - b"g" => Op::new("G", f.operands.clone()), - b"rg" => Op::new("RG", f.operands.clone()), - b"k" => Op::new("K", f.operands.clone()), - b"cs" => Op::new("CS", f.operands.clone()), - b"sc" => Op::new("SC", f.operands.clone()), - _ => Op::new("SCN", f.operands.clone()), - }) - .collect(); - block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(colour); - block_ops.push(Op::new("w", vec![n(size * 0.06)])); - for (x0, x1, y) in underlines { - block_ops.push(Op::new("m", vec![n(x0), n(y)])); - block_ops.push(Op::new("l", vec![n(x1), n(y)])); - } - block_ops.push(Op::new("S", vec![])); - block_ops.push(Op::new("Q", vec![])); - } - block_ops.push(Op::new("Q", vec![])); + let writer = LineWriter { + advance: &advance, + alignment: Alignment { align, width, k, anchored_right, right_edge, rtl }, + encode: &font.encode, + source: source_metrics, + justify: align == Some(crate::added::Align::Justify), + word_spacing_reaches_spaces, + width, + scale: o_state.scale, + k, + size, + space: source.space, + char_spacing: o_state.char_spacing, + lead, + }; + let plan = + BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); @@ -617,9 +878,10 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !drop.contains(&i)) + (insert, !dropped.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); + let new_font = !reuse; if new_font { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); } @@ -630,7 +892,7 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; - Ok(LineEdit { substituted }) + Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 7516cb793b..be5b4dffa6 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -1,6 +1,11 @@ //! Geometry-based regions and mixed-style rewriting of existing text. -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, + source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, +}; #[path = "text_regions/grouping.rs"] mod grouping; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 29004b8e33..b8a5abca1e 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 09baf1ac7e..e8b3b683d0 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,5 +1,10 @@ use super::grouping::{across, is_centred, row_rect}; -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, + splice, type3_faces, type3_glyph, write_type3_glyphs, +}; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. // A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). @@ -13,122 +18,462 @@ struct FallbackGroup { positions: Vec, } -/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. -fn shape_source( - doc: &Document, - fonts: &Dict, - name: &str, - text: &str, -) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { - if !pdfcraft_fonts::source_shaping::required(text) { - return Ok(None); - } - let source = - fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; - let family = Metrics::from_dict(doc, &source).base_font; - let mut candidates = vec![source]; - candidates.extend( - fonts - .iter() - .filter_map(|(_, f)| doc.dict(f)) - .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), - ); - for font in candidates { - if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) - && pdfcraft_fonts::embedding::covers(&bytes, text) - && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) +struct FallbackBuilder { + groups: Vec, + names: std::collections::BTreeMap<(String, bool, char), usize>, + glyphs: usize, + per_font: usize, + budget: usize, +} + +struct FontResolver<'a> { + doc: &'a mut Document, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + full_faces: HashMap, + programs: HashMap>>>, +} + +impl FontResolver<'_> { + fn encodable_font(&mut self, source: &TextLine, text: &str, requested: Option<&str>) -> Result, EditError> { + if let Some(resource) = requested { + if !self.metrics.get(resource).is_some_and(|metrics| source_encodes(metrics, text)) { + return Err(EditError::Invalid(format!( + "the selected font resource {resource} cannot show U+{:04X}", + text.chars().next().map_or(0, u32::from) + ))); + } + return Ok(Some(resource.to_owned())); + } + if self.metrics.get(&source.font).is_some_and(|metrics| source_encodes(metrics, text)) { + return Ok(Some(source.font.clone())); + } + if let Some((name, _)) = self.metrics.iter().find(|(_, metrics)| { + pdfcraft_fonts::embedding::face_key(&metrics.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) + && source_encodes(metrics, text) + }) { + return Ok(Some(name.clone())); + } + if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|face| pdfcraft_fonts::embedding::covers(face.bytes, text)) { - return Ok(Some((bytes, shaped, family))); + return self.embed_face(face.name(), face.bytes, face.family, "the full source font could not be loaded").map(Some); } + let Some(bytes) = self + .programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(self.doc, self.fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, text)) + else { + return Ok(None); + }; + let key = format!("source {}", source.font); + if let Some(name) = self.full_faces.get(&key) { + return Ok(Some(name.clone())); + } + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + self.embed_face(key, &bytes, &family, "the source font could not be loaded").map(Some) + } + + fn embed_face(&mut self, key: String, bytes: &[u8], family: &str, missing_message: &str) -> Result { + if let Some(name) = self.full_faces.get(&key) { + return Ok(name.clone()); + } + let name = pdfcraft_fonts::embedding::embed(self.doc, self.fonts, bytes, family).map_err(EditError::Invalid)?; + let font = + self.fonts.get(name.as_bytes()).and_then(|resource| self.doc.dict(resource)).ok_or_else(|| EditError::Invalid(missing_message.into()))?; + self.metrics.insert(name.clone(), Metrics::from_dict(self.doc, &font)); + self.full_faces.insert(key, name.clone()); + Ok(name) + } + + fn substitute_font(&mut self, source: &TextLine) -> String { + let family = source_family(&source.base_font); + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + self.metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(self.doc, &font)); + self.fonts.set(name.clone().into_bytes(), Object::Dict(font)); + name } - Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) } -/// For each output row (a range of the draft's characters), whether it is exactly the source row -/// at the same position: the same characters, none of them inserted or changed by the edit. -fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { - let old: Vec = block.editor_text.chars().collect(); - let new: Vec = draft.chars().collect(); - let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); - let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); - // The source character a draft character is, if the edit left it alone. - let origin = |i: usize| { - if i < prefix { - Some(i) - } else if i >= new.len().saturating_sub(suffix) { - (old.len() + i).checked_sub(new.len()) +struct PreparedCharacters { + values: Vec, + substituted: Option, +} + +struct CharacterPreparation<'a> { + doc: &'a mut Document, + first: &'a TextLine, + lines: &'a [TextLine], + draft: &'a str, + spans: Vec<(std::ops::Range, usize)>, + style: &'a BlockStyle, + overrides: &'a HashMap, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + max_glyphs: usize, +} + +impl CharacterPreparation<'_> { + fn prepare(self) -> Result { + let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback = FallbackBuilder::new(max_glyphs); + let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; + for (range, index) in spans { + let source = lines.get(index).unwrap_or(first); + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } + let text = ch.to_string(); + let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); + let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + name + } else if needs_type3(&text) { + fallback.prepare(source, ch, &text, chars.len())? + } else { + let name = resolver.substitute_font(source); + substituted = Some(source_family(&source.base_font).base_font(source.bold, source.italic).to_owned()); + name + }; + chars.push((ch, index, name)); + } + } + drop(resolver); + for group in fallback.groups { + let written = write_type3_glyphs(doc, fonts, group.face, false, group.glyphs, fallback.per_font); + for name in &written.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|resource| doc.dict(resource)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = written.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = written.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(written.family.to_owned()); + } + Ok(PreparedCharacters { values: chars, substituted }) + } +} + +impl FallbackBuilder { + fn new(max_glyphs: usize) -> Self { + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + Self { + groups: Vec::new(), + names: std::collections::BTreeMap::new(), + glyphs: 0, + per_font, + budget: per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES), + } + } + + fn prepare(&mut self, source: &TextLine, ch: char, text: &str, position: usize) -> Result { + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = self.names.get(&key) { + *index } else { - None + let (faces, _) = type3_faces(text, source_family(&source.base_font), source.bold)?; + let face = fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(text))?; + let index = self.groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = self.groups.len(); + self.groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + self.names.insert(key, index); + index + }; + let group = self.groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if self.glyphs >= self.budget { + return Err(EditError::Invalid(format!( + "the replacement needs more than {} fallback glyphs; edit a smaller paragraph or load a complete font", + self.budget + ))); + } + let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; + let code = u8::try_from((group.glyphs.len() % self.per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + self.glyphs = self.glyphs.saturating_add(1); } - }; - let mut source_rows = Vec::new(); - let mut start = 0; - for (i, ch) in old.iter().enumerate() { - if *ch == '\n' { - source_rows.push(start..i); - start = i + 1; + group.positions.push(position); + Ok(String::new()) + } +} + +struct StyledLines<'a> { + lines: Vec, + spans: Vec<(std::ops::Range, usize)>, + underlines: HashMap, + overrides: HashMap, +} + +type StyledChar = (char, usize, String); +type StyledRows = Vec>; +type RowRanges = Vec>; +type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; + +struct ShapedRows { + runs: ShapedRunCache, + advances: HashMap<(usize, usize), f64>, +} + +fn prepare_shaped_rows( + doc: &Document, + fonts: &Dict, + rows: &[Vec], + kept: &[bool], + advance: impl Fn(&StyledChar) -> f64, +) -> Result { + let mut runs = HashMap::new(); + let mut advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) { + end += 1; + } + let segment = row.get(at..end).unwrap_or_default(); + advances.insert((row_index, at), segment.iter().map(&advance).sum::()); + let text: String = segment.iter().map(|character| character.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { + runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; } } - source_rows.push(start..old.len()); - // A line break inside a source run would pair editor rows with the wrong visual rows. - if source_rows.len() != block.rows.len() { - return vec![false; rows.len()]; + Ok(ShapedRows { runs, advances }) +} + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { + let mut resources = HashMap::new(); + for (key, (bytes, shaped, family)) in runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + resources.insert(*key, resource); } - rows.iter() - .enumerate() - .map(|(r, range)| { - source_rows.get(r).is_some_and(|source| { - !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) - }) - }) - .collect() + Ok(resources) } -/// Rewrite visual rows while keeping the source style on each surviving character. Resource -/// names may differ between Unicode subsets of the same face; encoding searches those subsets. -pub(crate) fn rewrite_styled_block( - doc: &mut Document, - page: usize, - block: &TextBlock, +fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { + for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { + let text: String = row.iter().map(|character| character.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) + && row.first().is_some_and(|first| row.iter().any(|character| character.1 != first.1 || character.2 != first.2)) + { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + Ok(()) +} + +fn calibration_factors( lines: &[TextLine], - text: Option<&str>, + metrics: &std::collections::BTreeMap, style: &BlockStyle, - max_glyphs: usize, -) -> Result { - let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + overrides: &HashMap, + space_of: impl Fn(&str, &Metrics) -> f64, +) -> HashMap { + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(metrics) = metrics.get(&source.font) { + let nominal = source_width( + metrics, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, size)| *size), + space_of(&source.font, metrics), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * across(source); + let changed_face = + style.resource.is_some() || overrides.get(&index).is_some_and(|range| range.family.is_some() || range.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + calibration +} + +fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, EditError> { + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|range| range.range.start); + let invalid_range = ranges.iter().any(|range| { + range.range.start >= range.range.end + || draft.get(range.range.clone()).is_none() + || [range.size, range.char_spacing, range.scale].iter().flatten().any(|value| !value.is_finite()) + || range.size.is_some_and(|value| value <= 0.0 || value > 14_400.0) + || range.color.is_some_and(|color| color.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) + }); + if ranges.len() > 1024 || invalid_range || ranges.windows(2).any(|pair| pair[0].range.end > pair[1].range.start) { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + Ok(ranges) +} + +fn replacement_text<'a>(block: &'a TextBlock, text: Option<&'a str>) -> Result<&'a str, EditError> { let draft = match text { - Some(t) if t != block.text => t, + Some(text) if text != block.text => text, _ => &block.editor_text, }; if draft.len() > 4_000_000 { return Err(EditError::Invalid("the replacement text is too large".into())); } - if draft == block.editor_text && *style == BlockStyle::default() { - return Ok(LineEdit { substituted: None }); + Ok(draft) +} + +fn has_no_effective_style_change(block: &TextBlock, draft: &str, style: &BlockStyle) -> bool { + draft == block.editor_text && *style == BlockStyle::default() +} + +fn page_font_metrics( + doc: &Document, + resource_dict: &Dict, + block: &TextBlock, + lines: &[TextLine], +) -> (Dict, std::collections::BTreeMap) { + let mut fonts = resource_dict.get(b"Font").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(font)); } - let mut ranges: Vec<_> = style.ranges.iter().collect(); - ranges.sort_by_key(|r| r.range.start); - if ranges.len() > 1024 - || ranges.iter().any(|r| { - r.range.start >= r.range.end - || draft.get(r.range.clone()).is_none() - || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) - || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) - || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) - }) - || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|resource| doc.resolve(resource).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + (fonts, metrics) +} + +fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextBlock, style: &BlockStyle) -> Result<(f64, f64, f64), EditError> { + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|value| !value.is_finite()) + || style.color.is_some_and(|color| color.iter().any(|value| !value.is_finite())) { - return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); } - let source_count = lines.len(); + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|width| !width.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + let natural = if block.invisible || block.rows.len() == 1 { + (page.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) + } else { + block.rect[2] - block.rect[0] + }; + Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) +} + +fn wrap_styled_rows( + chars: &[StyledChar], + width: f64, + measure: impl Fn(&[StyledChar]) -> Result, +) -> Result<(StyledRows, RowRanges), EditError> { + let mut rows: Vec> = Vec::new(); + let mut row_ranges = Vec::new(); + let mut row_start = 0; + let mut row = Vec::new(); + let mut row_width = 0.0; + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + if item.0 == '\r' { + at += 1; + continue; + } + if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + row_start = at; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) + && !current.0.is_whitespace() + && !crate::linebreak::between(before.0, current.0) + { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let word_width = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { + row.pop(); + } + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + row_start = at; + } + row.extend_from_slice(word); + row_width += word_width; + at = end; + } + if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); + rows.push(row); + } + Ok((rows, row_ranges)) +} + +fn apply_range_styles<'a>( + first: &TextLine, + block: &TextBlock, + lines: &[TextLine], + draft: &str, + ranges: &[&'a RangeStyle], + style: &BlockStyle, +) -> StyledLines<'a> { let mut styled_lines = lines.to_vec(); let mut styled_spans = Vec::new(); let mut underlines = HashMap::new(); let mut overrides = HashMap::new(); for (span, source) in block.edited_spans(draft) { let mut edges = vec![span.start, span.end]; - for r in &ranges { - for edge in [r.range.start, r.range.end] { + for range in ranges { + for edge in [range.range.start, range.range.end] { if span.contains(&edge) { edges.push(edge); } @@ -138,8 +483,8 @@ pub(crate) fn rewrite_styled_block( edges.dedup(); for bounds in edges.windows(2) { let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let selected = ranges.iter().find(|range| range.range.contains(&start)).copied(); let mut index = source; - let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); if selected.is_some() || style.family.is_some() || style.bold.is_some() { let default_range = RangeStyle { range: start..end, @@ -151,10 +496,10 @@ pub(crate) fn rewrite_styled_block( char_spacing: None, scale: None, }; - let r = selected.unwrap_or(&default_range); + let range = selected.unwrap_or(&default_range); let mut line = lines.get(source).unwrap_or(first).clone(); if let Some((family, bold, italic)) = - r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + range.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) { let base = family.base_font(bold, italic); line.font = format!("PCRange{}", base.replace('-', "")); @@ -162,225 +507,192 @@ pub(crate) fn rewrite_styled_block( line.bold = bold; line.italic = italic; } - if let Some(resource) = &r.resource { + if let Some(resource) = &range.resource { line.font = resource.clone(); } - if let Some(size) = r.size { + if let Some(size) = range.size { line.size = size; } - if let Some(color) = r.color { + if let Some(color) = range.color { line.color = color; line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; } - if let Some(spacing) = r.char_spacing { + if let Some(spacing) = range.char_spacing { line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); } - if let Some(scale) = r.scale { + if let Some(scale) = range.scale { line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); } index = styled_lines.len(); styled_lines.push(line); - if let Some(r) = selected { - overrides.insert(index, r); + if let Some(range) = selected { + overrides.insert(index, range); } - if let Some(underline) = r.underline { + if let Some(underline) = range.underline { underlines.insert(index, underline); } } styled_spans.push((start..end, index)); } } - let lines = styled_lines.as_slice(); - let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); - } - let mut metrics = std::collections::BTreeMap::new(); - for (name, object) in fonts.iter().take(256) { - if let Some(font) = doc.resolve(object).as_dict() { - metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); - } + StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } +} + +/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. +fn shape_source( + doc: &Document, + fonts: &Dict, + name: &str, + text: &str, +) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { + if !pdfcraft_fonts::source_shaping::required(text) { + return Ok(None); } - for index in &block.lines { - if let Some(line) = lines.get(*index) - && !metrics.contains_key(&line.font) - && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) + let source = + fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; + let family = Metrics::from_dict(doc, &source).base_font; + let mut candidates = vec![source]; + candidates.extend( + fonts + .iter() + .filter_map(|(_, f)| doc.dict(f)) + .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), + ); + for font in candidates { + if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) + && pdfcraft_fonts::embedding::covers(&bytes, text) + && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) { - metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + return Ok(Some((bytes, shaped, family))); } } - if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) - || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) - { - return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); - } - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { - return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); - } - // A visual row grows even when it contains several source/font runs. Multi-row - // paragraphs retain their container width; an explicit width always wins. - let natural = if block.invisible || block.rows.len() == 1 { - (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) - } else { - block.rect[2] - block.rect[0] + Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) +} + +/// For each output row (a range of the draft's characters), whether it is exactly the source row +/// at the same position: the same characters, none of them inserted or changed by the edit. +fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { + let old: Vec = block.editor_text.chars().collect(); + let new: Vec = draft.chars().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + // The source character a draft character is, if the edit left it alone. + let origin = |i: usize| { + if i < prefix { + Some(i) + } else if i >= new.len().saturating_sub(suffix) { + (old.len() + i).checked_sub(new.len()) + } else { + None + } }; - let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); - // Each tuple is a character, its source style and an encodable resource name. - let mut chars = Vec::new(); - let mut substituted = None; - let mut fallback_groups: Vec = Vec::new(); - let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); - let mut fallback_glyphs = 0_usize; - let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); - let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); - let mut full_faces: HashMap = HashMap::new(); - let mut programs: HashMap>>> = HashMap::new(); - for (range, index) in styled_spans { - let source = lines.get(index).unwrap_or(first); - let start = range.start; - for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - // A carriage return breaks the row too. Before `\n` the pair is one break, so the - // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's - // characters, which rows are counted in. - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); - chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); - continue; - } - let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { - if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { - return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); - } - resource.clone() - } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { - source.font.clone() - } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { - pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) - }) { - name.clone() - } else if let Some(face) = - pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) - { - let key = face.name(); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else if let Some(bytes) = programs - .entry(source.font.clone()) - .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) - .clone() - .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) - { - // The document's own complete program of this font has the glyph: draw it with - // that face rather than another family's fallback. - let key = format!("source {}", source.font); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else { - let family = source_family(&source.base_font); - if needs_type3(&s) { - let key = (source.base_font.clone(), source.bold, ch); - let group_index = if let Some(index) = fallback_names.get(&key) { - *index - } else { - // Keep the old per-character face choice: a serif Japanese character - // and a Cyrillic character may need different faces even in one style. - // Hebrew uses the separate RTL path before reaching this writer. - let (faces, _) = type3_faces(&s, family, source.bold)?; - let face = - fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; - let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { - let index = fallback_groups.len(); - fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); - index - }); - fallback_names.insert(key, index); - index - }; - let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; - if group.seen.insert(ch) { - if fallback_glyphs >= fallback_budget { - return Err(EditError::Invalid(format!( - "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" - ))); - } - let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; - let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) - .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; - group.glyphs.push(Type3Glyph { code, ch, path, width }); - fallback_glyphs = fallback_glyphs.saturating_add(1); - } - group.positions.push(chars.len()); - // Filled from the prepared font/code map before measuring or drawing. - String::new() - } else { - let base = family.base_font(source.bold, source.italic); - let name = format!("PCEd{}", base.replace('-', "")); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); - fonts.set(name.clone().into_bytes(), Object::Dict(f)); - substituted = Some(base.to_string()); - name - } - }; - chars.push((ch, index, name)); + let mut source_rows = Vec::new(); + let mut start = 0; + for (i, ch) in old.iter().enumerate() { + if *ch == '\n' { + source_rows.push(start..i); + start = i + 1; } } - // All fallback outlines have now succeeded. Batch by actual face, preserving source - // style indices; the existing writer then coalesces adjacent equal-font/style characters. - for group in fallback_groups { - let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); - for name in &fallback.names { - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - } - for position in group.positions { - let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; - let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; - item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + source_rows.push(start..old.len()); + // A line break inside a source run would pair editor rows with the wrong visual rows. + if source_rows.len() != block.rows.len() { + return vec![false; rows.len()]; + } + rows.iter() + .enumerate() + .map(|(r, range)| { + source_rows.get(r).is_some_and(|source| { + !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) + }) + }) + .collect() +} + +struct RewriteAnchor<'a> { + lead: &'a TextLine, + drop: std::collections::HashSet, + start: usize, + split: bool, +} + +fn rewrite_anchor<'a>( + block: &TextBlock, + lines: &'a [TextLine], + first: &'a TextLine, + ops: &[Op], + kept_lines: &std::collections::HashSet, + kept: &mut [bool], +) -> RewriteAnchor<'a> { + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|index| !kept_lines.contains(index)).filter_map(|index| lines.get(*index)).collect(); + let lead = rewritten + .last() + .copied() + .filter(|_| !rewritten.iter().flat_map(|line| &line.ops).any(|index| ops.get(*index).is_some_and(|op| op.is("'") || op.is("\"")))); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|value| *value = false); + let all: Vec<&TextLine> = block.lines.iter().filter_map(|index| lines.get(*index)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) } - substituted = Some(fallback.family.to_owned()); + }; + let origin = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|line| line.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(origin.bt_op); + let split = ops + .get(origin.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(index, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(origin.bt_op + index))); + RewriteAnchor { lead, drop, start, split } +} + +/// Rewrite visual rows while keeping the source style on each surviving character. Resource +/// names may differ between Unicode subsets of the same face; encoding searches those subsets. +pub(crate) fn rewrite_styled_block( + doc: &mut Document, + page: usize, + block: &TextBlock, + lines: &[TextLine], + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { + let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + let draft = replacement_text(block, text)?; + if has_no_effective_style_change(block, draft, style) { + return Ok(LineEdit { substituted: None }); + } + let ranges = validated_ranges(draft, style)?; + let source_count = lines.len(); + let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let lines = styled.lines.as_slice(); + let styled_spans = styled.spans; + let underlines = styled.underlines; + let overrides = styled.overrides; + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + let (mut fonts, mut metrics) = page_font_metrics(doc, &res, block, lines); + let (dx, dy, width) = paragraph_geometry(doc, &p, block, style)?; + let prepared = CharacterPreparation { + doc, + first, + lines, + draft, + spans: styled_spans, + style, + overrides: &overrides, + fonts: &mut fonts, + metrics: &mut metrics, + max_glyphs, } + .prepare()?; + let chars = prepared.values; + let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; @@ -400,23 +712,7 @@ pub(crate) fn rewrite_styled_block( spaces.borrow_mut().insert(name.to_owned(), w); w }; - let mut calibration = HashMap::new(); - for (index, source) in lines.iter().enumerate() { - if let Some(m) = metrics.get(&source.font) { - let nominal = source_width( - m, - &source.text, - source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - space_of(&source.font, m), - source.origin.state.char_spacing, - source.origin.state.word_spacing, - ) * source.origin.state.scale - * across(source); - let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); - let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; - calibration.insert(index, factor); - } - } + let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); let advance = |item: &(char, usize, String)| -> f64 { let source = lines.get(item.1).unwrap_or(first); let Some(m) = metrics.get(&item.2) else { return 0.0 }; @@ -466,58 +762,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let mut rows: Vec> = Vec::new(); - // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). - let mut row_ranges = Vec::new(); - let mut row_start = 0; - let mut row = Vec::new(); - let mut row_width = 0.0; - // Wrap at word boundaries, retaining explicit source row breaks and whitespace. - let mut at = 0; - while at < chars.len() { - let Some(item) = chars.get(at) else { break }; - // The CR of a CRLF: the `\n` after it breaks the row. - if item.0 == '\r' { - at += 1; - continue; - } - if item.0 == '\n' { - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - at += 1; - row_start = at; - continue; - } - let mut end = at + 1; - if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) - && !c.0.is_whitespace() - && !crate::linebreak::between(before.0, c.0) - { - end += 1; - } - } - let word = chars.get(at..end).unwrap_or_default(); - let w = measure(word)?; - if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { - while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { - row.pop(); - } - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - row_start = at; - } - row.extend_from_slice(word); - row_width += w; - at = end; - } - if !row.is_empty() { - row_ranges.push(row_start..row_start + row.len()); - rows.push(row); - } + let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -531,65 +776,18 @@ pub(crate) fn rewrite_styled_block( }; let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); - let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted - // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = - rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); - let (members, lead) = match lead { - Some(lead) if !kept_lines.is_empty() => (rewritten, lead), - _ => { - kept.iter_mut().for_each(|k| *k = false); - let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let last = all.last().copied().unwrap_or(first); - (all, last) - } - }; + // The rewrite is placed after the last rewritten row unless a text-show operator there + // would make a kept following row depend on that position. + let anchor = rewrite_anchor(block, lines, first, &ops, &kept_lines, &mut kept); + let RewriteAnchor { lead, drop, start, split } = anchor; let o = &lead.origin; - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; - for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { - let text: String = row.iter().map(|v| v.0).collect(); - if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { - return Err(EditError::Invalid( - "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" - .into(), - )); - } - } - let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; - let mut shaped_runs = HashMap::new(); - let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { - let mut at = 0; - while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); - let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { - shaped_runs.insert((row_index, at), (bytes, shaped, family)); - } - at = end; - } - } - let mut glyph_resources = HashMap::new(); - for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); - } + validate_contextual_rows(&rows, &kept)?; + let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; + let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; + let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs index 893a0e4d24..fe1aa78688 100644 --- a/crates/edit/src/text_session.rs +++ b/crates/edit/src/text_session.rs @@ -3,7 +3,10 @@ //! another one's output; the commit writes into the document it was opened on, and refuses if //! that document has changed since. -use super::*; +use super::{ + BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from, + text_lines, +}; /// One paragraph being edited: the document as it was opened, and the page's lines then. pub struct TextSession { From 166f706e3e73ae69ef15931b467d1c87bcbe5e98 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 08/38] Refactor engine for strict Clippy checks --- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/edit/labels.rs | 2 +- crates/engine/src/edit/run.rs | 5 +- crates/engine/src/session/editing.rs | 9 +- crates/engine/src/session/files.rs | 6 +- crates/engine/src/session/save.rs | 5 +- crates/engine/src/session/view.rs | 5 +- crates/engine/src/tests.rs | 149 +++++++++++++++------------ 8 files changed, 110 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d25cc65037..d28429de6f 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5e25ae465b..ea25898068 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table}; /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7901a3b7d8..7bf5467fcf 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 9b1a8340a5..f8d5648a2d 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -1,4 +1,11 @@ -use super::*; +#[cfg(test)] +use super::check_refresh_panic; +use super::{ + Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings, + render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, + write_incremental, xfa, xfa_sync_datasets, +}; impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 6d4e11d390..1e8cd9b08a 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 23994aee37..d4156a2eb1 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard, + render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, +}; impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index cef539189a..055437f562 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, + apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, +}; impl Session { pub fn new() -> Self { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e7e68a7588..14a61b2633 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3068,7 +3068,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -3077,7 +3082,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -3101,7 +3108,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -8106,7 +8115,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { // Redo now succeeds cleanly. assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; @@ -8124,7 +8138,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { assert_eq!(s.get(id).unwrap().info.title, None); assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; s.apply(id, Edit::Protect(protect)).unwrap(); @@ -8720,69 +8737,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} From 5e75b9c3e6c48e44da1d1e2223d3115ce9416a01 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 09/38] Refactor export for strict Clippy checks --- crates/export/src/as_docx.rs | 2 +- crates/export/src/as_rtf.rs | 2 +- crates/export/src/layout.rs | 2 +- crates/export/src/lib.rs | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index d8fb84f7d4..ab7451dc0e 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ +use super::Zip; use super::as_html::esc; use super::layout::{Image, Item, Page, Table, body_size, items}; -use super::*; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs index d7495df135..61585d82fb 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -1,7 +1,7 @@ use super::as_docx::rgb8; use super::as_html::allowed; use super::layout::{Block, Item, Page, count_lookup_steps, items}; -use super::*; +use std::collections::HashMap; fn rtf_text(s: &str) -> String { let mut o = String::new(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 8a06050898..d1d074ac61 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use std::collections::HashMap; /// A paragraph of a page. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index cf32ea7930..5275b02439 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -10,8 +10,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; - mod pptx; mod zip; From 4965e23bfe4f2b0e37f5d978654e20594885e538 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 10/38] Refactor forms for strict Clippy checks --- crates/forms/src/af/script.rs | 2 +- crates/forms/src/appearances.rs | 2 +- crates/forms/src/author/edit.rs | 231 +++++++++++++++------------ crates/forms/src/author/lifecycle.rs | 5 +- crates/forms/src/author/look.rs | 2 +- crates/forms/src/author/tree.rs | 2 +- crates/forms/src/read.rs | 2 +- crates/forms/src/tab_order.rs | 2 +- crates/forms/src/tests.rs | 203 +++++++++++++---------- crates/forms/src/values.rs | 5 +- 10 files changed, 263 insertions(+), 193 deletions(-) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index c807f2d7d7..9aed297b45 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -1,6 +1,6 @@ use super::calc::{js_tokens, notation_js, notation_range}; use super::format::{DATE_PRESETS, TIME_PRESETS}; -use super::*; +use super::{CalcOp, Calculate, Format, Validate}; // ── parsing scripts ───────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index d035feb4ef..64569fa3ca 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -1,6 +1,6 @@ use super::read::{acroform, fields}; use super::values::merged_appearance; -use super::*; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4fc..d61cfe28f2 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,6 +1,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; -use super::*; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; /// Swap width and height, keeping the center. `None` when that is not a usable widget. fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { @@ -70,59 +70,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } + let new_name = rename_field(doc, &all, &f, name, props)?; // The new default appearance when the size or the look changes. let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); let new_da = match &props.look { @@ -248,51 +196,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } + apply_widget_style_props(doc, &f, name, props)?; if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); @@ -341,6 +245,127 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result { + let mut new_name = name.to_string(); + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok(new_name) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -348,7 +373,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Dict { let mut d = Dict::new(); diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 6044bdc70b..23661ed70a 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 4f7db4d8e1..d8ca67ffb7 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; /// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. pub(crate) fn ensure_form(doc: &mut Document) -> Result { diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index a962af91bd..e689c0e813 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -1,5 +1,5 @@ use super::tab_order::rank_tabs; -use super::*; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 9c5c33b694..77234ee094 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -1,5 +1,5 @@ use super::read::{fields, page_refs}; -use super::*; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..d8199f15d3 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() { }) .unwrap(); + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name @@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { - let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { - if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { - continue; - } - if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { continue; } cases += 1; - let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); - let mut entries = String::new(); - match helv { - "inline" => entries += &format!("/Helv {} ", font("Arial")), - "object" => entries += "/Helv 7 0 R ", - "dangling" => entries += "/Helv 99 0 R ", - "null" => entries += "/Helv null ", - "number" => entries += "/Helv 3 ", - _ => {} - } - match mine { - "inline" => entries += &format!("/Mine {} ", font("Symbol")), - "object" => entries += "/Mine 8 0 R ", - _ => {} - } - let fonts_value = match font_shape { - "inline" => format!("<< {entries}>>"), - "object" => "6 0 R".into(), - "number" => "7".into(), - _ => String::new(), - }; - let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; - let dr_value = match dr_shape { - "inline" => dr_dict.clone(), - "object" => "5 0 R".into(), - _ => String::new(), - }; - let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; - let mut doc = document(&[ - "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R >>".into(), - af, - if dr_shape == "object" { dr_dict } else { "null".into() }, - if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, - font("Helvetica-Oblique"), - font("ZapfDingbats"), - ]); - add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) - .unwrap_or_else(|e| panic!("{what}: {e}")); - let doc = reopen(&doc); - let af = acroform(&doc).unwrap(); - let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); - let fonts = dr_fonts(&doc); - for (name, value) in fonts.iter() { - assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); - } - for name in ["Helv", "TiRo", "Cour"] { - assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); - } - let base = - |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); - match helv { - "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), - "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), - "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), - "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), - _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), - } - match mine { - "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), - _ => assert!(!fonts.contains(b"Mine"), "{what}"), - } - if dr_shape == "object" { - assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); - } - if font_shape == "object" { - assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); - } + assert_font_shape_case(&case); } } } @@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() { assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a70..37df5502d1 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,6 +1,9 @@ use super::appearances::{declares_pdfa, need_appearances}; use super::read::{acroform, field_flags, fields, page_refs}; -use super::*; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author, + calc_order, flags, invalid, name_obj, scripting, +}; // ── writing ───────────────────────────────────────────────────────────────────────────────── From a0598816d2c9fd043b9c3d8fec22e8f44bae720e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 11/38] Refactor js for strict Clippy checks --- crates/js/src/document.rs | 2 +- crates/js/src/format.rs | 2 +- crates/js/src/formcalc/access.rs | 5 +- crates/js/src/formcalc/ast.rs | 2 +- crates/js/src/formcalc/builtins.rs | 67 +++++++---- crates/js/src/formcalc/eval.rs | 5 +- crates/js/src/formcalc/lexer.rs | 2 +- crates/js/src/formcalc/parser.rs | 2 +- crates/js/src/globals.rs | 2 +- crates/js/src/host.rs | 5 +- crates/js/src/run.rs | 2 +- crates/js/src/tests.rs | 173 ++++++++++++++++++++--------- crates/js/src/xfa/globals.rs | 5 +- crates/js/src/xfa/host.rs | 2 +- crates/js/src/xfa/instances.rs | 2 +- crates/js/src/xfa/model.rs | 2 +- crates/js/src/xfa/nodes.rs | 5 +- crates/js/src/xfa/run.rs | 2 +- 18 files changed, 201 insertions(+), 86 deletions(-) diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index 3d0fe194ee..e977457e2b 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -1,6 +1,6 @@ use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; macro_rules! getter { ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 5f2eed1021..7f207b1916 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -1,6 +1,6 @@ use super::host::{arg, error, s, text}; use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; -use super::*; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; /// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups /// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 3911a5223f..bffbd8823d 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Idx}; use super::value::{Val, num_text, parse_num}; -use super::*; +use super::{ + CLOCK_EVERY, HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, + instances, resolve_som, +}; impl Interp<'_> { pub(super) fn step(&mut self) -> R<()> { diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs index f9a88be798..ab453a8061 100644 --- a/crates/js/src/formcalc/ast.rs +++ b/crates/js/src/formcalc/ast.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Func, Rc}; // ── syntax ────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index 1e7bb94245..c3b7c7f1f2 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -3,7 +3,7 @@ use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, pars use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; use super::words::word_num; -use super::*; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { /// A built-in function: its result is never a string past [`MAX_STRING`], and work on @@ -22,6 +22,15 @@ impl Interp<'_> { fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { let lower = name.to_ascii_lowercase(); let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } Ok(match lower.as_str() { // ── arithmetic "abs" => return n(self.arg_num(args, 0)?.abs()), @@ -271,7 +280,24 @@ impl Interp<'_> { let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); parse_num(&cleaned).map_or(Val::Null, Val::Num) } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "date" => { #[cfg(not(target_arch = "wasm32"))] { @@ -304,7 +330,7 @@ impl Interp<'_> { let days = self.arg_num(args, 0)?; let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let (y, m, d) = civil_from_days(days as i64); Val::Str(format_date(y, m, d, &pic)) @@ -326,7 +352,7 @@ impl Interp<'_> { "num2time" | "num2gmtime" => { let ms = self.arg_num(args, 0)?; if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let s = (ms / 1000.0) as u64; Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) @@ -344,7 +370,13 @@ impl Interp<'_> { parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) } - // ── financial + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "fv" => { let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) @@ -428,7 +460,13 @@ impl Interp<'_> { } Val::Num((lo + hi) / 2.0 * 12.0) } - // ── units and references + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "unittype" => { let s = self.arg_str(args, 0)?; let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); @@ -459,19 +497,8 @@ impl Interp<'_> { let target = if to.trim().is_empty() { unit.clone() } else { to }; Val::Num(v / per_in(&unit) * per_in(&target)) } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) + _ => return Ok(None), + }; + Ok(Some(value)) } } diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index 23ce3ec0c1..a3218a78f6 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Stmt}; use super::value::{Val, is_numeric_text, num_text}; -use super::*; +use super::{ + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip, + descendants_named, instances, reset_names, resolve_som, +}; impl Interp<'_> { // ── expressions diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index ba9ae4f16a..46af19f761 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -1,4 +1,4 @@ -use super::*; +use super::MAX_STRING; // ── lexer ─────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index 84cf121cde..fceedadf5f 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -1,6 +1,6 @@ use super::ast::{Expr, Idx, Stmt}; use super::lexer::{Tok, Token, lex}; -use super::*; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; struct Parser { toks: Vec, diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index dd487c2482..a9ad4d2b8f 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -2,7 +2,7 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie use super::format::{printd, printf, printx, scand}; use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; // ── app, util, console ────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 131b8851c9..6e442d2a2b 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -1,6 +1,9 @@ use super::model::{DocInfo, FieldState, FieldType, Request}; use super::run::MAX_ARRAY_LENGTH; -use super::*; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell, + xfa, +}; pub(crate) struct Host { pub(crate) fields: Arc>, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 17b5c252ff..07c0e984bb 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -1,7 +1,7 @@ use super::globals::{event_object, install}; use super::host::{Host, Shared, text}; use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; -use super::*; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 9e96dcd2e3..38cebf54ce 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1017,6 +1017,14 @@ mod formcalc { #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); @@ -1034,6 +1042,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -1050,6 +1061,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -1065,6 +1081,9 @@ mod formcalc { assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); @@ -1073,8 +1092,9 @@ mod formcalc { assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } @@ -1147,6 +1167,13 @@ s"# #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -1170,7 +1197,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -1182,12 +1208,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -1215,7 +1247,9 @@ table._row.count"#, XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#, #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); + assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); - let o = run("qty = Space(100000)"); + let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + fn run_hostile_formcalc(script: &str) -> XfaOutcome { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 2507941658..54508e6cab 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -2,7 +2,10 @@ use super::host::host; use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; -use super::*; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index 419d2141fc..ea2d4d4332 100644 --- a/crates/js/src/xfa/host.rs +++ b/crates/js/src/xfa/host.rs @@ -2,7 +2,7 @@ use super::model::{ MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, effect_key, payload, }; -use super::*; +use super::{Context, HashMap, JsResult, RefCell, error}; /// One node of the flattened form, shared by the JavaScript and FormCalc engines. pub(crate) struct HNode { diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs index b03e6aef09..c099c5abe8 100644 --- a/crates/js/src/xfa/instances.rs +++ b/crates/js/src/xfa/instances.rs @@ -1,7 +1,7 @@ use super::host::{HNode, XHost, host}; use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; use super::nodes::{instances, node_object}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index b9c6cedaae..83b2d7d149 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashSet; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs index b9483f25d1..75943352e6 100644 --- a/crates/js/src/xfa/nodes.rs +++ b/crates/js/src/xfa/nodes.rs @@ -2,7 +2,10 @@ use super::globals::{recalculate, relayout}; use super::host::{HNode, XHost, host}; use super::instances::{instance_manager, list_object}; use super::model::{MAX_DEPTH, XfaEffect}; -use super::*; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; // ── node objects ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs index 2db77d7062..f951e02a54 100644 --- a/crates/js/src/xfa/run.rs +++ b/crates/js/src/xfa/run.rs @@ -1,7 +1,7 @@ use super::globals::install; use super::host::{Shared, XHost}; use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; -use super::*; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; /// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { From f40a57878b993cea1bb5574c627af1bed921d225 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 12/38] Refactor organize for strict Clippy checks --- crates/organize/src/import/carry.rs | 2 +- crates/organize/src/import/combine.rs | 2 +- crates/organize/src/import/copy.rs | 2 +- crates/organize/src/import/fields.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 21c21b9be3..15a412e231 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -1,5 +1,5 @@ use super::copy::Copier; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 09a1b0725a..06972ae101 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -1,5 +1,5 @@ use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; -use super::*; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index f78411806f..f4b5499625 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs index 98f69c69ec..44910d0406 100644 --- a/crates/organize/src/import/fields.rs +++ b/crates/organize/src/import/fields.rs @@ -1,5 +1,5 @@ use super::copy::WIDGET_KEYS; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; /// Rename the top-level fields about to be registered in `dst` whose fully-qualified names /// (`parent.T.child`) are already taken, so each imported field stays its own field instead of From 530e2a26450541654b1e0a951e01e50eac180c6f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 13/38] Refactor pdfcraft-cli for strict Clippy checks --- apps/pdfcraft-cli/src/check.rs | 4 +++- apps/pdfcraft-cli/src/commands.rs | 2 +- apps/pdfcraft-cli/src/options.rs | 2 +- apps/pdfcraft-cli/src/run.rs | 4 +++- apps/pdfcraft-cli/src/ui.rs | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 1821f85826..62993ad6dc 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -1,6 +1,8 @@ use super::commands::{parse_dpi, read}; use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; /// Audit a document for faux redactions. Prints the findings as JSON; fails when any are /// found so scripts and CI can gate on it, and when the audit itself cannot run. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 5efec8331c..9d0b2057f0 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -2,7 +2,7 @@ use super::options::{ COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, validate_options, }; -use super::*; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; pub(crate) fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs index ab1a3db34b..1ddbfabcd5 100644 --- a/apps/pdfcraft-cli/src/options.rs +++ b/apps/pdfcraft-cli/src/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::CliError; pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs index 19eed21876..0726087d9d 100644 --- a/apps/pdfcraft-cli/src/run.rs +++ b/apps/pdfcraft-cli/src/run.rs @@ -1,7 +1,9 @@ #[cfg(feature = "mcp")] use super::options::MCP_OPTIONS; use super::options::{RUN_OPTIONS, flag, positional, validate_options}; -use super::*; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; // ---- automation -------------------------------------------------------------------------------- diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index 9d12233528..dc419171b0 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -1,5 +1,5 @@ use super::options::{UI_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{CliError, Duration, stdout_line}; // ---- UI control channel client ----------------------------------------------------------------- From c043c82531526d976762fb0ca9e0e16ea5b6e05e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 14/38] Refactor preflight for strict Clippy checks --- crates/preflight/src/lib.rs | 230 +++++++++++++++++++++++------------- 1 file changed, 145 insertions(+), 85 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 13ca8997e3..3fc6424808 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -514,97 +514,157 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { + check_objects(doc, level, &objs, &page_of, &mut issue); + // One message per kind of problem is enough for repeated ones (as preflight summaries do). + let mut seen = std::collections::HashSet::new(); + out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); + out +} + +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role), with the actions they - // chain to and those written directly in an /A or /OpenAction. - let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); - let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); - for a in [own].into_iter().chain(direct).flatten() { - if let Some(s) = chain_forbidden(doc, a) { - issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); - } + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) { + check_annotation(doc, dict, subtype, page_of(reference), issue); + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); } + Level::A3b => {} } - // One message per kind of problem is enough for repeated ones (as preflight summaries do). - let mut seen = std::collections::HashSet::new(); - out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); - out } fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { From bc89733725c41f8373d28c1595370221362ea7ad Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 15/38] Refactor print for strict Clippy checks --- crates/print/src/imposition.rs | 2 +- crates/print/src/settings.rs | 2 +- crates/print/src/sheets.rs | 61 +++++++++++++++++++--------------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 1257bcf25e..aa9a501f66 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::settings::{Content, Settings}; use super::sheets::{Sheet, layout}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; // Model-derived transforms and clipping bounds need their full PDF decimal precision. fn geometry_number(value: f64) -> String { diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 7c9554f25e..0506eb5b18 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::spool; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index ce80adc3eb..061a6ed5c3 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::imposition::decoded_contents; use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects @@ -162,32 +162,7 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } } Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); @@ -348,3 +323,35 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } Ok(sheets) } + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} From 33e23da8a92b0d344bc93e2e6cef180520298b88 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 16/38] Refactor redact for strict Clippy checks --- crates/redact/src/interp.rs | 530 +++++++++++++++++------------ crates/redact/src/interp/layers.rs | 2 +- crates/redact/src/interp/prune.rs | 5 +- crates/redact/src/lib.rs | 398 ++++++++++++---------- crates/redact/src/type3.rs | 66 ++-- 5 files changed, 558 insertions(+), 443 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 71b4684bc7..3baec29c2c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -242,32 +242,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -277,12 +266,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -292,228 +301,297 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + stack: Vec, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), + } + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; + } + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) + } + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push(self.gs.clone()), + b"Q" => { + if let Some(g) = self.stack.pop() { + self.gs = g; } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; } - _ => {} } - ops.push(op); + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); + } + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} } - if !path.is_empty() { - ops.append(&mut path); + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None + } + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); } - processed.push((ops, changed)); + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); - } - bytes - })); + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs index 32d7afdf1e..88d1c4e677 100644 --- a/crates/redact/src/interp/layers.rs +++ b/crates/redact/src/interp/layers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; impl<'a> Scope<'a> { /// Is this optional-content reference (an OCG or OCMD) hidden? diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2708a87a42..6ca5ea2803 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; /// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, /// so a full-rewrite save stops reaching the original image or form it named. A name that some diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d89..de9dafe7db 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -702,6 +533,213 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab902..57971571af 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; - } - - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; + } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> { From a11909551d5027cc6b509404a94cebc1e8f7f075 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 17/38] Refactor render for strict Clippy checks --- crates/render/src/inspect/annotations.rs | 2 +- crates/render/src/inspect/core.rs | 5 ++++- crates/render/src/inspect/model.rs | 2 +- crates/render/src/inspect/navigation.rs | 5 ++++- crates/render/src/inspect/resources.rs | 2 +- crates/render/src/raster/pool.rs | 6 +++++- crates/render/src/text/device.rs | 5 ++++- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/order.rs | 2 +- 9 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 07283c0473..603aa88110 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,7 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; use super::navigation::text_string; -use super::*; +use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 875d873654..c190dc565b 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,7 +1,10 @@ use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; -use super::*; +use super::{ + Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object, + ObjectId, OnceCell, OpenError, Pdf, Structure, catch_unwind, +}; /// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is /// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index e5f09c9242..490897b60f 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashMap; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 173217c93b..4000a74135 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -2,7 +2,10 @@ use super::core::inspect; use super::core::spend; use super::model::{DestView, OutlineItem, PageInfo}; -use super::*; +use super::{ + BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, + MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, +}; /// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document /// reads only this window; the rest are listed by page through `pdfcraft_organize`. diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 0386c5781f..0895c64f8a 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -3,7 +3,7 @@ use super::core::inspect; use super::core::{Inspector, load_options}; use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; use super::navigation::NameTreeSeen; -use super::*; +use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind}; /// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to /// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. diff --git a/crates/render/src/raster/pool.rs b/crates/render/src/raster/pool.rs index 81447263d8..66a575eaee 100644 --- a/crates/render/src/raster/pool.rs +++ b/crates/render/src/raster/pool.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, AssertUnwindSafe, AtomicBool, AtomicRenderStats, InterpreterSettings, JoinHandle, Mutex, Ordering, PageRenderer, Pdf, Pixels, Receiver, + RenderCache, RenderConfig, RenderRequest, RenderStats, RenderedPage, RequestFailureKey, RequestKind, ResultReceiver, Stopwatch, SyncSender, + WorkQueue, catch_unwind, finish, panic_message, parse, render_page, sync_channel, take_warnings, warning_settings, +}; /// How long one page may render before the pool gives up on it (the watchdog). pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20); diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index eba4d9c1c3..79bf05f7db 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,5 +1,8 @@ use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; -use super::*; +use super::{ + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, + Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, +}; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be /// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 302184aebc..00b45e266c 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,6 +1,6 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl}; -use super::*; +use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. pub(super) struct Flow { diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 6892d3f2ab..d8cb65f32c 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,5 +1,5 @@ +use super::TextGlyph; use super::layout::{is_rtl, union}; -use super::*; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. #[derive(Clone, Copy, Debug, PartialEq, Eq)] From 074d4f311198a241461808f691e90a1003dcf58d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 18/38] Refactor sign for strict Clippy checks --- crates/sign/src/pdf/changes.rs | 180 +++++++++++++++++++------------- crates/sign/src/pdf/listing.rs | 2 +- crates/sign/src/pdf/signing.rs | 2 +- crates/sign/src/pdf/types.rs | 2 +- crates/sign/src/pdf/validate.rs | 4 +- 5 files changed, 116 insertions(+), 74 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 2c66418a6c..3a10a74e41 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -1,5 +1,5 @@ use super::types::{Modification, kind}; -use super::*; +use super::{Arc, Dict, Document, HashMap, HashSet, ObjRef, Object}; /// What later revisions changed, classified as Acrobat reports it, under DocMDP `p` (or none: /// an approval signature permits form fill, comments and further signatures). @@ -72,6 +72,18 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if doc.root() != old.root() { disallowed.push(kind::STRUCTURE); } + let classifier = ChangedObjectClassifier { + doc, + old: &old, + old_content: &old_content, + old_metadata, + old_xfa_data: &old_xfa_data, + signed_pages: &signed_pages, + signed_annots: &signed_annots, + signed_resources: &signed_resources, + old_info, + dss: &dss, + }; for num in doc.object_numbers() { if same_place(num) { continue; @@ -93,75 +105,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< // graphics state does not make that a form fill. A container (the resources dictionary, // a category dictionary) that kept every entry it had and only gained names changes // nothing the signed content draws, and is judged as before. - let kept_entries = |d: &Dict| before.as_deref().and_then(Object::as_dict).is_some_and(|b| b.iter().all(|(k, v)| d.get(k) == Some(v))); - let resource_kind = match (signed_resources.get(&r), &*new) { - (Some(false), _) => Some(kind::PAGE_CONTENT), - (Some(true), Object::Dict(d)) if kept_entries(d) => None, - (Some(true), _) => Some(kind::PAGE_CONTENT), - (None, _) => None, - }; - let what = match (resource_kind, &*new) { - (Some(k), _) => k, - // The Document Security Store's own arrays and dictionaries, when they only grew. - (_, _) if dss.contains(&num) => kind::SECURITY_STORE, - // A signed page's indirect /Annots array, still an array: what it lost, then what it - // gained. Dropping a signed annotation is never hidden behind an added signature. - (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { - let was: HashSet = - before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); - let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); - let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); - if removed.is_empty() { - let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); - added_annots(doc, &added) - } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { - // A form field or signature widget taken off the page: no permission covers it. - kind::OTHER - } else { - // Deleting comments is a comment change (permitted at P 3 only). - kind::COMMENTS - } - } - (_, Object::Dict(d)) => { - let was = before.as_deref().and_then(Object::as_dict); - if signed_pages.contains(&r) { - // A signed page is always judged by what the page shows, whatever it is now - // relabelled: a rewritten /Contents or /Resources is a page content change. - page_change_kind(doc, &old, d, was) - } else if was.is_some() && Some(r) == old_info { - // Only the signed revision's own /Info dictionary is a metadata change. - kind::METADATA - } else { - change_kind(doc, &old, d, was) - } - } - (_, Object::Stream(s)) => { - // File plumbing is what the stream was when signed: the /Type it has now is - // written by whoever changed it, and the renderer ignores /Type (a page's Form - // XObject relabelled /ObjStm still draws its new content). - let was_plumbing = matches!(before.as_deref(), Some(Object::Stream(b)) if matches!(b.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); - if old_content.contains(&r) { - kind::PAGE_CONTENT - } else if before.is_none() || was_plumbing { - continue; - } else if s.dict.name(b"Type") == Some(b"Metadata") && Some(r) == old_metadata { - // Only the signed catalog's XMP stream. A /Type /Metadata label on anything - // else, even one it already had when signed (a page's Form XObject: the - // renderer ignores /Type), does not make rewriting it a metadata change. - kind::METADATA - } else if old_xfa_data.contains(&r) { - // The form's XFA data (`datasets`) or state (`form`) as the signed revision - // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) - // save of a certified form does (#2759). The role comes from the signed - // revision, so a later one can't name page content as XFA data. - kind::FORM_FILL - } else { - kind::OTHER - } - } - _ if before.is_none() => continue, - _ => kind::OTHER, - }; + let Some(what) = classifier.classify(r, &new, before.as_deref()) else { continue }; let ok = match (what, p) { (kind::SIGNATURE | kind::SECURITY_STORE, _) => true, (kind::FORM_FILL, Some(2 | 3) | None) => true, @@ -195,6 +139,102 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +struct ChangedObjectClassifier<'a> { + doc: &'a Document, + old: &'a Document, + old_content: &'a HashSet, + old_metadata: Option, + old_xfa_data: &'a HashSet, + signed_pages: &'a HashSet, + signed_annots: &'a HashSet, + signed_resources: &'a HashMap, + old_info: Option, + dss: &'a HashSet, +} + +impl ChangedObjectClassifier<'_> { + fn classify(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + if let Some(kind) = self.resource_kind(r, new, before) { + return Some(kind); + } + if self.dss.contains(&r.num) { + return Some(kind::SECURITY_STORE); + } + match new { + Object::Array(now) if self.is_signed_annotation_array(r, before) => Some(self.annotation_array_change(now, before)), + Object::Dict(dict) => Some(self.dictionary_change(r, dict, before)), + Object::Stream(stream) => self.stream_change(r, stream, before), + _ if before.is_none() => None, + _ => Some(kind::OTHER), + } + } + + fn resource_kind(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + match (self.signed_resources.get(&r), new) { + (Some(false), _) => Some(kind::PAGE_CONTENT), + (Some(true), Object::Dict(dict)) if self.keeps_previous_entries(dict, before) => None, + (Some(true), _) => Some(kind::PAGE_CONTENT), + (None, _) => None, + } + } + + fn keeps_previous_entries(&self, dict: &Dict, before: Option<&Object>) -> bool { + before.and_then(Object::as_dict).is_some_and(|previous| previous.iter().all(|(key, value)| dict.get(key) == Some(value))) + } + + fn is_signed_annotation_array(&self, r: ObjRef, before: Option<&Object>) -> bool { + self.signed_annots.contains(&r) && before.is_some_and(|object| object.as_array().is_some()) + } + + fn annotation_array_change(&self, now: &[Object], before: Option<&Object>) -> &'static str { + let was: HashSet = + before.and_then(Object::as_array).map(|items| items.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|reference| !kept.contains(reference)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|reference| !was.contains(reference)).collect(); + added_annots(self.doc, &added) + } else if removed.iter().any(|reference| self.old.get(*reference).as_dict().is_some_and(|dict| dict.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } + } + + fn dictionary_change(&self, r: ObjRef, dict: &Dict, before: Option<&Object>) -> &'static str { + let previous = before.and_then(Object::as_dict); + if self.signed_pages.contains(&r) { + // A signed page is judged by what the page shows, whatever its new labels say. + page_change_kind(self.doc, self.old, dict, previous) + } else if previous.is_some() && Some(r) == self.old_info { + // Only the signed revision's own /Info dictionary is a metadata change. + kind::METADATA + } else { + change_kind(self.doc, self.old, dict, previous) + } + } + + fn stream_change(&self, r: ObjRef, stream: &pdfcraft_cos::Stream, before: Option<&Object>) -> Option<&'static str> { + // File plumbing is the stream's role when signed; a later /Type label cannot change it. + let was_plumbing = matches!(before, Some(Object::Stream(previous)) if matches!(previous.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); + if self.old_content.contains(&r) { + Some(kind::PAGE_CONTENT) + } else if before.is_none() || was_plumbing { + None + } else if stream.dict.name(b"Type") == Some(b"Metadata") && Some(r) == self.old_metadata { + // Only the signed catalog's XMP stream is metadata. + Some(kind::METADATA) + } else if self.old_xfa_data.contains(&r) { + // Signed XFA data and state packets are changed by a form fill. + Some(kind::FORM_FILL) + } else { + Some(kind::OTHER) + } + } +} + /// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` /// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one /// XFA stream holds its template too, and is not listed. diff --git a/crates/sign/src/pdf/listing.rs b/crates/sign/src/pdf/listing.rs index 88379b1836..cbcf339715 100644 --- a/crates/sign/src/pdf/listing.rs +++ b/crates/sign/src/pdf/listing.rs @@ -1,6 +1,6 @@ use super::types::{Modification, Status, TrustStore}; use super::validate::{is_doc_timestamp, validate_doc_timestamp, validate_into}; -use super::*; +use super::{Arc, Dict, DigestAlg, Document, HashMap, HashSet, ObjRef, Object, SignatureInfo}; /// Digests of signed byte ranges (and parsed signed revisions), kept across revalidations of /// one document: incremental edits only append to the file, so the signed bytes and their diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index 0d00c68a61..71dd10b302 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -1,5 +1,5 @@ use super::listing::{mk_rotation, nums, sig_fields}; -use super::*; +use super::{Certificate, Dict, DigestAlg, DigitalId, Document, HashSet, Object, PdfString, SaveOptions, SignError, SignedData, Stream}; // ── signing ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/sign/src/pdf/types.rs b/crates/sign/src/pdf/types.rs index c061bc3699..881f503573 100644 --- a/crates/sign/src/pdf/types.rs +++ b/crates/sign/src/pdf/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Certificate; /// Certificates the user trusts for signing (Acrobat: Trusted Certificates). #[derive(Clone, Debug, Default)] diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 1f0b8c3424..668798c20b 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -1,7 +1,9 @@ use super::changes::classify_changes; use super::listing::{DigestCache, list, nums, sig_fields, text}; use super::types::{Detail, Fill, Modification, Status, TrustStore}; -use super::*; +use super::{ + Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, +}; /// Validate one signature dictionary. pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option { From e7a60f2fd4871284c35a0b5737b0b7e5f7415325 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 19/38] Refactor ui canvas for strict Clippy checks --- crates/ui-egui/src/canvas.rs | 3725 +++++++++++++---------- crates/ui-egui/src/canvas/area.rs | 2 +- crates/ui-egui/src/canvas/bars.rs | 5 +- crates/ui-egui/src/canvas/cache.rs | 5 +- crates/ui-egui/src/canvas/find.rs | 2 +- crates/ui-egui/src/canvas/navigation.rs | 69 +- crates/ui-egui/src/canvas/organize.rs | 95 +- crates/ui-egui/src/canvas/selection.rs | 2 +- crates/ui-egui/src/canvas/xform.rs | 2 +- crates/ui-egui/src/canvas/zoom.rs | 2 +- 10 files changed, 2306 insertions(+), 1603 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index edcd645693..adf22626b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -808,200 +808,1323 @@ impl DocView { } } -/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a -/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. -pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Keyboard scrolling applies to this frame's page view only, never later. - let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { - app.views[index].end_find_following(); +/// The renders a frame wants (page, scale, tag, tile), and the pages visible. +type PageRenders = (Vec<(usize, f32, u64, Option)>, Vec); + +/// What the page-scroll closure reads: this frame's zoom, scrolling, tool and document state. +#[derive(Clone, Copy)] +struct PageEnv<'a> { + added: &'a Vec, + allowed: bool, + author: &'a String, + auto_delta: Vec2, + avail: Rect, + boost_extra: Vec2, + by_line: &'a String, + calc_order: bool, + can_copy: bool, + can_crop: bool, + can_fill: bool, + can_modify: bool, + comments_hidden: bool, + content_h: f32, + content_w: f32, + custom_stamp: &'a Option, + date_text: &'a Result, + doc: &'a pdfcraft_engine::Document, + doc_links: &'a Vec, + edge_delta: Vec2, + editing_content: bool, + form: &'a Arc>, + generation: u64, + glide: Vec2, + hand: bool, + info: &'a DocInfo, + initials: &'a Option, + key_scroll: f32, + middle_gesture: bool, + modal: bool, + navigation_enabled: bool, + paper: Color32, + ppp: f32, + precise_scroll: Vec2, + prefs: &'a comments::CommentPrefs, + preparing: bool, + rects: &'a Vec, + redact_mark: ([f64; 3], f64), + scale: f32, + selection_wheel: bool, + selects_text: bool, + signature: &'a Option, + single_paging: bool, + t: &'a Tokens, + table_preview: &'a Option, + tag: u64, + text_style: &'a pdfcraft_engine::AddedText, + tool: QuickTool, + units: crate::units::Unit, + unobstructed: bool, + visible_pages: &'a Vec, + y_shift: f32, + fill_text_size: f64, + today: (i64, u32, u32), +} + +/// What the page-scroll closure produces for the rest of the frame. +#[derive(Default)] +struct PageOut { + badge_hover: Option<(Pos2, String)>, + canvas_action: Option, + clicked_link: Option, + copy_selected: bool, + field_menu: Option, + field_placed: bool, + field_props: bool, + hover_text: Option<(Pos2, String)>, + image_action: Option, + open_boxes: bool, + open_initials: bool, + open_signature: bool, + open_table: Option<(usize, [f64; 4])>, + refused: Option, + stamp_placed: bool, +} + +/// The page-scroll closure's body: lays out and draws the visible pages, their tools and overlays, and +/// returns the renders wanted and the pages visible. +/// The state of one pass over the visible pages: what is read, and what the pages add up to. +struct PageFrame<'a> { + env: PageEnv<'a>, + resp: &'a egui::Response, + ptr: &'a PagePointer, + origin: Pos2, + visible: Rect, + pressed: bool, + pointer: Option, + signature_preview: &'a mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, + out: PageOut, + wanted: Vec<(usize, f32, u64, Option)>, + visible_now: Vec, + current: usize, + best_overlap: f32, + current_overlap: f32, + current_height: f32, + pressed_page: Option, + calc_hover: Option, +} + +/// Visible heights (points) closer than this are equal: one page size can meet the viewport a +/// fraction of a point differently once the scroll offset is applied. +const PAGE_TIE: f32 = 0.5; + +/// Draws page `i` of the visible pages: its raster, the tools' input and overlays. +/// One visible page: its index, its rectangle on screen and the transform to and from the page. +#[derive(Clone, Copy)] +struct PageSlot { + i: usize, + r: Rect, + xf: PageXform, +} + +/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders +/// it wants. +fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) { + let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let info = frame.env.info; + let painter = ui.painter(); + // Shadows would darken the preceding page when pages touch. + if view.show_page_gaps { + painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); + } + painter.rect_filled(r, CornerRadius::ZERO, paper); + if let Some(err) = view.errors.get(&i) { + painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); + icons::paint( + ui, + Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), + "triangle-alert", + 26.0, + Color32::from_rgb(0xC8, 0x3A, 0x3A), + ); + let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); + let msg = ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); + painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); + } else { + let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); + // A GPU may take smaller textures than these (OpenGL drivers report as little as + // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); + // Whole-page raster: sharp when small, a low-res backdrop when tiled. + let (want_scale, want_tag) = if tiled { + let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); + (bs, scale_tag(bs)) + } else { + (scale, tag) + }; + // Under tiles, a whole-page raster sharper than the backdrop (from before the page + // grew past the tiling size) is a better backdrop: keep demanding it as it is. + match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { + Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)), + None => frame.wanted.push((i, want_scale, want_tag, None)), + } + match view.pages.get(&i) { + Some(p) => { + if !tiled && p.tag == want_tag { + xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } else { + // A backdrop, or a raster at an older scale until the new one arrives. + xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } + } + None => { + let now = ui.input(|inp| inp.time); + let since = *view.waiting_since.entry(i).or_insert(now); + let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; + painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); + } + } + if tiled && r.intersects(visible) { + paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side)); + } } - // The Search panel closed: its search moves to the find bar. - let search_open = app.right == Some(crate::RightPanel::Search); - if let Some(f) = app.views[index].find.as_mut() - && f.in_panel - && !search_open +} + +/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, +/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing. +fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) { + let PageEnv { ppp, scale, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let painter = ui.painter(); + // Device-pixel geometry of the scaled page, and the visible part of it + // (found by mapping the visible screen corners back into the page). Sides round + // up as whole-page rasters do, so the last partial row and column get tiles. + let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); + // Adaptive tile side for this zoom and page, within the GPU texture limit: + // a zoomed viewport or a giant sheet is covered by a few large tiles + // instead of many small ones, and each tile costs one full page interpret. + // Never zero (u32 division below). + let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); + let vis = r.intersect(visible); + let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); + let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); + let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); + let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); + let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); + // Tiles of earlier zooms first, stretched, the nearest scale last (on top): + // until the current tiles arrive, a zoom shows them rather than the backdrop. + let mut older: Vec<(f32, u32, u32, &TextureHandle)> = + view.tiles.iter().filter(|((p, t, _, _), _)| *p == i && *t != tag).map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)).collect(); + older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); + for (s, ox, oy, tex) in older { + let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); + let [w, h] = tex.size(); + let (x, y) = (ox as f32, oy as f32); + xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); + } + // Visible tiles, nearest the viewport centre first: each tile costs a full + // page interpret, so the workers sharpen what is looked at before the edges. + let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); + let mut coords: Vec<(u32, u32)> = Vec::new(); + for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { + for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { + coords.push((tx, ty)); + } + } + coords.sort_by(|a, b| { + let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); + let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); + da.total_cmp(&db) + }); + for (tx, ty) in coords { + let (x, y) = (tx * ts, ty * ts); + let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); + if w == 0 || h == 0 { + continue; + } + frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); + if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { + let (fw, fh) = (dw as f32, dh as f32); + xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( + painter, + tex.id(), + x as f32 / fw, + y as f32 / fh, + (x + w) as f32 / fw, + (y + h) as f32 / fh, + ); + } + } +} + +/// What the page's tools did with the pointer this frame. +struct PageInput { + /// A tool, a field or a comment took the pointer: text selection must leave it alone. + consumed: bool, + /// A form field is under the pointer and has set its cursor. + over_field: bool, + /// Redact or Highlight draws boxes off text. + area_tool: bool, +} + +/// Comments: tools, selection, moving and resizing come before text selection. Form fields take +/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then +/// comments; then text selection. +fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { + let PageEnv { + added, + allowed, + author, + by_line, + can_crop, + can_fill, + can_modify, + comments_hidden, + custom_stamp, + date_text, + doc, + doc_links, + editing_content, + form, + info, + initials, + prefs, + preparing, + redact_mark, + signature, + table_preview, + text_style, + tool, + units, + fill_text_size, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); + } + + // Form fields take clicks first with the Select tool (as Acrobat fills fields in + // every viewing mode); then comments; then text selection. + if let QuickTool::Stamp(kind) = tool + && allowed + && let Some(p) = ptr.within(xf.rect) { - f.in_panel = false; + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() { + // Centred on the click, upright as the page is shown. + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; + let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); + let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: i, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.clone(), + })); + frame.out.stamp_placed = true; + } } - // The snapshot notice bar's Close was clicked: close here, where no document borrow is - // held. The tab is looked up by id: indices may have shifted since the click, and the - // flag rides its own view, so a stale flag can never close the wrong tab. - let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); - if let Some(i) = pending { - app.views[i].close_requested = false; - if app.has_unsaved_work(i) { - app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + if let Some(cs) = custom_stamp.as_ref() + && allowed + && let Some(p) = ptr.within(xf.rect) + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() { + // Centred on the click at its natural size (the engine sizes it). + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[i].view_to_user(vx, vy); + let (x, y) = (u[0] as f64, u[1] as f64); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page: i, + rect: [x, y, x, y], + name: cs.name.clone(), + file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, + author: author.clone(), + }); + frame.out.stamp_placed = true; + } + } + // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + if let QuickTool::Fill(ft) = tool + && allowed + && !comments::fill_grabs(&pcx, view) + { + match crate::fill_sign::page_input( + ui, + resp, + &xf, + ptr, + i, + info, + ft, + fill_text_size, + view, + signature.as_ref(), + initials.as_ref(), + frame.signature_preview, + author, + date_text, + ) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(ui, resp, &xf, ptr, i, view, can_modify); + } + if tool == QuickTool::Crop && crate::crop::page_input(ui, resp, &xf, ptr, i, info, view, can_crop) { + view.current = i; + frame.out.open_boxes = true; + } + // `over_field`: a form field is under the pointer and has set its cursor. + let (on_field, over_field) = if preparing { + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let o = crate::prepare::page_input(ui, resp, &xf, ptr, i, info, form, field_tool, allowed, view); + frame.out.field_props |= o.properties; + frame.out.field_placed |= o.placed; + frame.out.open_table = frame.out.open_table.or(o.table); + (o.consumed || field_tool.is_some(), false) + } else { + use crate::forms_ui::FieldPointer; + let field = if tool == QuickTool::Select + && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view)) + && !comments::select_grabs(&pcx, view) + { + crate::forms_ui::page_input(ui, resp, &xf, ptr, i, info, form, can_fill, view) } else { - app.close_tab(i); + FieldPointer::Away + }; + (field == FieldPointer::Owned, field == FieldPointer::Over) + }; + // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). + let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; + let boxing = area_tool && { + let text = view.page_text(i); + let over_text = |p: Pos2| { + let (vx, vy) = xf.screen_to_view(p); + text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + }; + crate::redact_ui::page_input(ui, resp, &xf, ptr, i, info, over_text, view) + }; + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, ptr, i, info, view); + let on_objects = + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, resp, &xf, ptr, i, doc, view); + let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { + let generation = doc.edit_generation(); + match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } } - return; + } else { + Vec::new() + }; + let over_source = tool == QuickTool::AddText + && pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) + && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) + }); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; } - let Some(doc) = app.session.get(app.views[index].id) else { return }; - let info = &doc.info; - let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { - let [r, g, b] = p.background; - Color32::from_rgb(r, g, b) - }); - if info.pages.is_empty() { - app.views[index].auto_scroll.cancel(); - ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); - return; + let on_content = !on_objects && editing_content && can_modify && { + source_blocked + || crate::content_ui::page_input( + ui, + resp, + &xf, + ptr, + i, + info, + added, + tool == QuickTool::AddText && !over_source, + matches!(tool, QuickTool::AddText | QuickTool::EditText), + text_style, + view, + ) + || tool == QuickTool::AddText && !over_source + }; + // Added content owns its gesture; its source text must not queue a second edit. + // Edit text & images boxes every paragraph and image whenever the tool is on. Only the + // input waits while selected objects or added content own the pointer, or while page + // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn + // instead of vanishing until the pointer comes back. + let others_own_pointer = on_objects || on_content; + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { + let generation = doc.edit_generation(); + let images = match view.edit_images.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.page_images(i); + view.edit_images.insert(i, (generation, l.clone())); + l + } + }; + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(ui, resp, &xf, ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (image_owns_pointer || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); + !others_own_pointer && (text_used || image_used) + }; + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, resp, &xf, ptr, i, info, doc_links, view); + if tool == QuickTool::Select + && resp.secondary_clicked() + && can_modify + && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) + && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + { + view.links.context = Some((i, l.index)); } - // The print dialog queues its own rasters later in the frame. Closing it drops them. - if app.dialog != Some(crate::Dialog::Print) { - app.views[index].print_pages.clear(); + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + PageInput { consumed, over_field, area_tool } +} + +/// Text layer: find matches, selection, I-beam and drag-to-select. +fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + if let Some(text) = view.texts.get(&i).cloned() { + paint_text_marks(ui, view, &text, i, xf, resp.dragged()); + if selects_text + && !input.consumed + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { + text_pointer_input( + ui, + view, + &text, + TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p, over_field: input.over_field }, + ); + } } - // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. - let preparing = app.is_preparing(); - // Prepare a form ▸ Calculation order: the order's steps and links on the page. - let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; - // Edit a PDF: added text and images can be selected, moved and edited. - let editing_content = - (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); - let text_style = app.text_style.clone(); - let show_secured_notice = app.show_secured_notice; - let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. The notice says so unless - // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ - // Security, stay as they are. - let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); - let repaired = !doc.repair_log().is_empty(); - match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { - #[cfg(not(target_arch = "wasm32"))] - Some(Notice::Reload) => app.watch.reload_request = Some(view.id), - #[cfg(target_arch = "wasm32")] - Some(Notice::Reload) => {} - Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), - Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), - Some(Notice::Unlock) => app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)), - // Flags the view: the tab closes at the top of the next frame, where no document - // borrow is held. - Some(Notice::Close) => { - if let Some(view) = app.views.get_mut(index) { - view.close_requested = true; +} + +fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { + let painter = ui.painter(); + let to_screen = |g: [f32; 4]| xf.view_rect(g); + if let Some(find) = &view.find { + for (index, (match_page, range)) in find.matches.iter().enumerate() { + if *match_page != page { + continue; + } + let fill = if find.current == Some(index) { + Color32::from_rgba_unmultiplied(255, 140, 0, 110) + } else { + Color32::from_rgba_unmultiplied(255, 214, 0, 90) + }; + for line_rect in text.line_rects(range.clone()) { + painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); } } - Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), - Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, - Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), - // Back on in Preferences ▸ General. - Some(Notice::HideSecured) => app.show_secured_notice = false, - None => {} } - // No dialog, close prompt or palette over the page: only then does page input count. - let modal = app.modal_open(); - let unobstructed = !modal && !app.palette_open; - let organize_zoom = app.organize_zoom; - let view = &mut app.views[index]; - if view.organize { - // One remembered grid size for every document, kept in the settings. - view.set_grid_zoom(organize_zoom); - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); - app.organize_zoom = view.grid_zoom(); - return; + if view.all_text { + for line_rect in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } } + if let Some(selection) = view.selection.filter(|selection| selection.page == page) { + for line_rect in selection.rects(text) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + if let Span::Column { rect } = selection.span + && dragging + { + let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); + painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); + } + } +} - let avail = ui.available_rect_before_wrap(); - view.viewport_w = avail.width(); - view.viewport_h = avail.height(); - let old_zoom = view.zoom; - view.fit_zoom(info); - if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { - let prev_viewport = view.viewport_screen; - let top_y = prev_viewport.min.y; - let anchor = view - .screen_rects - .iter() - .find(|(_, r)| r.min.y <= top_y && r.max.y > top_y) - .map(|(p, r)| { - let fy = if r.height() > 0.0 { ((top_y - r.min.y) / r.height()).clamp(0.0, 1.0) } else { 0.0 }; - (*p, fy, 0.0f32) - }) - .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) - .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); - if let Some((page, fy, rel_y)) = anchor { - view.zoom_anchor = Some((page, 0.5, fy, vec2(avail.width() / 2.0, rel_y))); - } else { - view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail.width() / 2.0, MARGIN))); +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, + over_field: bool, +} + +struct TextGesture<'a> { + page: usize, + xf: PageXform, + rect: Rect, + tool: QuickTool, + ptr: PagePointer, + resp: &'a egui::Response, + pointer: Pos2, + vx: f32, + vy: f32, + over_text: bool, + over_link: bool, + column: bool, +} + +fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { + let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer, over_field } = pointer; + let (vx, vy) = xf.screen_to_view(screen_pointer); + let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); + let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + update_text_cursor(ui, resp, over_field, &gesture); + update_selection_on_press(ui, view, text, &gesture); + update_dragged_selection(view, text, &gesture); + finish_text_pointer_gesture(ui, view, text, &gesture); +} + +fn update_text_cursor(ui: &egui::Ui, resp: &egui::Response, over_field: bool, gesture: &TextGesture<'_>) { + let cursor_free = !gesture.over_link && !over_field && (resp.contains_pointer() || resp.dragged()); + if gesture.column && cursor_free { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } else if gesture.over_text && cursor_free { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); + if gesture.ptr.pressed().is_some() && press_here && !gesture.over_link { + let now = ui.input(|input| input.time); + let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); + let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); + view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(gesture.pointer); + let clicks = selection_click_count(view.clicks, gesture.tool); + if clicks >= 2 + && gesture.over_text + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } + } + if gesture.resp.drag_started() && press_here && !gesture.over_link { + view.actions_dismissed = None; + view.selection_drag = true; + let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); + let (ox, oy) = gesture.xf.screen_to_view(origin); + if view.clicks < 2 { + view.selection = if gesture.column { + Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) + } else { + text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) + }; } } +} - // Pinch / ctrl+scroll zoom, anchored on the current page. - let zoom_delta = crate::motion::zoom_delta(ui.ctx()); - // Exclude the previous frame's scrollbar tracks from gesture activation. - let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); - // Full screen: a click on the document goes forward a page and a shift-click back, as in - // Acrobat, unless the click did something else (a link, a word selected). Decided below, - // once the pages have had the click. - let present_click = - view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select; - // Shift as held now, or as the click's own event carried it (a synthetic click). - let present_back = present_click - && ui.input(|i| { - i.modifiers.shift - || i.events.iter().any(|e| { - matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) - }) - }); - if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { - let z = view.zoom * zoom_delta; - match zoom_anchor(ui) { - Some(p) => view.zoom_at(z, p), - None => view.set_zoom(z), +fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { + if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } +} + +fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let clicks = selection_click_count(view.clicks, gesture.tool); + if gesture.resp.dragged() + && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) + { + match &mut selection.span { + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) + && let Some((first, last)) = click_unit(text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } + Span::Run { head, .. } => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { + *head = hit; + } + } + Span::Column { rect } => { + rect[2] = gesture.vx; + rect[3] = gesture.vy; + } } } - view.viewport_screen = avail; - let auto_delta = if unobstructed { - view.auto_scroll.update(ui, avail, false) - } else { - view.auto_scroll.cancel(); - Vec2::ZERO - }; + if gesture.resp.drag_stopped() + && view.selection.is_some_and(|selection| { + selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() + }) + { + view.selection = None; + } +} - // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view - // that way, so a point beyond what is shown can be reached without letting go of the tool. - let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = drawing_edge_pointer(ui); - crate::autoscroll::edge_scroll(pointer, avail, dt) +fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let release_clicks = if gesture.resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if gesture.resp.double_clicked() { + view.clicks.max(2) } else { - Vec2::ZERO + 0 }; - if edge_delta != Vec2::ZERO { - ui.ctx().request_repaint(); + if release_clicks > 0 { + view.clicks = release_clicks; + } + let clicks = selection_click_count(view.clicks, gesture.tool); + if release_clicks > 0 + && clicks >= 2 + && gesture.over_text + && view.selection.is_none() + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } else if gesture.resp.clicked() && view.clicks < 2 && !gesture.over_link { + let shift = ui.input(|input| input.modifiers.shift); + match ( + view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), + text.nearest(gesture.vx, gesture.vy), + ) { + (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, + _ => view.selection = None, + } } +} - let content_w = view.content_width(info, avail.width()); - let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); - // When not scrolling continuously, only one row is shown at a time: the current page, or the - // current two-page spread (with the cover rule). These indices drive paging, which pages are - // drawn, and the content height (#752). Empty for a document with no pages. - let shown_row = |view: &DocView| -> Vec { - if rects.is_empty() { - return Vec::new(); - } - let last = rects.len() - 1; - let cur = view.current.min(last); - match view.layout { - PageLayout::Single => vec![cur], - PageLayout::TwoUp if view.cover && cur == 0 => vec![0], - PageLayout::TwoUp => { - let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; - if first < last { vec![first, first + 1] } else { vec![first] } +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { + added, + allowed, + calc_order, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + generation, + info, + prefs, + preparing, + redact_mark, + table_preview, + tool, + unobstructed, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if tool == QuickTool::Select && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::drop_stale(doc, view); + crate::image_pick::paint(painter, &xf, i, info, view); + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } + if input.area_tool { + crate::redact_ui::paint(painter, i, view, pointer); + } + comments::paint_page(ui, painter, &pcx, view); + if editing_content { + crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); + } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::paint(painter, &xf, i, view); + } + if tool == QuickTool::Link { + crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); + } + if preparing { + crate::prepare::paint_page(ui, painter, &xf, i, info, form, view, pointer); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } + if calc_order { + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + if let Some(name) = crate::calc_order_ui::paint_page(painter, &xf, i, info, form, &view.calc_order, generation, selected, pointer) { + frame.calc_hover = Some(name); } } - }; - let row = shown_row(view); - let middle_gesture = view.auto_scroll.blocks_input(); - // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns - // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on - // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with - // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow - // keys. Runs before `visible_pages` so the frame that turns the page draws it. - let mut single_paging = false; - let mut edge_turned = false; + } else { + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + } + + // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. + paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); + // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips + // draw above the palette, so none shows while it is open, even beside it. + if !matches!(tool, QuickTool::Fill(_)) + && unobstructed + && let Some(p) = pointer + { + for l in info.links.iter().filter(|l| l.page == i) { + let sr = xf.user_rect(info, i, l.rect); + if sr.contains(p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + let label = match &l.target { + LinkTarget::Page(n, _) => { + crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) + } + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + frame.out.hover_text = Some((p, label)); + if resp.clicked() && tool == QuickTool::Select && !input.consumed { + frame.out.clicked_link = Some(l.target.clone()); + } + } + } + // Annotation hover shows the comment, as Acrobat's popups do; not while drawing + // freehand, where it would cover the next stroke (#429). + let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); + let gesturing = view.comments.gesture.is_some() || freehand; + // Inspection sorts annotations spatially, but the renderer paints in /Annots + // order. Keep the shared list intact and choose the last eligible raw index. + if frame.out.hover_text.is_none() + && !gesturing + && let Some((_, text)) = info + .annotations + .iter() + .filter(|a| a.page == i) + .filter(|a| !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty))) + .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) + .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) + .max_by_key(|(index, _)| *index) + { + frame.out.hover_text = Some((p, text)); + } + } + for (mp, mr, mc) in &view.compare_marks { + if *mp == i { + let sr = xf.user_rect(info, i, *mr).expand(1.5); + painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); + } + } + if let Some((fp, fr, t0)) = view.flash + && fp == i + { + let now = ui.input(|inp| inp.time); + let t0 = if t0 == 0.0 { now } else { t0 }; + view.flash = Some((fp, fr, t0)); + let age = (now - t0) as f32; + if age < 1.6 { + let a = ((1.6 - age) / 1.6 * 255.0) as u8; + let sr = xf.user_rect(info, i, fr).expand(4.0); + painter.rect_stroke( + sr, + CornerRadius::same(3), + Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } else { + view.flash = None; + } + } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); + let mark = Rect::from_center_size(sr.center(), vec2(side, side)); + icons::paint(ui, mark, icon, side * 0.64, color); + if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { + frame.out.badge_hover = Some((p, badge.summary.to_string())); + } + } + // Overlapping widgets paint in order, so the topmost badge owns the hover; an + // earlier link or annotation hover still wins over any badge. + if frame.out.hover_text.is_none() { + frame.out.hover_text = frame.out.badge_hover.take(); + } +} + +fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { + if !enabled { + return; + } + for field in form.iter() { + let required = field.has(pdfcraft_engine::field_flags::REQUIRED); + for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { + let rect = widget.rect; + let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); + let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; + if widget.round { + let radius = screen_rect.width().min(screen_rect.height()) / 2.0; + painter.circle_filled(screen_rect.center(), radius, tint); + painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); + } else { + painter.rect_filled(screen_rect, CornerRadius::same(1), tint); + painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); + } + } + } +} + +fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { + let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; + let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; + frame.out.badge_hover = None; + let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); + if !r.intersects(visible.expand(400.0)) { + return; + } + if pressed && frame.pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { + frame.pressed_page = Some(i); + } + if r.intersects(visible) { + frame.visible_now.push(i); + view.screen_rects.push((i, r)); + view.screen_xforms.push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); + } + let overlap = r.intersect(visible).height(); + if i == view.current { + frame.current_overlap = overlap; + frame.current_height = r.height(); + } + // Pages shown equally (two rows wholly on screen) differ only by rounding: the + // topmost one counts. + if overlap > frame.best_overlap + PAGE_TIE { + frame.best_overlap = overlap; + frame.current = i; + } + let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; + paint_page_surface(ui, view, frame, &PageSlot { i, r, xf }); + let painter = ui.painter(); + view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); + if editing_content { + view.move_background.paint(painter, &xf, i, info, &view.content, pointer); + } + if view.show_page_gaps { + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); + } + + let slot = PageSlot { i, r, xf }; + let input = page_tool_input(ui, view, frame, &slot); + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); + view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); + view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + // A press that lands on something drawn over the page (a menu, a dialog) is not one. + if view.all_text && frame.pressed_page == Some(i) { + view.all_text = false; + } + text_layer(ui, view, frame, &slot, &input); + page_overlays(ui, view, frame, &slot, &input); +} + +fn draw_pages( + ui: &mut egui::Ui, + viewport: Rect, + view: &mut DocView, + env: &PageEnv<'_>, + out: &mut PageOut, + signature_preview: &mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, +) -> PageRenders { + let PageEnv { + allowed, + auto_delta, + avail, + boost_extra, + calc_order, + can_copy, + can_modify, + content_h, + content_w, + doc, + edge_delta, + form, + generation, + glide, + hand, + info, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + precise_scroll, + prefs, + preparing, + selection_wheel, + single_paging, + unobstructed, + visible_pages, + y_shift, + .. + } = *env; + // egui's drag responses accept every pointer button. Keep a wheel gesture from also + // selecting text, drawing a mark, or panning with the Hand tool. + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + // Only interactions pause; the document must keep its original colours. + ui.set_opacity(opacity); + } + // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide + // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y + // moves the content down. All scroll without easing, unlike egui's wheel default. + // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution + // until after the text widgets when a selection owns that drag. + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } + // The Hand tool pans by this widget's drag, before the pages are laid out (above). + if hand { + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } + let origin = resp_rect.min - vec2(0.0, y_shift); + let visible = viewport.translate(resp_rect.min.to_vec2()); + view.screen_rects.clear(); + view.screen_xforms.clear(); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); + let pointer = ptr.pos; + // A press on a page makes it the current one (as in Acrobat), so a page command with + // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page + // gone to earlier that still shows whole beside it. + let pressed = unobstructed && ptr.pressed().is_some(); + if resp.secondary_clicked() { + // Set again below when the right-click lands on a link. + view.links.context = None; + } + let mut frame = PageFrame { + env: *env, + resp: &resp, + ptr: &ptr, + origin, + visible, + pressed, + pointer, + signature_preview, + out: std::mem::take(out), + wanted: Vec::new(), + visible_now: Vec::new(), + current: view.current, + best_overlap: -1.0, + current_overlap: -1.0, + current_height: f32::INFINITY, + pressed_page: None, + calc_hover: None, + }; + for &i in visible_pages { + draw_page(ui, view, &mut frame, i); + } + let PageFrame { + wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. + } = frame; + let PageOut { + badge_hover, + mut canvas_action, + clicked_link, + mut copy_selected, + mut field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + refused, + stamp_placed, + } = page_out; + let painter = ui.painter(); + if calc_order { + view.calc_order.set_hover_page(calc_hover); + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); + } + // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag + // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the + // pointer leaves the selection's page for the gap or the next page; over the whole + // document area, like #759. Combine direct and smoothed motion in one request so mixed + // Point and Line events retain their full contribution. + if selection_wheel { + let smooth = if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; + let delta = precise_scroll + boost_extra + smooth; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view + // so it can reach text off screen (#1670); the selection follows the pointer on its page. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && let Some(p) = ptr.pos + { + let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); + if dy != 0.0 { + view.end_find_following(); + ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); + // Keep scrolling while the pointer rests there and sends no events. + ui.ctx().request_repaint(); + } + } + // The page navigated to stays current while no page shows more of itself, so the next + // page step starts from it rather than from a page further down the screen (#188). + // It also stays current while it is wholly on screen: a short page (a cheque) gone to + // from the Pages panel shows less of itself than the tall page below it, and must not + // hand the highlight to that page. + if current_overlap >= best_overlap - PAGE_TIE || current_overlap >= current_height - PAGE_TIE { + current = view.current; + } + if let Some(p) = pressed_page { + current = p; + } + if view.continuous { + view.current = current; + } + resp.context_menu(|ui| { + // Preparing a form: the selected field's menu. + if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Several fields: Align, Center, Distribute, Set Fields to Same Size. + if !view.prepare.also.is_empty() { + use crate::prepare::Arrange as A; + let others = view.prepare.also.clone(); + let anchor = (name.clone(), wi); + let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { + if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { + view.pending_edit = crate::prepare::arrange(form, &anchor, &others, op); + ui.close(); + } + }; + ui.menu_button(tl!("Align"), |ui| { + pick(ui, A::AlignLeft, "Left"); + pick(ui, A::AlignRight, "Right"); + pick(ui, A::AlignTop, "Top"); + pick(ui, A::AlignBottom, "Bottom"); + pick(ui, A::AlignCenterV, "Center vertically"); + pick(ui, A::AlignCenterH, "Center horizontally"); + }); + ui.menu_button(tl!("Distribute"), |ui| { + pick(ui, A::DistributeH, "Horizontally"); + pick(ui, A::DistributeV, "Vertically"); + }); + ui.menu_button(tl!("Set Fields to Same Size"), |ui| { + pick(ui, A::SameHeight, "Height"); + pick(ui, A::SameWidth, "Width"); + pick(ui, A::SameSize, "Both"); + }); + ui.separator(); + } + if ui.button(tl!("Properties…")).clicked() { + field_menu = Some(FieldMenu::Properties); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { + field_menu = Some(FieldMenu::Duplicate(name.clone())); + ui.close(); + } + ui.separator(); + let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; + if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { + view.pending_edit = crate::prepare::delete_selected(view); + ui.close(); + } + return; + } + if let Some((page, index)) = view.links.context { + if ui.button(tl!("Properties…")).clicked() { + view.links.open_existing = Some((page, index)); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { + view.pending_edit = Some(Edit::DeleteLink { page, index }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { + view.pending_edit = Some(Edit::RemoveLinks { pages: None }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + return; + } + if view.comments.selected.is_none() { + let selected = view.selected_text().is_some_and(|t| !t.is_empty()); + let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { + copy_selected = true; + ui.close(); + } + ui.separator(); + } + // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. + let mut copy_image = None; + if view.comments.selected.is_none() + && let Some((page, at)) = view.comments.context_at + { + // The page's content is parsed once per right-click, not on every frame the menu is open. + let key = egui::Id::unique("copy-image-under-menu"); + let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); + let hit = match cached { + Some(c) => c.2, + None => { + let hit = doc.page_image_at(page, at); + ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); + hit + } + }; + if let Some(image) = hit { + if ui.button(tl!("Copy Image")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImage(page, image)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); + ui.close(); + } + ui.separator(); + } + } + canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); + }); + *out = PageOut { + badge_hover, + canvas_action, + clicked_link, + copy_selected, + field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + refused, + stamp_placed, + }; + (wanted, visible_now) +} + +/// A zoom the fit mode changed keeps the page at the top of the view in place. +fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { + if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { + let prev_viewport = view.viewport_screen; + let top_y = prev_viewport.min.y; + let anchor = view + .screen_rects + .iter() + .find(|(_, r)| r.min.y <= top_y && r.max.y > top_y) + .map(|(p, r)| { + let fy = if r.height() > 0.0 { ((top_y - r.min.y) / r.height()).clamp(0.0, 1.0) } else { 0.0 }; + (*p, fy, 0.0f32) + }) + .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) + .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); + if let Some((page, fy, rel_y)) = anchor { + view.zoom_anchor = Some((page, 0.5, fy, vec2(avail_width / 2.0, rel_y))); + } else { + view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail_width / 2.0, MARGIN))); + } + } +} + +/// What the pager needs to know about the page row on screen. +#[derive(Clone, Copy)] +struct PagingCx<'a> { + row: &'a [usize], + rects: &'a [Rect], + avail: Rect, + middle_gesture: bool, + unobstructed: bool, +} + +/// Not scrolling: when the whole page or spread fits, the wheel turns pages instead. Returns whether +/// paging owns the wheel, and whether it turned the page at an edge. +fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> (bool, bool) { + let (mut single_paging, mut edge_turned) = (false, false); + let PagingCx { row, rects, avail, middle_gesture, unobstructed } = *cx; if !view.continuous { // The row's bounding box on screen (one page, or a two-page spread). let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union); @@ -1030,10 +2153,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } - // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, - // smoothed base and adds only the accelerated surplus. While point suppression is active, - // reconstruct line and page input here so current coarse events survive without exposing an - // older eased tail. + (single_paging, edge_turned) +} + +/// This frame's wheel motion over the page view, after paging and point/line suppression. +struct Wheel { + precise_scroll: Vec2, + line_base: Vec2, + boost_extra: Vec2, + has_page: bool, + /// The pager owns the vertical axis (single-page view, or the rest of a gesture that turned the page). + paging_owns_vertical: bool, +} + +/// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, smoothed +/// base and adds only the accelerated surplus. While point suppression is active, line and page +/// input are reconstructed so current coarse events survive without exposing an older eased tail. +fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool) -> Wheel { let now = ui.input(|i| i.time); let input = if ui.rect_contains_pointer(avail) { // Match the line distance used by egui's other scroll areas. @@ -1101,1161 +2237,849 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Page input belongs to egui on the free axis; the pager still owns vertical motion. ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } - // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a - // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or - // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. - // Asked of the preference, not of egui's animation time, which test harnesses zero. - let animates = app.animation_effects.animates(app.motion.reduced); - let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; - let over_page = ui.rect_contains_pointer(avail); - ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { - view.fling.stop(); - } - let glide = view.fling.step(now); - if view.fling.gliding() { - ui.ctx().request_repaint(); - } - // Any reader-owned motion ends automatic search following, even when it stays on the same - // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the - // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so - // its programmatic scroll is never mistaken for a reader action. - let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; - if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { - view.end_find_following(); - } - // The wheel may have turned the page above: draw the row it turned to. Measured from the old - // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. - let row = shown_row(view); - let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; - let (y_shift, content_h) = if view.continuous { - (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) - } else { - // Only the current row scrolls into view; the content is its bounding box plus margins. - match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { - Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), - None => (0.0, 0.0), - } - }; + Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } +} - let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { - drag: if middle_gesture { - egui::scroll_area::DragScroll::Never - } else if app.quick_tool == QuickTool::Hand { - egui::scroll_area::DragScroll::Always - } else { - egui::scroll_area::DragScroll::OnTouch - }, - ..Default::default() - }); - // Each document keeps its own scroll position when switching tabs (#189). - scroll = scroll.id_salt(("page-view", view.id)); - // Every scroll here asks for no animation. An animated area would still queue a zero-length - // offset target for the next frame, which overwrites the jump a page turn just set. - scroll = scroll.animated(false); - // A newly opened document starts at the top. eframe saves egui's memory with the settings - // and document ids start again at 1 every launch, so the offset stored under this id can be - // where an earlier session left another document. - if !std::mem::replace(&mut view.shown, true) { - scroll = scroll.scroll_offset(Vec2::ZERO); - } - // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer - // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the - // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a - // click, the page catches up with all the pointer has moved since the press. A jump asked for - // below still wins. - if app.quick_tool == QuickTool::Hand - && !middle_gesture - && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) +/// What decides which renders a frame asks for. +#[derive(Clone, Copy)] +struct RenderPlan<'a> { + visible_pages: &'a [usize], + tag: u64, + scale: f32, + sharp_held: bool, + pan_held: bool, + hand: bool, +} + +/// Queues the renders for the pages and tiles `renders` wants (nearest the view first), prefetches the +/// rows either side, frees tiles of far pages and old zooms, and asks for the text layers. +fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: PageRenders, plan: &RenderPlan<'_>) { + let (mut wanted, visible_now) = renders; + let RenderPlan { visible_pages, tag, scale, sharp_held, pan_held, hand } = *plan; + // Single-page and two-page views draw only the current row, so the next one was first asked + // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, + // as continuous scrolling does for the pages just off screen. + if !view.continuous + && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) { - let moved = if ui.ctx().drag_started_id() == Some(id) { - ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + let n = if view.layout == PageLayout::Single { 1 } else { 2 }; + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { + let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; + // As drawn: the whole page at this scale, or the backdrop of a tiled page. + let side = p.width.max(p.height).max(1.0); + wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { + let bs = BASE_SIDE.min(max_side) / side; + (i, bs, scale_tag(bs), None) + } else { + (i, scale, tag, None) + }); + } + } + let cur = view.current; + wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); + // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page + // that is on screen, whether or not the page is tiled at this zoom (#2196): a zoom out below + // the tiling threshold used to drop the tiles of the zoom just left, so coming back to it + // was a cold miss and the page re-blurred through the backdrop while every tile was + // interpreted again. `OLD_TILES` still bounds how many older tiles are kept, nearest scale + // first, and admission keeps them inside `TILE_BYTES`. + let missing: HashSet = wanted.iter().filter(|w| w.3.is_some()).map(|w| w.0).collect(); + let on_screen: HashSet = visible_now.iter().copied().collect(); + view.tiles.retain(|(p, t, _, _), _| p.abs_diff(cur) <= 2 && (*t == tag || missing.contains(p) || on_screen.contains(p))); + let mut older: Vec<(f32, (usize, u64, u32, u32))> = + view.tiles.keys().filter(|k| k.1 != tag).map(|k| ((tag_scale(k.1) / scale).ln().abs(), *k)).collect(); + // Tiles of earlier zooms are kept nearest scale first, and bounded by count *and* bytes: a + // large scan renders a tile up to 4096² (about 67 MiB), so a count alone let a few tiles of + // a few zooms run far past the tile allowance. #2196 keeps a zoom already visited, which + // makes that reachable in ordinary zooming, so the byte bound has to be here too. + older.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut kept = 0usize; + let mut kept_bytes = 0usize; + let mut drop: Vec<(usize, u64, u32, u32)> = Vec::new(); + for (_dist, key) in older { + let bytes = view.tiles.get(&key).map_or(0, texture_bytes); + if kept >= OLD_TILES || kept_bytes.saturating_add(bytes) > TILE_BYTES { + drop.push(key); } else { - ui.input(|i| i.pointer.delta()) - }; - if moved != Vec2::ZERO { - view.end_find_following(); + kept = kept.saturating_add(1); + kept_bytes = kept_bytes.saturating_add(bytes); } - // Within the content, as egui keeps it: past an end the page would show there for a - // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. - let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); } - if let Some(v) = view.sync_scroll.take() { - scroll = scroll.scroll_offset(v); + for key in drop { + view.tiles.remove(&key); } - if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { - let r = rects[page.min(rects.len() - 1)]; - let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); - scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); - } else if let Some((page, frac)) = view.goto.take() { - let page = page.min(rects.len() - 1); - let r = rects[page]; - // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() - // too high for a frame and then hopped into place. (`avail` is the viewport, as the - // scroll bars float.) - let max_y = (content_h - avail.height()).max(0.0); - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); - // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off - // to one side. A page already shown whole, or one filling the window's width, keeps the - // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a - // page that fits is centred, and a wider one scrolled just far enough to fill the window. - // The page counts with its side gutters, which keep its edge clear of the floating - // quick-action bar as the layout does. - let (x, w) = (view.last_scroll.x, avail.width()); - let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; - if (target - x).abs() > 0.5 { - scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); - } - } - let ppp = ui.ctx().pixels_per_point(); - let scale = view.render_scale(ppp); - let tag = scale_tag(scale); - // Hold sharp re-renders while the zoom is still moving: a new scale every frame would - // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD - // plot with ~1000 images renders for seconds) the sharp raster never arrives and the - // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the - // sharp request goes out once the zoom settles. - let now_scale = ui.input(|i| i.time); - if (scale - view.last_scale).abs() > f32::EPSILON { - view.last_scale = scale; - view.sharp_hold_until = now_scale + 0.35; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); - } - let sharp_held = now_scale < view.sharp_hold_until; - view.current_scale_tag = tag; - let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; - // Text selection runs for the Select tool and for the markup tools (highlight…). - let selects_text = match tool { - QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, - QuickTool::Select => !preparing, - QuickTool::Redact | QuickTool::ColumnSelect => true, - QuickTool::Hand - | QuickTool::Measure(_) - | QuickTool::Crop - | QuickTool::EnhanceCamera - | QuickTool::Fill(_) - | QuickTool::Field(_) - | QuickTool::AddText - | QuickTool::EditText - | QuickTool::Stamp(_) - | QuickTool::CustomStamp(_) - | QuickTool::Link - | QuickTool::SignArea { .. } - | QuickTool::MarqueeZoom - | QuickTool::Snapshot => false, - }; - let prefs = &app.comment_prefs; - let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); - let units = app.units; - let allowed = doc.allows_annotation(); - let can_copy = doc.allows_copying(); - let mut copy_selected = false; - let comments_hidden = doc.comments_hidden(); - let form = doc.form.clone(); - let generation = doc.edit_generation(); - let can_fill = doc.allows_form_filling(); - let can_crop = doc.allows_assembly(); - let mut open_boxes = false; - let signature = app.signature.clone(); - let initials = app.initials.clone(); - let mut open_initials = false; - let author = app.comment_prefs.author.clone(); - let today = app.session.today(); - // In Preferences ▸ Date format, or why it can't be stamped into the PDF. - let date_text = crate::date_text_for_pdf(&app.session); - let mut refused: Option = None; - let by_line = app.session.stamp_by_line(&author); - let mut stamp_placed = false; - let mut image_action: Option = None; - let custom_stamp = match app.quick_tool { - QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), - _ => None, - }; - let mut open_signature = false; - let mut hover_text: Option<(Pos2, String)> = None; - let mut badge_hover: Option<(Pos2, String)> = None; - let mut clicked_link: Option = None; - let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; - let mut canvas_action: Option = None; - let mut field_menu: Option = None; - let mut open_props: Option<(usize, usize)> = None; - let mut field_props = false; - let mut field_placed = false; - let mut open_table: Option<(usize, [f64; 4])> = None; - // The table being set up in its window, shown on its own document's page. - let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); - let can_modify = doc.allows_modification(); - if preparing { - crate::prepare::after_refresh(view, &form); - } else { - view.prepare.selected = None; + let mut queue: Vec = wanted + .iter() + .map(|&(page, wanted_scale, wanted_tag, tile)| RenderRequest { + page, + kind: RequestKind::Pixels, + tile, + scale: wanted_scale, + tag: wanted_tag, + // The low-res backdrop under tiles is a preview: tiny images are skipped for it. + // It is the whole-page request whose scale is not the current one; sharp tiles + // and whole-page finals always draw everything. + preview: tile.is_none() && (wanted_scale != scale || wanted_tag != tag), + }) + .collect(); + // While the zoom or pan is settling, hold back sharp re-renders of pages that already + // show something (a stale whole-page raster or older tiles, drawn stretched): each new + // scale would cancel the running full-page interpret and restart it from scratch, and + // each pan frame would queue a storm of full-page tile interprets. + // Pages with nothing on screen yet still queue, so panning to a fresh page draws it. + if sharp_held || pan_held { + let main_tag = tag; + queue.retain(|req| { + // Fast pages render straight through: the hold only protects heavy pages + // whose full-page interpret costs seconds. + if !view.slow_pages.contains(&req.page) { + return true; + } + if req.tile.is_some() { + let has_current_tile = view.tiles.keys().any(|(p, t, _, _)| *p == req.page && *t == req.tag); + if has_current_tile { + return false; + } + let has_any_surface = view.pages.contains_key(&req.page) || view.tiles.keys().any(|(p, _, _, _)| *p == req.page); + !has_any_surface + } else if req.tag != main_tag { + // The low-res backdrop under tiles is zoom-independent: let it through, + // it is the stretched surface the hold relies on. + true + } else { + // The stale raster stays stretched until the zoom or pan settles; a page with + // nothing on screen yet still renders. + !view.pages.contains_key(&req.page) + } + }); } - view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); - view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); - let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; - let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; - if editing_content { - crate::content_ui::after_refresh(view, &added); - } else { - view.content.selected = None; + // Text layers: visible pages for selection, every page while a search is active. (Select All + // of the document needs no more: shown pages highlight as their text comes, and Copy reads + // the rest, as text far away is let go to bound memory.) + let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); + let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; + if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { + let n = info.pages.len(); + let rest: Vec = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect(); + text_pages.extend(rest); } - view.move_background.prepare(ui.ctx(), doc, &view.content, scale); + queue.extend(text_pages.into_iter().map(|page| RenderRequest { + page, + kind: RequestKind::Text, + tile: None, + scale: 1.0, + tag: TEXT_TAG, + preview: false, + })); + view.frame_queue = queue; + view.frame_visible = visible_now.iter().copied().collect(); +} - // A destination's position (bookmarks, links) refines `goto`: its point at the window's - // top-left, or the centre of a /FitR rectangle at the window's centre. - if let Some((page, fx, fy, rel)) = view.goto_point.take() { - let r = rects[page.min(rects.len() - 1)]; - // Within the content, so an unreachable point never shows a frame scrolled past the end. - let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - if let Some(fx) = fx { - scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); - } - if let Some(fy) = fy { - scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); - } - } - let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { - // egui's drag responses accept every pointer button. Keep a wheel gesture from also - // selecting text, drawing a mark, or panning with the Hand tool. - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - // Only interactions pause; the document must keep its original colours. - ui.set_opacity(opacity); - } - // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y - // moves the content down. All scroll without easing, unlike egui's wheel default. - // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution - // until after the text widgets when a selection owns that drag. - let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); - view.page_widget = Some(resp.id); - // A drag lasts only while egui reports it, so the mark can't outlive one. - if !resp.dragged_by(egui::PointerButton::Primary) { - view.selection_drag = false; - } - // The Hand tool pans by this widget's drag, before the pages are laid out (above). - if hand { - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); +/// Acts on the notice bar button that was clicked. +fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { + match notice { + #[cfg(not(target_arch = "wasm32"))] + Some(Notice::Reload) => app.watch.reload_request = app.views.get(index).map(|view| view.id), + #[cfg(target_arch = "wasm32")] + Some(Notice::Reload) => {} + Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), + Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), + Some(Notice::Unlock) => { + if let Some(doc) = app.views.get(index).and_then(|view| app.session.get(view.id)) { + app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)); } } - let origin = resp_rect.min - vec2(0.0, y_shift); - let painter = ui.painter(); - let visible = viewport.translate(resp_rect.min.to_vec2()); - let mut wanted = Vec::new(); - let mut visible_now = Vec::new(); - view.screen_rects.clear(); - view.screen_xforms.clear(); - // Visible heights (points) closer than this are equal: one page size can meet the - // viewport a fraction of a point differently once the scroll offset is applied. - const TIE: f32 = 0.5; - let mut current = view.current; - let mut best_overlap = -1.0f32; - let mut current_overlap = -1.0f32; - let mut current_height = f32::INFINITY; - // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), - // or a press they hold. The page modules read it rather than egui's raw pointer state. - let ptr = PagePointer::read(ui, &resp, modal); - let pointer = ptr.pos; - // A press on a page makes it the current one (as in Acrobat), so a page command with - // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page - // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ptr.pressed().is_some(); - let mut pressed_page = None; - if resp.secondary_clicked() { - // Set again below when the right-click lands on a link. - view.links.context = None; - } - let mut calc_hover = None; - for &i in &visible_pages { - badge_hover = None; - let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); - if !r.intersects(visible.expand(400.0)) { - continue; - } - if pressed && pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { - pressed_page = Some(i); - } - if r.intersects(visible) { - visible_now.push(i); - view.screen_rects.push((i, r)); - view.screen_xforms - .push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); - } - let overlap = r.intersect(visible).height(); - if i == view.current { - current_overlap = overlap; - current_height = r.height(); - } - // Pages shown equally (two rows wholly on screen) differ only by rounding: the - // topmost one counts. - if overlap > best_overlap + TIE { - best_overlap = overlap; - current = i; - } - let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; - // Shadows would darken the preceding page when pages touch. - if view.show_page_gaps { - painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); - } - painter.rect_filled(r, CornerRadius::ZERO, paper); - if let Some(err) = view.errors.get(&i) { - painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); - icons::paint( - ui, - Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), - "triangle-alert", - 26.0, - Color32::from_rgb(0xC8, 0x3A, 0x3A), - ); - let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); - let msg = - ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); - painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); - } else { - let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); - // A GPU may take smaller textures than these (OpenGL drivers report as little as - // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. - let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; - let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); - // Whole-page raster: sharp when small, a low-res backdrop when tiled. - let (want_scale, want_tag) = if tiled { - let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); - (bs, scale_tag(bs)) - } else { - (scale, tag) - }; - // Under tiles, a whole-page raster sharper than the backdrop (from before the page - // grew past the tiling size) is a better backdrop: keep demanding it as it is. - match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { - Some(p) => wanted.push((i, tag_scale(p.tag), p.tag, None)), - None => wanted.push((i, want_scale, want_tag, None)), - } - match view.pages.get(&i) { - Some(p) => { - if !tiled && p.tag == want_tag { - xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); - } else { - // A backdrop, or a raster at an older scale until the new one arrives. - xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); - } - } - None => { - let now = ui.input(|inp| inp.time); - let since = *view.waiting_since.entry(i).or_insert(now); - let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; - painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); - } - } - if tiled && r.intersects(visible) { - // Device-pixel geometry of the scaled page, and the visible part of it - // (found by mapping the visible screen corners back into the page). Sides round - // up as whole-page rasters do, so the last partial row and column get tiles. - let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); - // Adaptive tile side for this zoom and page, within the GPU texture limit: - // a zoomed viewport or a giant sheet is covered by a few large tiles - // instead of many small ones, and each tile costs one full page interpret. - // Never zero (u32 division below). - let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); - let vis = r.intersect(visible); - let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); - let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); - let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); - let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); - let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); - // Tiles of earlier zooms first, stretched, the nearest scale last (on top): - // until the current tiles arrive, a zoom shows them rather than the backdrop. - let mut older: Vec<(f32, u32, u32, &TextureHandle)> = view - .tiles - .iter() - .filter(|((p, t, _, _), _)| *p == i && *t != tag) - .map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)) - .collect(); - older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); - for (s, ox, oy, tex) in older { - let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); - let [w, h] = tex.size(); - let (x, y) = (ox as f32, oy as f32); - xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); - } - // Visible tiles, nearest the viewport centre first: each tile costs a full - // page interpret, so the workers sharpen what is looked at before the edges. - let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); - let mut coords: Vec<(u32, u32)> = Vec::new(); - for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { - for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { - coords.push((tx, ty)); - } - } - coords.sort_by(|a, b| { - let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); - let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); - da.total_cmp(&db) - }); - for (tx, ty) in coords { - let (x, y) = (tx * ts, ty * ts); - let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); - if w == 0 || h == 0 { - continue; - } - wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); - if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { - let (fw, fh) = (dw as f32, dh as f32); - xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( - painter, - tex.id(), - x as f32 / fw, - y as f32 / fh, - (x + w) as f32 / fw, - (y + h) as f32 / fh, - ); - } - } - } - } - view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); - if editing_content { - view.move_background.paint(painter, &xf, i, info, &view.content, pointer); - } - if view.show_page_gaps { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); + // Flags the view: the tab closes at the top of the next frame, where no document + // borrow is held. + Some(Notice::Close) => { + if let Some(view) = app.views.get_mut(index) { + view.close_requested = true; } + } + Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), + Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, + Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, + None => {} + } +} - // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units); - } +/// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The +/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own +/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about). +fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { + let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); + let Some(i) = pending else { return false }; + app.views[i].close_requested = false; + if app.has_unsaved_work(i) { + app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + } else { + app.close_tab(i); + } + true +} - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if resp.clicked() { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if resp.clicked() { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - stamp_placed = true; - } - } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). - if let QuickTool::Fill(ft) = tool - && allowed - && !comments::fill_grabs(&pcx, view) - { - match crate::fill_sign::page_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - ft, - app.fill_text_size, - view, - signature.as_ref(), - initials.as_ref(), - &mut app.signature_preview, - &author, - &date_text, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); - } - if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { - view.current = i; - open_boxes = true; - } - // `over_field`: a form field is under the pointer and has set its cursor. - let (on_field, over_field) = if preparing { - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let allowed = can_modify && table_preview.is_none() && open_table.is_none(); - let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view); - field_props |= o.properties; - field_placed |= o.placed; - open_table = open_table.or(o.table); - (o.consumed || field_tool.is_some(), false) - } else { - use crate::forms_ui::FieldPointer; - let field = if tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) - && !comments::select_grabs(&pcx, view) - { - crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) - } else { - FieldPointer::Away - }; - (field == FieldPointer::Owned, field == FieldPointer::Over) - }; - // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). - let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; - let boxing = area_tool && { - let text = view.page_text(i); - let over_text = |p: Pos2| { - let (vx, vy) = xf.screen_to_view(p); - text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) - }; - crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) - }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); - let on_objects = - !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view); - let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { - let generation = doc.edit_generation(); - match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - } - } else { - Vec::new() - }; - let over_source = tool == QuickTool::AddText - && pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, &added, p) - && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) - }); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(&added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; - } - let on_content = !on_objects && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - &added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - &text_style, - view, - ) - || tool == QuickTool::AddText && !over_source - }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - let generation = doc.edit_generation(); - let images = match view.edit_images.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.page_images(i); - view.edit_images.insert(i, (generation, l.clone())); - l - } - }; - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = - crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); - let image_used = crate::edit_text_ui::image_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - &images, - view, - &mut image_action, - !others_own_pointer && (image_owns_pointer || !text_used), - ); - !others_own_pointer && (text_used || image_used) - }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view); - if tool == QuickTool::Select - && resp.secondary_clicked() - && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) - { - view.links.context = Some((i, l.index)); - } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); - - let preview_target = - (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); - view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); - view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); - - // Any press on the page ends Select All of the document: text read or not, any tool, - // on a link too. - // A press that lands on something drawn over the page (a menu, a dialog) is not one. - if view.all_text && pressed_page == Some(i) { - view.all_text = false; - } - // Text layer: find matches, selection, I-beam and drag-to-select. - let to_screen = |g: [f32; 4]| xf.view_rect(g); - if let Some(text) = view.texts.get(&i).cloned() { - if let Some(f) = &view.find { - for (k, (mp, range)) in f.matches.iter().enumerate() { - if *mp != i { - continue; - } - let current = f.current == Some(k); - for lr in text.line_rects(range.clone()) { - let fill = if current { - Color32::from_rgba_unmultiplied(255, 140, 0, 110) - } else { - Color32::from_rgba_unmultiplied(255, 214, 0, 90) - }; - painter.rect_filled(to_screen(lr).expand(1.0), CornerRadius::same(2), fill); - } - } - } - if view.all_text { - for lr in text.line_rects(0..text.glyphs.len()) { - painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - } - if let Some(sel) = view.selection.filter(|s| s.page == i) { - for lr in sel.rects(&text) { - painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - // The rectangle being drawn, as Acrobat shows it while column-selecting. - if let Span::Column { rect } = sel.span - && resp.dragged() - { - let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); - painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); - } - } - if selects_text - && !consumed - && let Some(p) = pointer.filter(|p| r.contains(*p)) - { - let (vx, vy) = xf.screen_to_view(p); - let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]); - let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p)); - // Column select (#740): the Column select tool, or Alt (Option on macOS) held - // while a drag starts, as in Acrobat. - let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt); - // A link or a form field under the pointer keeps its own cursor: a check box - // drawn with a glyph, or set on printed text, shows the hand, not the I-beam. - // So do the mode bar and side panels over a page scrolled under them (#2755), - // unless a selection is being dragged there. - let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged()); - if column && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } else if over_text && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - // The pages' press, kept when its release came in the same frame (#2100): it - // still starts multi-click selection. - let press_here = ptr.origin.is_some_and(|o| r.contains(o)); - let pressed = ptr.pressed().is_some(); - if pressed && press_here && !over_link { - let now = ui.input(|inp| inp.time); - // The same limits egui uses to count clicks on release. - let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let is_consecutive = - now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(p) <= max_dist); - view.clicks = if is_consecutive { view.clicks.saturating_add(1) } else { 1 }; - view.last_press_time = now; - view.last_press_pos = Some(p); - - // Multi-click highlights immediately on pointer press instead of release (#1024). - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - if clicks >= 2 && over_text { - let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); - } - } - } - if resp.drag_started() && press_here && !over_link { - // A drag replaces the selection without passing through none, so a - // re-drag over text whose bar Copy closed would keep the bar hidden. - view.actions_dismissed = None; - view.selection_drag = true; - let origin = ptr.origin.unwrap_or(p); - let (ox, oy) = xf.screen_to_view(origin); - if view.clicks < 2 { - view.selection = if column { - Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } }) - } else { - text.nearest(ox, oy).map(|a| Selection::run(i, a, a)) - }; - } - } - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - if resp.dragged() - && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i) - { - match &mut sel.span { - // After a multi-click the drag extends by its unit (#1685), as in - // Acrobat and browsers: whole words or lines, always keeping the one - // clicked, which holds the anchor whichever way the drag went. - Span::Run { anchor, head } if clicks >= 2 => { - if let Some(h) = text.nearest(vx, vy) - && let Some((first, last)) = click_unit(&text, clicks, *anchor) - { - let (to_first, to_last) = click_unit(&text, clicks, h).unwrap_or((h, h)); - (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; - } - } - Span::Run { head, .. } => { - if let Some(h) = text.nearest(vx, vy) { - *head = h; - } - } - Span::Column { rect } => { - rect[2] = vx; - rect[3] = vy; - } - } - } - // A rectangle around no text selects nothing. - if resp.drag_stopped() - && view.selection.is_some_and(|s| s.page == i && matches!(s.span, Span::Column { .. }) && s.text(&text).is_empty()) - { - view.selection = None; - } - // Quick clicks widen the selection: two a word, three the line, four the page. - // Release-based fallback for platforms or synthetic events that report double_clicked. - let release_clicks = if resp.triple_clicked() { - view.clicks.max(2).saturating_add(1) - } else if resp.double_clicked() { - view.clicks.max(2) - } else { - 0 - }; - if release_clicks > 0 { - view.clicks = release_clicks; - } - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - // Only on the frame egui reports the multi-click: `view.clicks` outlives it, and - // acting on every frame re-selected the word as soon as a selection was cleared - // (a highlight made by the double-click left its word selected). - if release_clicks > 0 && clicks >= 2 && over_text { - if view.selection.is_none() { - let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); - } - } - } else if resp.clicked() && view.clicks < 2 && !over_link { - // ⇧-click extends a selection on this page to the click, keeping its - // anchor (#527). Otherwise (no shift, no selection on this page, or a - // column selection, which has no anchor glyph; a selection cannot yet - // span pages) a click clears the selection. - let shift = ui.input(|inp| inp.modifiers.shift); - match (view.selection.as_mut().filter(|s| shift && s.page == i).map(|s| &mut s.span), text.nearest(vx, vy)) { - (Some(Span::Run { head, .. }), Some(h)) => *head = h, - _ => view.selection = None, - } - } - } - } +/// Where the pages are laid out, for placing the scroll area. +#[derive(Clone, Copy)] +struct ScrollGeometry<'a> { + rects: &'a [Rect], + y_shift: f32, + content_w: f32, + content_h: f32, + avail: Rect, + middle_gesture: bool, + hand: bool, +} - if tool == QuickTool::Select && !consumed { - crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); - } - crate::image_pick::drop_stale(doc, view); - crate::image_pick::paint(painter, &xf, i, info, view); - comments::page_after_text(&resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(&resp, i, info, view); - } - if area_tool { - crate::redact_ui::paint(painter, i, view, pointer); - } - comments::paint_page(ui, painter, &pcx, view); - if editing_content { - crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer); - } - if tool == QuickTool::EditText { - crate::object_ui::paint(painter, &xf, i, info, &view.objects); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::paint(painter, &xf, i, view); - } - if tool == QuickTool::Link { - crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer); - } - if preparing { - crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer); - if let Some(preview) = &table_preview { - crate::field_table_ui::paint_preview(painter, &xf, i, preview); - } - if calc_order { - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = - crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer) - { - calc_hover = Some(name); - } - } - } else { - crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer); - } +/// The page view's scroll area, with the offset this frame's jumps, drags and zoom anchor ask for. +fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, middle_gesture, hand } = *geometry; + let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { + drag: if middle_gesture { + egui::scroll_area::DragScroll::Never + } else if hand { + egui::scroll_area::DragScroll::Always + } else { + egui::scroll_area::DragScroll::OnTouch + }, + ..Default::default() + }); + // Each document keeps its own scroll position when switching tabs (#189). + scroll = scroll.id_salt(("page-view", view.id)); + // Every scroll here asks for no animation. An animated area would still queue a zero-length + // offset target for the next frame, which overwrites the jump a page turn just set. + scroll = scroll.animated(false); + // A newly opened document starts at the top. eframe saves egui's memory with the settings + // and document ids start again at 1 every launch, so the offset stored under this id can be + // where an earlier session left another document. + if !std::mem::replace(&mut view.shown, true) { + scroll = scroll.scroll_offset(Vec2::ZERO); + } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + if moved != Vec2::ZERO { + view.end_find_following(); + } + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } + if let Some(v) = view.sync_scroll.take() { + scroll = scroll.scroll_offset(v); + } + if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { + let r = rects[page.min(rects.len() - 1)]; + let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); + scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); + } else if let Some((page, frac)) = view.goto.take() { + let page = page.min(rects.len() - 1); + let r = rects[page]; + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. + let (x, w) = (view.last_scroll.x, avail.width()); + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } + } + scroll +} - // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as - // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a - // radio button's own) is left as it is drawn. - if view.highlight_fields { - for f in form.iter() { - let required = f.has(pdfcraft_engine::field_flags::REQUIRED); - for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) { - let r = w.rect; - let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); - let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); - let (width, color) = - if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if w.round { - let radius = sr.width().min(sr.height()) / 2.0; - painter.circle_filled(sr.center(), radius, tint); - painter.circle_stroke(sr.center(), radius - width / 2.0, Stroke::new(width, color)); - } else { - painter.rect_filled(sr, CornerRadius::same(1), tint); - painter.rect_stroke(sr, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); - } - } - } - } - // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips - // draw above the palette, so none shows while it is open, even beside it. - if !matches!(tool, QuickTool::Fill(_)) - && unobstructed - && let Some(p) = pointer - { - for l in info.links.iter().filter(|l| l.page == i) { - let sr = xf.user_rect(info, i, l.rect); - if sr.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - let label = match &l.target { - LinkTarget::Page(n, _) => { - crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) - } - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - hover_text = Some((p, label)); - if resp.clicked() && tool == QuickTool::Select && !consumed { - clicked_link = Some(l.target.clone()); - } - } - } - // Annotation hover shows the comment, as Acrobat's popups do; not while drawing - // freehand, where it would cover the next stroke (#429). - let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); - let gesturing = view.comments.gesture.is_some() || freehand; - // Inspection sorts annotations spatially, but the renderer paints in /Annots - // order. Keep the shared list intact and choose the last eligible raw index. - if hover_text.is_none() - && !gesturing - && let Some((_, text)) = info - .annotations - .iter() - .filter(|a| a.page == i) - .filter(|a| { - !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)) - }) - .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) - .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) - .max_by_key(|(index, _)| *index) - { - hover_text = Some((p, text)); - } - } - for (mp, mr, mc) in &view.compare_marks { - if *mp == i { - let sr = xf.user_rect(info, i, *mr).expand(1.5); - painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); - } - } - if let Some((fp, fr, t0)) = view.flash - && fp == i - { - let now = ui.input(|inp| inp.time); - let t0 = if t0 == 0.0 { now } else { t0 }; - view.flash = Some((fp, fr, t0)); - let age = (now - t0) as f32; - if age < 1.6 { - let a = ((1.6 - age) / 1.6 * 255.0) as u8; - let sr = xf.user_rect(info, i, fr).expand(4.0); - painter.rect_stroke( - sr, - CornerRadius::same(3), - Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } else { - view.flash = None; - } - } - // Validation is viewer chrome over the signer's original artwork, which - // may still say "Signature Not Verified". These badges never enter document - // streams, image/text exports, vector printing or Print as Image. - for badge in signature_badges(&doc.signatures, i) { - let [x0, y0, x1, y1] = badge.rect; - let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); - let (icon, color) = crate::sign_ui::signature_status_style(badge.status); - painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); - // A widget too small for the mark keeps its outline: the verdict still shows. - if sr.width() < 4.0 || sr.height() < 4.0 { - continue; - } - // Big enough to read at a glance, small enough to leave the signer's own - // text visible around it. - let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); - let mark = Rect::from_center_size(sr.center(), vec2(side, side)); - icons::paint(ui, mark, icon, side * 0.64, color); - if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { - badge_hover = Some((p, badge.summary.to_string())); - } - } - // Overlapping widgets paint in order, so the topmost badge owns the hover; an - // earlier link or annotation hover still wins over any badge. - if hover_text.is_none() { - hover_text = badge_hover.take(); - } +/// Notes how far the page view scrolled this frame: reader-owned scrolling ends search following, +/// a pan holds back fresh tile renders until it settles, and a glide keeps going or stops. Whether +/// a pan is still settling. +fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput, glide: Vec2) -> bool { + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } + let now_settle = ui.input(|i| i.time); + if (scroll_offset - view.last_scroll).length() > 2.0 { + view.pan_hold_until = now_settle + 0.25; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); + } + if glide != Vec2::ZERO { + view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); + } + view.last_scroll = scroll_offset; + now_settle < view.pan_hold_until +} + +/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the +/// text editor was finished. +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hover_text: Option<(Pos2, String)>) -> bool { + let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; + if let Some((pos, text)) = hover_text { + // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. + let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_max_width(320.0); + ui.label(text); + }); + }); + } + find_bar(view, info.pages.len(), avail, ui, t); + page_size_hint(view, info, units, avail, unobstructed, ui, t); + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); + } + if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { + view.pending_edit = Some(e); + } + if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { + view.pending_edit = Some(e); + } + let text_box = crate::forms_ui::text_box(doc, view, form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, text_box.as_ref(), today) { + view.pending_edit = Some(e); + } + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { + view.pending_edit = Some(e); + } + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); + if typed.is_some() { + view.pending_edit = typed; + } + text_done +} + +/// Full screen: the click turns the page unless it did something else first. It waits out the +/// double-click time: turning on the first click's release sent a double-click's second click, +/// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a +/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { + let now = ui.input(|i| i.time); + if let Some(back) = click + && !link_followed + && view.selection.is_none() + && view.clicks < 2 + { + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + view.present_turn = Some((now + delay, !back)); + } + if let Some((due, forward)) = view.present_turn { + if link_followed || view.selection.is_some() || view.presentation.is_none() { + view.present_turn = None; + } else if now >= due { + view.present_turn = None; + view.step_page(forward); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); } - if calc_order { - view.calc_order.set_hover_page(calc_hover); - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); - } - // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag - // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the - // pointer leaves the selection's page for the gap or the next page; over the whole - // document area, like #759. Combine direct and smoothed motion in one request so mixed - // Point and Line events retain their full contribution. - if selection_wheel { - let smooth = - if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; - let delta = precise_scroll + boost_extra + smooth; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } +} + +/// Follows the link that was clicked. A link to another file comes back, to be opened once the +/// document is no longer borrowed. +fn follow_link( + app: &mut PdfCraftApp, + index: usize, + target: Option, +) -> Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> { + let view_id = app.views[index].id; + match target? { + LinkTarget::Page(p, dest) => { + let view = &mut app.views[index]; + if let Some(doc) = app.session.get(view.id) { + view.go_to_dest(p, dest, &doc.info); } } - // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view - // so it can reach text off screen (#1670); the selection follows the pointer on its page. - if view.selection_drag - && navigation_enabled - && unobstructed - && !single_paging - && let Some(p) = ptr.pos - { - let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); - if dy != 0.0 { - view.end_find_following(); - ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); - // Keep scrolling while the pointer rests there and sends no events. - ui.ctx().request_repaint(); + LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link), + LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb), + LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)), + LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), + } + None +} + +/// A finished marquee zoom, a signature rectangle drawn, or an empty signature field clicked. +/// Returns the tool to go on with. +fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool) -> QuickTool { + if let Some(done) = app.views[index].marquee_done.take() { + app.finish_marquee(index, done); + } + // A signature rectangle was drawn, or an empty signature field clicked. + let drawn = app.views[index].sign.drawn.take(); + if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { + tool = QuickTool::Select; + app.quick_tool = tool; + app.start_signing(page, Some(rect), None, certify.then_some(2)); + } + if let Some(field) = app.views[index].sign.field.take() { + let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); + if signed { + app.right = Some(RightPanel::Signatures); + if !app.sig_expanded.contains(&field) { + app.sig_expanded.push(field); } + } else { + let page = app.views[index].current; + app.start_signing(page, None, Some(field), None); } - // The page navigated to stays current while no page shows more of itself, so the next - // page step starts from it rather than from a page further down the screen (#188). - // It also stays current while it is wholly on screen: a short page (a cheque) gone to - // from the Pages panel shows less of itself than the tall page below it, and must not - // hand the highlight to that page. - if current_overlap >= best_overlap - TIE || current_overlap >= current_height - TIE { - current = view.current; - } - if let Some(p) = pressed_page { - current = p; - } - if view.continuous { - view.current = current; - } - resp.context_menu(|ui| { - // Preparing a form: the selected field's menu. - if preparing && let Some((name, wi)) = view.prepare.selected.clone() { - // Several fields: Align, Center, Distribute, Set Fields to Same Size. - if !view.prepare.also.is_empty() { - use crate::prepare::Arrange as A; - let others = view.prepare.also.clone(); - let anchor = (name.clone(), wi); - let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { - if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { - view.pending_edit = crate::prepare::arrange(&form, &anchor, &others, op); - ui.close(); - } - }; - ui.menu_button(tl!("Align"), |ui| { - pick(ui, A::AlignLeft, "Left"); - pick(ui, A::AlignRight, "Right"); - pick(ui, A::AlignTop, "Top"); - pick(ui, A::AlignBottom, "Bottom"); - pick(ui, A::AlignCenterV, "Center vertically"); - pick(ui, A::AlignCenterH, "Center horizontally"); - }); - ui.menu_button(tl!("Distribute"), |ui| { - pick(ui, A::DistributeH, "Horizontally"); - pick(ui, A::DistributeV, "Vertically"); - }); - ui.menu_button(tl!("Set Fields to Same Size"), |ui| { - pick(ui, A::SameHeight, "Height"); - pick(ui, A::SameWidth, "Width"); - pick(ui, A::SameSize, "Both"); - }); - ui.separator(); - } - if ui.button(tl!("Properties…")).clicked() { - field_menu = Some(FieldMenu::Properties); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { - field_menu = Some(FieldMenu::Duplicate(name.clone())); - ui.close(); - } - ui.separator(); - let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; - if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { - view.pending_edit = crate::prepare::delete_selected(view); - ui.close(); - } - return; - } - if let Some((page, index)) = view.links.context { - if ui.button(tl!("Properties…")).clicked() { - view.links.open_existing = Some((page, index)); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { - view.pending_edit = Some(Edit::DeleteLink { page, index }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - ui.separator(); - if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { - view.pending_edit = Some(Edit::RemoveLinks { pages: None }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - return; - } - if view.comments.selected.is_none() { - let selected = view.selected_text().is_some_and(|t| !t.is_empty()); - let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); - if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { - copy_selected = true; - ui.close(); - } - ui.separator(); - } - // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. - let mut copy_image = None; - if view.comments.selected.is_none() - && let Some((page, at)) = view.comments.context_at - { - // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::unique("copy-image-under-menu"); - let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); - let hit = match cached { - Some(c) => c.2, - None => { - let hit = doc.page_image_at(page, at); - ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); - hit - } - }; - if let Some(image) = hit { - if ui.button(tl!("Copy Image")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImage(page, image)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); - ui.close(); - } - ui.separator(); - } - } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); - }); - (wanted, visible_now) + } + tool +} + +/// When not scrolling continuously, only one row is shown at a time: the current page, or the +/// current two-page spread (with the cover rule). These indices drive paging, which pages are +/// drawn, and the content height (#752). Empty for a document with no pages. +fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { + if rects.is_empty() { + return Vec::new(); + } + let last = rects.len() - 1; + let cur = view.current.min(last); + match view.layout { + PageLayout::Single => vec![cur], + PageLayout::TwoUp if view.cover && cur == 0 => vec![0], + PageLayout::TwoUp => { + let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; + if first < last { vec![first, first + 1] } else { vec![first] } + } + } +} + +/// Full screen: a click on the document goes forward a page and a shift-click back, as in +/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages +/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { + let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + // Shift as held now, or as the click's own event carried it (a synthetic click). + clicked.then(|| { + ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| { + matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) + }) + }) + }) +} + +/// Pinch / ctrl+scroll zoom, anchored on the pointer or the current page. +fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rect: Rect, enabled: bool) { + if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && enabled && ui.rect_contains_pointer(navigation_rect) { + let z = view.zoom * zoom_delta; + match zoom_anchor(ui) { + Some(p) => view.zoom_at(z, p), + None => view.set_zoom(z), + } + } +} + +/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the +/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point +/// beyond what is shown can be reached without letting go of the tool. +fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { + let auto_delta = if unobstructed { + view.auto_scroll.update(ui, avail, false) + } else { + view.auto_scroll.cancel(); + Vec2::ZERO + }; + + // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view + // that way, so a point beyond what is shown can be reached without letting go of the tool. + let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { + let (pointer, dt) = drawing_edge_pointer(ui); + crate::autoscroll::edge_scroll(pointer, avail, dt) + } else { + Vec2::ZERO + }; + if edge_delta != Vec2::ZERO { + ui.ctx().request_repaint(); + } + (auto_delta, edge_delta) +} + +/// What decides whether touchpad momentum goes on. +#[derive(Clone, Copy)] +struct GlideCx { + now: f64, + may_glide: bool, + key_scroll: f32, + auto_delta: Vec2, + zoom_delta: f32, + edge_delta: Vec2, + wheel_scrolled: bool, +} + +/// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a stop, +/// through the same un-eased path. Never while the pager owns the wheel, a gesture or dialog has +/// the input, or animations are off; any click, key, zoom or other scrolling ends it. Any +/// reader-owned motion ends automatic search following, even when it stays on the same page; the +/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for +/// a reader action. Returns this frame's glide. +fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { + let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let over_page = ui.rect_contains_pointer(avail); + ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + let interrupted = glide_interrupted(ui); + if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + view.fling.stop(); + } + let glide = view.fling.step(now); + if view.fling.gliding() { + ui.ctx().request_repaint(); + } + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } + glide +} + +/// Text selection runs for the Select tool and for the markup tools (highlight…). +fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { + match tool { + QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, + QuickTool::Select => !preparing, + QuickTool::Redact | QuickTool::ColumnSelect => true, + QuickTool::Hand + | QuickTool::Measure(_) + | QuickTool::Crop + | QuickTool::EnhanceCamera + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::AddText + | QuickTool::EditText + | QuickTool::Stamp(_) + | QuickTool::CustomStamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot => false, + } +} + +/// Holds sharp re-renders while the zoom is still moving: a new scale every frame would cancel the +/// in-flight full-page interpret and restart it, so on a heavy page (a CAD plot with ~1000 images +/// renders for seconds) the sharp raster never arrives and the view looks stuck on a soft image. +/// The stale raster stays stretched meanwhile; the sharp request goes out once the zoom settles. +fn hold_sharp_renders(ui: &egui::Ui, view: &mut DocView, scale: f32) -> bool { + let now_scale = ui.input(|i| i.time); + if (scale - view.last_scale).abs() > f32::EPSILON { + view.last_scale = scale; + view.sharp_hold_until = now_scale + 0.35; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); + } + now_scale < view.sharp_hold_until +} + +/// A destination's position (bookmarks, links) refines `goto`: its point at the window's top-left, +/// or the centre of a /FitR rectangle at the window's centre. +fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, .. } = *geometry; + if let Some((page, fx, fy, rel)) = view.goto_point.take() { + let r = rects[page.min(rects.len() - 1)]; + // Within the content, so an unreachable point never shows a frame scrolled past the end. + let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + if let Some(fx) = fx { + scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); + } + if let Some(fy) = fy { + scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); + } + } + scroll +} + +/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a +/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. +pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // Keyboard scrolling applies to this frame's page view only, never later. + let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } + // The Search panel closed: its search moves to the find bar. + let search_open = app.right == Some(crate::RightPanel::Search); + if let Some(f) = app.views[index].find.as_mut() + && f.in_panel + && !search_open + { + f.in_panel = false; + } + if close_requested_tab(app, index) { + return; + } + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { + let [r, g, b] = p.background; + Color32::from_rgb(r, g, b) + }); + if info.pages.is_empty() { + app.views[index].auto_scroll.cancel(); + ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); + return; + } + // The print dialog queues its own rasters later in the frame. Closing it drops them. + if app.dialog != Some(crate::Dialog::Print) { + app.views[index].print_pages.clear(); + } + // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. + let preparing = app.is_preparing(); + // Prepare a form ▸ Calculation order: the order's steps and links on the page. + let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; + // Edit a PDF: added text and images can be selected, moved and edited. + let editing_content = + (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); + let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; + let view = &mut app.views[index]; + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + let repaired = !doc.repair_log().is_empty(); + let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); + apply_notice(app, index, notice); + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + // No dialog, close prompt or palette over the page: only then does page input count. + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; + let organize_zoom = app.organize_zoom; + let view = &mut app.views[index]; + if view.organize { + // One remembered grid size for every document, kept in the settings. + view.set_grid_zoom(organize_zoom); + organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + app.organize_zoom = view.grid_zoom(); + return; + } + + let avail = ui.available_rect_before_wrap(); + view.viewport_w = avail.width(); + view.viewport_h = avail.height(); + let old_zoom = view.zoom; + view.fit_zoom(info); + anchor_zoom_change(view, avail.width(), old_zoom); + + // Pinch / ctrl+scroll zoom, anchored on the current page. + let zoom_delta = crate::motion::zoom_delta(ui.ctx()); + // Exclude the previous frame's scrollbar tracks from gesture activation. + let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; + let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); + pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); + view.viewport_screen = avail; + let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); + + let content_w = view.content_width(info, avail.width()); + let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); + // When not scrolling continuously, only one row is shown at a time: the current page, or the + // current two-page spread (with the cover rule). These indices drive paging, which pages are + // drawn, and the content height (#752). Empty for a document with no pages. + let row = shown_row(view, &rects); + let middle_gesture = view.auto_scroll.blocks_input(); + // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns + // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on + // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with + // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow + // keys. Runs before `visible_pages` so the frame that turns the page draws it. + let (single_paging, edge_turned) = single_page_paging(ui, view, &PagingCx { row: &row, rects: &rects, avail, middle_gesture, unobstructed }); + // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, + // smoothed base and adds only the accelerated surplus. While point suppression is active, + // reconstruct line and page input here so current coarse events survive without exposing an + // older eased tail. + let now = ui.input(|i| i.time); + let Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } = read_wheel(ui, view, avail, single_paging, edge_turned); + let animates = app.animation_effects.animates(app.motion.reduced); + let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let glide = step_glide(ui, view, avail, &motion); + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view, &rects); + let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; + let (y_shift, content_h) = if view.continuous { + (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) + } else { + // Only the current row scrolls into view; the content is its bounding box plus margins. + match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { + Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), + None => (0.0, 0.0), + } + }; + + let mut scroll = page_scroll_area( + ui, + view, + &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand: app.quick_tool == QuickTool::Hand }, + ); + let ppp = ui.ctx().pixels_per_point(); + let scale = view.render_scale(ppp); + let tag = scale_tag(scale); + // Hold sharp re-renders while the zoom is still moving: a new scale every frame would + // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD + // plot with ~1000 images renders for seconds) the sharp raster never arrives and the + // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the + // sharp request goes out once the zoom settles. + let sharp_held = hold_sharp_renders(ui, view, scale); + view.current_scale_tag = tag; + let hand = app.quick_tool == QuickTool::Hand; + let tool = app.quick_tool; + // Text selection runs for the Select tool and for the markup tools (highlight…). + let selects_text = tool_selects_text(tool, preparing); + let prefs = &app.comment_prefs; + let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); + let units = app.units; + let allowed = doc.allows_annotation(); + let can_copy = doc.allows_copying(); + let comments_hidden = doc.comments_hidden(); + let form = doc.form.clone(); + let generation = doc.edit_generation(); + let can_fill = doc.allows_form_filling(); + let can_crop = doc.allows_assembly(); + let signature = app.signature.clone(); + let initials = app.initials.clone(); + let author = app.comment_prefs.author.clone(); + let today = app.session.today(); + // In Preferences ▸ Date format, or why it can't be stamped into the PDF. + let date_text = crate::date_text_for_pdf(&app.session); + let by_line = app.session.stamp_by_line(&author); + let custom_stamp = match app.quick_tool { + QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), + _ => None, + }; + let mut open_props: Option<(usize, usize)> = None; + // The table being set up in its window, shown on its own document's page. + let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); + let can_modify = doc.allows_modification(); + if preparing { + crate::prepare::after_refresh(view, &form); + } else { + view.prepare.selected = None; + } + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); + let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; + let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; + if editing_content { + crate::content_ui::after_refresh(view, &added); + } else { + view.content.selected = None; + } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); + + scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); + let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let env = PageEnv { + added: &added, + allowed, + author: &author, + auto_delta, + avail, + boost_extra, + by_line: &by_line, + calc_order, + can_copy, + can_crop, + can_fill, + can_modify, + comments_hidden, + content_h, + content_w, + custom_stamp: &custom_stamp, + date_text: &date_text, + doc, + doc_links: &doc_links, + edge_delta, + editing_content, + form: &form, + generation, + glide, + hand, + info, + initials: &initials, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + paper, + ppp, + precise_scroll, + prefs, + preparing, + rects: &rects, + redact_mark, + scale, + selection_wheel, + selects_text, + signature: &signature, + single_paging, + t: &t, + table_preview: &table_preview, + tag, + text_style: &text_style, + tool, + units, + unobstructed, + visible_pages: &visible_pages, + y_shift, + fill_text_size: app.fill_text_size, + today, + }; + let mut page_out = PageOut::default(); + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { + draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) }); + let PageOut { + badge_hover: _, + canvas_action, + clicked_link, + mut copy_selected, + field_menu, + field_placed, + mut field_props, + hover_text, + mut image_action, + open_boxes, + open_initials, + open_signature, + open_table, + mut refused, + stamp_placed, + } = page_out; view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { // At the top once the page's top edge shows where going to a page puts it (a gap // below the window's top). Within a point, as for wheel paging, so layout rounding @@ -2276,182 +3100,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; // pages with nothing on screen yet still queue. - let scroll_offset = out.state.clamped_offset(); - // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas - // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag - // background. The output gives us the area id, and egui's pinned ScrollArea registers those - // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. - // Ask for their exact responses instead of treating any held drag as scrolling: a text - // selection, a panel splitter or another widget's drag must not excuse the programmatic jump - // a search flash makes, while a track click (which moves the offset with no pointer motion) - // must still count as the reader scrolling. - let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); - let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); - if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { - view.end_find_following(); - } - let now_settle = ui.input(|i| i.time); - if (scroll_offset - view.last_scroll).length() > 2.0 { - view.pan_hold_until = now_settle + 0.25; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); - } - if glide != Vec2::ZERO { - view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); - } - view.last_scroll = scroll_offset; - let pan_held = now_settle < view.pan_hold_until; - let (mut wanted, visible_now) = out.inner; - // Single-page and two-page views draw only the current row, so the next one was first asked - // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, - // as continuous scrolling does for the pages just off screen. - if !view.continuous - && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) - { - let n = if view.layout == PageLayout::Single { 1 } else { 2 }; - let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; - for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { - let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; - // As drawn: the whole page at this scale, or the backdrop of a tiled page. - let side = p.width.max(p.height).max(1.0); - wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { - let bs = BASE_SIDE.min(max_side) / side; - (i, bs, scale_tag(bs), None) - } else { - (i, scale, tag, None) - }); - } - } - let cur = view.current; - wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); - // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page - // that is on screen, whether or not the page is tiled at this zoom (#2196): a zoom out below - // the tiling threshold used to drop the tiles of the zoom just left, so coming back to it - // was a cold miss and the page re-blurred through the backdrop while every tile was - // interpreted again. `OLD_TILES` still bounds how many older tiles are kept, nearest scale - // first, and admission keeps them inside `TILE_BYTES`. - let missing: HashSet = wanted.iter().filter(|w| w.3.is_some()).map(|w| w.0).collect(); - let on_screen: HashSet = visible_now.iter().copied().collect(); - view.tiles.retain(|(p, t, _, _), _| p.abs_diff(cur) <= 2 && (*t == tag || missing.contains(p) || on_screen.contains(p))); - let mut older: Vec<(f32, (usize, u64, u32, u32))> = - view.tiles.keys().filter(|k| k.1 != tag).map(|k| ((tag_scale(k.1) / scale).ln().abs(), *k)).collect(); - // Tiles of earlier zooms are kept nearest scale first, and bounded by count *and* bytes: a - // large scan renders a tile up to 4096² (about 67 MiB), so a count alone let a few tiles of - // a few zooms run far past the tile allowance. #2196 keeps a zoom already visited, which - // makes that reachable in ordinary zooming, so the byte bound has to be here too. - older.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut kept = 0usize; - let mut kept_bytes = 0usize; - let mut drop: Vec<(usize, u64, u32, u32)> = Vec::new(); - for (_dist, key) in older { - let bytes = view.tiles.get(&key).map_or(0, texture_bytes); - if kept >= OLD_TILES || kept_bytes.saturating_add(bytes) > TILE_BYTES { - drop.push(key); - } else { - kept = kept.saturating_add(1); - kept_bytes = kept_bytes.saturating_add(bytes); - } - } - for key in drop { - view.tiles.remove(&key); - } - let mut queue: Vec = wanted - .iter() - .map(|&(page, wanted_scale, wanted_tag, tile)| RenderRequest { - page, - kind: RequestKind::Pixels, - tile, - scale: wanted_scale, - tag: wanted_tag, - // The low-res backdrop under tiles is a preview: tiny images are skipped for it. - // It is the whole-page request whose scale is not the current one; sharp tiles - // and whole-page finals always draw everything. - preview: tile.is_none() && (wanted_scale != scale || wanted_tag != tag), - }) - .collect(); - // While the zoom or pan is settling, hold back sharp re-renders of pages that already - // show something (a stale whole-page raster or older tiles, drawn stretched): each new - // scale would cancel the running full-page interpret and restart it from scratch, and - // each pan frame would queue a storm of full-page tile interprets. - // Pages with nothing on screen yet still queue, so panning to a fresh page draws it. - if sharp_held || pan_held { - let main_tag = tag; - queue.retain(|req| { - // Fast pages render straight through: the hold only protects heavy pages - // whose full-page interpret costs seconds. - if !view.slow_pages.contains(&req.page) { - return true; - } - if req.tile.is_some() { - let has_current_tile = view.tiles.keys().any(|(p, t, _, _)| *p == req.page && *t == req.tag); - if has_current_tile { - return false; - } - let has_any_surface = view.pages.contains_key(&req.page) || view.tiles.keys().any(|(p, _, _, _)| *p == req.page); - !has_any_surface - } else if req.tag != main_tag { - // The low-res backdrop under tiles is zoom-independent: let it through, - // it is the stretched surface the hold relies on. - true - } else { - // The stale raster stays stretched until the zoom or pan settles; a page with - // nothing on screen yet still renders. - !view.pages.contains_key(&req.page) - } - }); - } - // Text layers: visible pages for selection, every page while a search is active. (Select All - // of the document needs no more: shown pages highlight as their text comes, and Copy reads - // the rest, as text far away is let go to bound memory.) - let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); - let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; - if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { - let n = info.pages.len(); - let rest: Vec = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect(); - text_pages.extend(rest); - } - queue.extend(text_pages.into_iter().map(|page| RenderRequest { - page, - kind: RequestKind::Text, - tile: None, - scale: 1.0, - tag: TEXT_TAG, - preview: false, - })); - view.frame_queue = queue; - view.frame_visible = visible_now.iter().copied().collect(); + let pan_held = track_scroll(ui, view, &out, glide); + plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); - if let Some((pos, text)) = hover_text { - // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. - let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); - tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_max_width(320.0); - ui.label(text); - }); - }); - } - find_bar(view, info.pages.len(), avail, ui, &t); - page_size_hint(view, info, units, avail, unobstructed, ui, &t); - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::bar(ui.ctx(), view, avail, &t, can_modify); - } - if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { - view.pending_edit = Some(e); - } - if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { - view.pending_edit = Some(e); - } - let text_box = crate::forms_ui::text_box(doc, view, &form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { - view.pending_edit = Some(e); - } - if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { - view.pending_edit = Some(e); - } - let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, &added); - if typed.is_some() { - view.pending_edit = typed; - } + let text_done = frame_overlays(ui, view, &env, hover_text); if let Some(why) = view.objects.notice.take() { refused = Some(why); } @@ -2498,60 +3150,71 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } - // Full screen: the click turns the page unless it did something else first. It waits out the - // double-click time: turning on the first click's release sent a double-click's second click, - // meant to select a word, to the next page (review on #2070). A selection cancels it, and so - // do a link followed meanwhile and leaving full screen; a double-click elsewhere still turns - // once. - let now = ui.input(|i| i.time); - if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 { - let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); - view.present_turn = Some((now + delay, !present_back)); - } - if let Some((due, forward)) = view.present_turn { - if clicked_link.is_some() || view.selection.is_some() || view.presentation.is_none() { - view.present_turn = None; - } else if now >= due { - view.present_turn = None; - view.step_page(forward); - } else { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); - } - } - match clicked_link { - Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info), - Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), - Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb), - Some(LinkTarget::RemoteGoTo { file, destination, .. }) => remote_open = Some((view.id, file, destination)), - Some(LinkTarget::Other(s)) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), - None => {} - } + schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; } - if let Some(done) = app.views[index].marquee_done.take() { - app.finish_marquee(index, done); - } - // A signature rectangle was drawn, or an empty signature field clicked. - let drawn = app.views[index].sign.drawn.take(); - if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { - tool = QuickTool::Select; - app.quick_tool = tool; - app.start_signing(page, Some(rect), None, certify.then_some(2)); - } - if let Some(field) = app.views[index].sign.field.take() { - let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); - if signed { - app.right = Some(RightPanel::Signatures); - if !app.sig_expanded.contains(&field) { - app.sig_expanded.push(field); - } - } else { - let page = app.views[index].current; - app.start_signing(page, None, Some(field), None); - } - } + let tool = finish_sign_requests(app, index, tool); app.quick_tool = tool; + let requests = FrameRequests { + open_props, + field_props, + field_menu, + image_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + }; + apply_frame_requests(app, index, ui, requests); +} + +/// What the frame's input asked the app to do, once the document is no longer borrowed. +struct FrameRequests { + open_props: Option<(usize, usize)>, + field_props: Option<(String, usize)>, + field_menu: Option, + image_action: Option, + refused: Option, + open_signature: bool, + open_initials: bool, + open_boxes: bool, + open_table: Option<(usize, [f64; 4])>, + form_notice: Option, + copy_selected: bool, + remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, + form: Arc>, + generation: u64, + avail: Rect, +} + +/// Opens the dialogs and runs the app commands the frame asked for. +fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) { + let FrameRequests { + open_props, + field_props, + field_menu, + image_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + } = requests; if let Some((page, at)) = app.views[index].comments.attach_at.take() { app.attach_file_comment(page, at); } diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index d7997a79a0..ea3b741701 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LayerOp, PdfCraftApp, Pos2, Rect}; /// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text. pub(crate) struct SignatureBadge { diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 4d916a1b8c..b48027ae35 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DocInfo, DocView, Edit, PdfCraftApp, Pos2, QuickTool, Rect, Sense, SnapshotKind, Stroke, Tokens, comments, icons, theme, + vec2, +}; /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). pub(super) fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index d8a1d1d445..a0ad8fb335 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -1,5 +1,8 @@ use super::find::Follow; -use super::*; +use super::{ + Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextureHandle, TextureOptions, + device_pixels, +}; pub(super) const THUMB_TAG: u64 = 1 << 63; diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index d44ad9d5e2..bcb4b42a20 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; /// The find bar (⌘F): query, matches across the document, current match. #[derive(Default)] diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 1cf3be91ed..46468a112d 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -1,5 +1,5 @@ use super::zoom::GRID_ZOOM_STEP; -use super::*; +use super::{DestView, DocInfo, DocView, Fit, PT, PageLayout, SIDE, ViewAction, pos2, vec2}; impl DocView { /// Where a bookmark made now should go: the page and the `/XYZ` position `[left, top]` in user @@ -299,38 +299,10 @@ impl DocView { } } -pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { +/// ⌘+ / ⌘− / ⌘0 / ⌘1 / ⌘2: zoom the page, or size the pages in the page grid. +fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { use egui::{Key, KeyboardShortcut, Modifiers}; - if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { - view.open_find(); - } let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); - let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); - // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview - // (#2669): ahead of the text-input check, but only while there is a search to step. - if view.find.is_some() { - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { - view.find_step(false); - } else if pressed(cmd(Key::G)) { - view.find_step(true); - } - } - // A focused check box or radio button holds egui's keyboard focus only for Space, and a - // radio button for the arrows that move within its group: the other page keys still work. - let form_box = view.forms.keys.filter(|(id, _)| ctx.memory(|m| m.focused()) == Some(*id)); - if ctx.egui_wants_keyboard_input() && form_box.is_none() { - return; - } - let arrows = !form_box.is_some_and(|(_, radio)| radio); - // Rotation first: egui matches ⌘+ loosely with respect to Shift. - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) - || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) - { - view.rotate_view(true); - } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) { - view.rotate_view(false); - } // In the page grid ⌘+ / ⌘− / ⌘0 size its pages instead (looking is always allowed). let (zoom_in, zoom_out, zoom_reset) = (pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)), pressed(cmd(Key::Minus)), pressed(cmd(Key::Num0))); if view.organize { @@ -362,6 +334,41 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.fit = Fit::Width; view.goto = Some((view.current, 0.0)); } +} + +pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { + use egui::{Key, KeyboardShortcut, Modifiers}; + if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { + view.open_find(); + } + let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); + let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); + // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview + // (#2669): ahead of the text-input check, but only while there is a search to step. + if view.find.is_some() { + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { + view.find_step(false); + } else if pressed(cmd(Key::G)) { + view.find_step(true); + } + } + // A focused check box or radio button holds egui's keyboard focus only for Space, and a + // radio button for the arrows that move within its group: the other page keys still work. + let form_box = view.forms.keys.filter(|(id, _)| ctx.memory(|m| m.focused()) == Some(*id)); + if ctx.egui_wants_keyboard_input() && form_box.is_none() { + return; + } + let arrows = !form_box.is_some_and(|(_, radio)| radio); + // Rotation first: egui matches ⌘+ loosely with respect to Shift. + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) + || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) + { + view.rotate_view(true); + } + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) { + view.rotate_view(false); + } + zoom_shortcuts(view, &pressed); if pressed(cmd(Key::OpenBracket)) { view.view_history(false); } diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 3eae2cc379..7f333b3e46 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -1,6 +1,9 @@ use super::cache::{GRID_TAG, thumbnail_density, thumbnail_rows, thumbnail_scale}; use super::zoom::{GRID_ZOOM_RANGE, GRID_ZOOM_STEP}; -use super::*; +use super::{ + Align2, Color32, CornerRadius, DocInfo, DocView, Edit, Pos2, Rect, RenderRequest, RequestKind, Sense, Stroke, Tokens, Vec2, ViewAction, + device_pixels, icons, pos2, theme, vec2, widgets, +}; /// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes /// the size on every frame, and a slightly soft or slightly large image is fine meanwhile. @@ -210,6 +213,60 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin resp.on_hover_text(label).clicked() } +/// Middle-button auto-scroll: its delta scrolls the grid, and the page controls under it are +/// disabled (but not dimmed) while the gesture lasts. +fn apply_auto_scroll(ui: &mut egui::Ui, middle_gesture: bool, auto_delta: Vec2) { + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + ui.set_opacity(opacity); + } + if auto_delta != Vec2::ZERO { + ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); + } +} + +/// Drag pages to move them (the selection, or the page grabbed); `drop` is set when the drag ends. +fn drag_pages(view: &mut DocView, resp: &egui::Response, page: usize, drop: &mut bool) { + if resp.drag_started() { + if !view.selected.contains(&page) { + view.selected = [page].into(); + view.select_anchor = Some(page); + } + let mut pages: Vec = view.selected.iter().copied().collect(); + pages.sort_unstable(); + view.org_drag = Some(pages); + } + if resp.drag_stopped() { + *drop = true; + } +} + +/// A page drawn larger than its thumbnail gets a render at the size drawn, while it is in view; +/// until that arrives the thumbnail stands in. +fn request_sharper( + view: &DocView, + page: usize, + want: f32, + page_width: f32, + drawn_width: Option, + in_view: &mut Vec<(usize, f32)>, + sharper: &mut Vec, +) { + in_view.push((page, want)); + if !drawn_width.is_some_and(|w| sharp_enough(w, want)) && !view.errors.contains_key(&page) { + sharper.push(RenderRequest { + page, + kind: RequestKind::Pixels, + tile: None, + scale: want / page_width.max(1.0), + tag: GRID_TAG, + // Grid cells are previews: tiny images are skipped for them. + preview: true, + }); + } +} + pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { let ppp = ui.ctx().pixels_per_point(); // The page image scales with the zoom; the padding and the page number don't. @@ -234,14 +291,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, // The source cell can scroll out of the virtualized rows during a drag. let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released()); egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| { - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - ui.set_opacity(opacity); - } - if auto_delta != Vec2::ZERO { - ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); - } + apply_auto_scroll(ui, middle_gesture, auto_delta); ui.add_space(20.0); let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize; let rows = info.pages.len().div_ceil(cols); @@ -267,19 +317,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) { ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none()); } - // Drag pages to move them (the selection, or the page grabbed). - if resp.drag_started() { - if !view.selected.contains(&i) { - view.selected = [i].into(); - view.select_anchor = Some(i); - } - let mut pages: Vec = view.selected.iter().copied().collect(); - pages.sort_unstable(); - view.org_drag = Some(pages); - } - if resp.drag_stopped() { - drop = true; - } + drag_pages(view, &resp, i, &mut drop); let page_label = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, view.selected.contains(&i), page_label.clone())); let pr = grid_page_rect(c, vec2(p.width, p.height)); @@ -300,18 +338,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, let thumb_ok = thumb.is_some_and(|t| t.size()[0] as f32 >= want * GRID_SHARP.start()); let sharp = view.grid_pages.get(&i).filter(|_| !thumb_ok); if !thumb_ok && ui.is_rect_visible(c) { - in_view.push((i, want)); - if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) { - sharper.push(RenderRequest { - page: i, - kind: RequestKind::Pixels, - tile: None, - scale: want / p.width.max(1.0), - tag: GRID_TAG, - // Grid cells are previews: tiny images are skipped for them. - preview: true, - }); - } + request_sharper(view, i, want, p.width, sharp.map(|(w, _)| *w), &mut in_view, &mut sharper); } if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) { ui.painter().image(tex.id(), pr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index 43d5990c57..cfc5967bcf 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocInfo, DocView, Edit, HashMap, PageText, Pos2, Range, Rect, comments, pos2}; /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 24ff25f7d1..91a3739d62 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, DocInfo, DocView, Pos2, Rect, pos2, vec2}; impl DocView { /// Where `page` is drawn on screen this frame (`None` when it is not on screen). diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index b6a9669fd0..91bc4feac8 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocInfo, DocView, Fit, GAP, MARGIN, PT, PageLayout, Pos2, Rect, SIDE, SavedDisplay, Vec2, device_pixels, pos2, vec2}; /// The page grid's zoom range and the step of its buttons and keys. pub const GRID_ZOOM_RANGE: std::ops::RangeInclusive = 0.5..=3.0; From f796c71abe2634a7ac0cc3157c9797a76c1659ef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 20/38] Refactor ui commands for strict Clippy checks --- crates/ui-egui/src/commands.rs | 574 ++++++++++++++++++--------------- 1 file changed, 312 insertions(+), 262 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 26fad6f77c..acccbcc55a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -112,56 +112,64 @@ impl PdfCraftApp { } } + /// Why the command can't run right now, for the user. + fn disabled_reason(&self, spec: &CommandSpec) -> String { + match spec.needs { + commands::Needs::Undo => tl!("Nothing to undo").to_string(), + commands::Needs::Redo => tl!("Nothing to redo").to_string(), + commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), + commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), + commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), + commands::Needs::HasRedactions if self.active.is_some() => { + tl!("There are no redaction marks (mark text, areas or pages first)").to_string() + } + commands::Needs::Marks(k) if self.active.is_some() => { + let kind = match k { + pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), + pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), + pdfcraft_engine::MarkKind::Background => tl!("background"), + }; + crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) + } + // Restricted documents ask for their permissions password instead, so only an + // unprotected or a read-only document gets here. + commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { + match self.active_ids().and_then(|(_, id)| self.session.get(id)) { + Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]), + _ => tl!("This document isn't password-protected").to_string(), + } + } + commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), + commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { + tl!("The document's security settings don't allow this change").to_string() + } + commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), + commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(), + commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), + _ => tl!("Open a document first").to_string(), + } + } + fn execute_unguarded(&mut self, id: &str) -> bool { let Some(spec) = commands::command(id) else { return false }; if !self.command_enabled(spec) { - let why = match spec.needs { - commands::Needs::Undo => tl!("Nothing to undo").to_string(), - commands::Needs::Redo => tl!("Nothing to redo").to_string(), - commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), - commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), - commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), - commands::Needs::HasRedactions if self.active.is_some() => { - tl!("There are no redaction marks (mark text, areas or pages first)").to_string() - } - commands::Needs::Marks(k) if self.active.is_some() => { - let kind = match k { - pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), - pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), - pdfcraft_engine::MarkKind::Background => tl!("background"), - }; - crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) - } - // Restricted documents ask for their permissions password instead, so only an - // unprotected or a read-only document gets here. - commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { - match self.active_ids().and_then(|(_, id)| self.session.get(id)) { - Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]), - _ => tl!("This document isn't password-protected").to_string(), - } - } - commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), - commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { - tl!("The document's security settings don't allow this change").to_string() - } - commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), - commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(), - commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), - _ => tl!("Open a document first").to_string(), - }; + let why = self.disabled_reason(spec); self.notify(why); return false; } let active = self.active; let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); + self.run_file_command(id, active) + .or_else(|| self.run_view_command(id, active)) + .or_else(|| self.run_page_command(id, active, targets)) + .or_else(|| self.run_edit_command(id, active)) + .or_else(|| self.run_form_command(id, active)) + .or_else(|| self.run_secure_command(id, active)) + .unwrap_or(false) + } + + fn run_file_command(&mut self, id: &str, active: Option) -> Option { match id { - "ui.dock.reset" => { - if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { - self.notify(error); - } - } - "view.side_panel" => self.toggle_side_panel(), - "view.tools_pane" => self.left_open = !self.left_open, "file.open" => self.open_dialog(), "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { // The palette runs commands without a submenu: open the most recent file. @@ -171,7 +179,6 @@ impl PdfCraftApp { "file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"), "file.clear_recent" => self.recent.clear(), "file.pin_folder" => self.pin_folder_dialog(), - "page.combine" => self.open_combine_tab(), "file.save" => { self.save_active(SaveTarget::InPlace); } @@ -198,35 +205,58 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } - "edit.attach" => self.attach_file_dialog(), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); } } - "page.number" => { - if let Some(i) = active { - let v = &self.views[i]; - let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; - let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); - self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; - } - self.dialog = Some(Dialog::NumberPages); - } link if pdfcraft_engine::links::for_command(link).is_some() => { if let Some(l) = pdfcraft_engine::links::for_command(link) { self.open_url(l.url); } } - "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), - "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), - "edit.undo" => self.undo(), - "edit.redo" => self.redo(), - "edit.find" => { - if let Some(i) = active { - self.views[i].open_find(); + "app.preferences" => self.dialog = Some(Dialog::Preferences), + "protect.remove_hidden" => self.open_remove_hidden(), + "print.dialog" => self.open_print(), + "optimize.advanced" => self.dialog = Some(Dialog::Optimize), + "doc.compare" => self.dialog = Some(Dialog::CompareFiles), + "standards.pdfa" => { + self.pdfa.issues = None; + self.dialog = Some(Dialog::PdfA); + } + "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { + self.wizard.editing = None; + match id { + "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), + "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), + _ => {} } + self.dialog = Some(Dialog::ActionWizard); } + "create.blank" => self.create_blank(), + "create.file" => self.open_dialog(), + "create.multiple" => self.create_multiple_dialog(), + "create.multiple_separate" => self.create_multiple_separate_dialog(), + "create.images" => self.create_from_images_dialog(), + "create.clipboard" => self.create_from_clipboard(), + "optimize.reduce" => self.reduce_file_size(), + "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), + "help.about" => self.dialog = Some(Dialog::About), + "help.check_updates" => self.check_for_updates(), + _ => return None, + } + Some(true) + } + + fn run_view_command(&mut self, id: &str, active: Option) -> Option { + match id { + "ui.dock.reset" => { + if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { + self.notify(error); + } + } + "view.side_panel" => self.toggle_side_panel(), + "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get_mut(i))) { // The page box in the toolbar (chrome.rs); select its label so typing replaces it. @@ -287,7 +317,7 @@ impl PdfCraftApp { "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - return v.view_history(id == "view.next_view"); + return Some(v.view_history(id == "view.next_view")); } } "view.next_tab" => self.step_tab(true), @@ -300,54 +330,33 @@ impl PdfCraftApp { "view.theme.system" => self.set_theme_preference(ThemePreference::System), "view.theme.light" => self.set_theme_preference(ThemePreference::Light), "view.theme.dark" => self.set_theme_preference(ThemePreference::Dark), - "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), - tool if crate::comments::CommentTool::from_command(tool).is_some() => { - let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false }; - self.comment_prefs.group_tool[tool.group()] = tool; - self.quick_tool = crate::QuickTool::Comment(tool); - // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. - if self.right.is_none() && !self.comments_panel_closed { - self.choose_right_panel(Some(RightPanel::Comments)); - } - // A text selection made before picking a markup tool is marked right away. - if let (Some(kind), Some(i)) = (tool.markup(), active) - && let Some(doc) = self.session.get(self.views[i].id) + "view.fit_visible" => { + if let Some(i) = self.active + && let Err(e) = self.fit_visible(i) { - let info = &doc.info; - if let Some((page, quads)) = self.views[i].selection_quads(info) { - self.views[i].clear_selection(); - let style = self.comment_prefs.style(tool); - let author = self.comment_prefs.author.clone(); - let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads }; - self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })); - } - } - } - "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), - "form.calculation_order" => { - self.left = crate::LeftPanel::CalculationOrder; - // The page edits the fields, so Preview ends; the page grid closes, as for a tool. - self.form_preview = false; - if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - v.organize = false; - } - self.reveal_tools(); - } - "comment.flatten" => { - self.apply_edit(Edit::Flatten { comments: true, fields: false }); - } - "form.flatten" => { - // Flatten what's typed in a field too (#166); a refused value flattens nothing. - if self.commit_form_typing() { - self.apply_edit(Edit::Flatten { comments: false, fields: true }); + self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); } } - "form.clear" => { + "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, + _ => return None, + } + Some(true) + } + + fn run_page_command(&mut self, id: &str, active: Option, targets: Vec) -> Option { + match id { + "page.combine" => self.open_combine_tab(), + "page.number" => { if let Some(i) = active { - self.views[i].forms.focus = None; + let v = &self.views[i]; + let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; + let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); + self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; } - self.apply_edit(Edit::ResetForm { names: None }); + self.dialog = Some(Dialog::NumberPages); } + "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), + "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), "page.organize" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { if v.organize { @@ -376,6 +385,80 @@ impl PdfCraftApp { } "page.insert" => self.insert_from_file_dialog(), "page.replace" => self.replace_pages_dialog(), + "page.copy" => self.copy_pages(false), + "page.cut" => self.copy_pages(true), + "page.paste" => self.paste_pages(), + "page.duplicate" => { + self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); + } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); + } + "page.crop" => { + self.quick_tool = crate::QuickTool::Crop; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); + } + "page.boxes" => { + self.boxes_draft.seeded = None; + self.dialog = Some(Dialog::PageBoxes); + } + "page.extract" => self.open_extract_dialog(), + "page.rotate_dialog" => { + if let Some(i) = active { + let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); + self.rotate_draft.to = n; + self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 }; + } + self.dialog = Some(Dialog::RotatePages); + } + "page.split" => self.dialog = Some(Dialog::Split), + _ => return None, + } + Some(true) + } + + fn run_edit_command(&mut self, id: &str, active: Option) -> Option { + match id { + "edit.attach" => self.attach_file_dialog(), + "edit.undo" => self.undo(), + "edit.redo" => self.redo(), + "edit.find" => { + if let Some(i) = active { + self.views[i].open_find(); + } + } + "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), + tool if crate::comments::CommentTool::from_command(tool).is_some() => { + let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return Some(false) }; + self.comment_prefs.group_tool[tool.group()] = tool; + self.quick_tool = crate::QuickTool::Comment(tool); + // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. + if self.right.is_none() && !self.comments_panel_closed { + self.choose_right_panel(Some(RightPanel::Comments)); + } + // A text selection made before picking a markup tool is marked right away. + if let (Some(kind), Some(i)) = (tool.markup(), active) + && let Some(doc) = self.session.get(self.views[i].id) + { + let info = &doc.info; + if let Some((page, quads)) = self.views[i].selection_quads(info) { + self.views[i].clear_selection(); + let style = self.comment_prefs.style(tool); + let author = self.comment_prefs.author.clone(); + let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads }; + self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })); + } + } + } + "comment.flatten" => { + self.apply_edit(Edit::Flatten { comments: true, fields: false }); + } "edit.header_footer" | "edit.watermark" | "edit.background" @@ -416,15 +499,7 @@ impl PdfCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), - "a11y.check" => self.start_accessibility_check(), - "a11y.find_untagged" => self.find_untagged_content(), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), - "tools.js_console" => self.dialog = Some(Dialog::JsConsole), - "tools.document_js" => { - self.doc_js = Default::default(); - self.dialog = Some(Dialog::DocumentJs); - } - "app.preferences" => self.dialog = Some(Dialog::Preferences), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; @@ -440,16 +515,6 @@ impl PdfCraftApp { self.choose_right_panel(Some(crate::RightPanel::Search)); } } - "a11y.report" => self.show_accessibility_report(), - "a11y.autotag" => { - if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { - self.choose_right_panel(Some(RightPanel::Tags)); - } - } - "a11y.alt_text" => self.start_alt_text(), - "a11y.autotag_fields" => self.autotag_form_fields(), - "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), - "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)), "edit.text" => { self.quick_tool = crate::QuickTool::AddText; @@ -463,50 +528,82 @@ impl PdfCraftApp { } "edit.links_from_urls" => self.links_from_urls(), "edit.remove_links" => self.remove_all_links(), - "redact.mark" => { - self.quick_tool = crate::QuickTool::Redact; - self.left = crate::LeftPanel::Tool("redact"); + "comment.import" | "form.import_data" => self.import_data_dialog(), + "comment.stamp" => { + self.left = crate::LeftPanel::Tool("stamp"); self.reveal_tools(); - // A text selection made first is marked right away. - if let Some(i) = active - && let Some(doc) = self.session.get(self.views[i].id) - { - let info = &doc.info; - if let Some((page, quads)) = self.views[i].selection_quads(info) { - self.views[i].clear_selection(); - let author = self.comment_prefs.author.clone(); - self.apply_edit(self.redact_prefs.mark(page, quads, &author)); - } + } + "comment.export" => self.export_data_dialog(true, false), + "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), + "edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect, + "edit.snapshot" => { + self.quick_tool = crate::QuickTool::Snapshot; + self.notify_tr("Drag a rectangle around the area to copy"); + } + "edit.copy" => { + if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { + return Some(self.copy_selected_text(i, &ctx)); } + return Some(false); } - "redact.pages" => { + "comment.hide_all" => { if let Some(i) = active { - let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); - self.redact_pages_draft = crate::RedactPagesDraft { current: true, from: self.views[i].current + 1, to: n }; + let id = self.views[i].id; + let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); + if self.session.set_hide_comments(id, hide) { + self.views[i].invalidate_content(); + } } - self.dialog = Some(Dialog::RedactPages); } - "redact.search" => { - self.redact_search.found = None; - self.dialog = Some(Dialog::RedactSearch); + "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), + "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), + "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), + "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), + "ocr.enhance_camera" => { + // The corners start on the document's edges when they can be found, else + // inset on the current page (camera_ui::page_input). + self.quick_tool = crate::QuickTool::EnhanceCamera; + if let Some(i) = self.active + && let Some(view) = self.views.get_mut(i) + && let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned()) + { + view.camera_done = None; + view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad { + page: view.current, + corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)), + grab: None, + }); + } + self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page."); } - "redact.properties" => { - self.redact_before = Some(self.redact_prefs.clone()); - self.dialog = Some(Dialog::RedactProps); + _ => return None, + } + Some(true) + } + + fn run_form_command(&mut self, id: &str, active: Option) -> Option { + match id { + "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), + "form.calculation_order" => { + self.left = crate::LeftPanel::CalculationOrder; + // The page edits the fields, so Preview ends; the page grid closes, as for a tool. + self.form_preview = false; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.reveal_tools(); } - "redact.apply" => { - let marks = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.redaction_marks()); - if marks == 0 { - self.notify_tr("There are no redaction marks to apply"); - } else { - self.dialog = Some(Dialog::RedactApply); + "form.flatten" => { + // Flatten what's typed in a field too (#166); a refused value flattens nothing. + if self.commit_form_typing() { + self.apply_edit(Edit::Flatten { comments: false, fields: true }); } } - "protect.remove_hidden" => self.open_remove_hidden(), - "print.dialog" => self.open_print(), - "redact.sanitize" => self.dialog = Some(Dialog::Sanitize), - "redact.clear" => { - self.apply_edit(Edit::ClearRedactions); + "form.clear" => { + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.apply_edit(Edit::ResetForm { names: None }); } "form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => { let order = match id { @@ -517,13 +614,6 @@ impl PdfCraftApp { let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len()); self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order }); } - "comment.import" | "form.import_data" => self.import_data_dialog(), - "comment.stamp" => { - self.left = crate::LeftPanel::Tool("stamp"); - self.reveal_tools(); - } - "comment.export" => self.export_data_dialog(true, false), - "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), "sign.digital" | "sign.certify" => { let certify = id == "sign.certify"; self.quick_tool = crate::QuickTool::SignArea { certify }; @@ -539,44 +629,8 @@ impl PdfCraftApp { self.choose_right_panel(Some(RightPanel::Signatures)); } "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)), - "optimize.advanced" => self.dialog = Some(Dialog::Optimize), - "view.fit_visible" => { - if let Some(i) = self.active - && let Err(e) = self.fit_visible(i) - { - self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); - } - } - "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, - "edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect, - "edit.snapshot" => { - self.quick_tool = crate::QuickTool::Snapshot; - self.notify_tr("Drag a rectangle around the area to copy"); - } - "edit.copy" => { - if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { - return self.copy_selected_text(i, &ctx); - } - return false; - } - "page.copy" => self.copy_pages(false), - "page.cut" => self.copy_pages(true), - "page.paste" => self.paste_pages(), - "comment.hide_all" => { - if let Some(i) = active { - let id = self.views[i].id; - let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); - if self.session.set_hide_comments(id, hide) { - self.views[i].invalidate_content(); - } - } - } "form.export_data" => self.export_data_dialog(false, true), "form.merge_data" => self.merge_data_dialog(), - "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), - "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), - "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), - "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), "form.prepare" => { self.left = crate::LeftPanel::Tool("form"); self.reveal_tools(); @@ -589,27 +643,13 @@ impl PdfCraftApp { } } "form.detect" => self.detect_fields(), - "doc.compare" => self.dialog = Some(Dialog::CompareFiles), - "standards.pdfa" => { - self.pdfa.issues = None; - self.dialog = Some(Dialog::PdfA); - } - "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { - self.wizard.editing = None; - match id { - "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), - "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), - _ => {} - } - self.dialog = Some(Dialog::ActionWizard); - } "form.field.properties" => { if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { self.open_field_props(&name, w); } } field if crate::prepare::FieldTool::from_command(field).is_some() => { - let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return false }; + let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return Some(false) }; self.quick_tool = crate::QuickTool::Field(tool); self.left = crate::LeftPanel::Tool("form"); self.reveal_tools(); @@ -633,7 +673,7 @@ impl PdfCraftApp { self.dialog = Some(Dialog::Signature); } fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { - let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return false }; + let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) }; self.quick_tool = crate::QuickTool::Fill(tool); let initials = tool == crate::fill_sign::FillTool::Initials; if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { @@ -641,66 +681,76 @@ impl PdfCraftApp { self.dialog = Some(Dialog::Signature); } } - "create.blank" => self.create_blank(), - "create.file" => self.open_dialog(), - "create.multiple" => self.create_multiple_dialog(), - "create.multiple_separate" => self.create_multiple_separate_dialog(), - "create.images" => self.create_from_images_dialog(), - "create.clipboard" => self.create_from_clipboard(), - "optimize.reduce" => self.reduce_file_size(), - "page.duplicate" => { - self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); + _ => return None, + } + Some(true) + } + + fn run_secure_command(&mut self, id: &str, active: Option) -> Option { + match id { + "a11y.check" => self.start_accessibility_check(), + "a11y.find_untagged" => self.find_untagged_content(), + "tools.js_console" => self.dialog = Some(Dialog::JsConsole), + "tools.document_js" => { + self.doc_js = Default::default(); + self.dialog = Some(Dialog::DocumentJs); } - // Two or more selected pages reverse among themselves; otherwise the whole document. - "page.reverse" => { - let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); - let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; - self.apply_edit(Edit::ReversePages { pages }); + "a11y.report" => self.show_accessibility_report(), + "a11y.autotag" => { + if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { + self.choose_right_panel(Some(RightPanel::Tags)); + } } - "ocr.enhance_camera" => { - // The corners start on the document's edges when they can be found, else - // inset on the current page (camera_ui::page_input). - self.quick_tool = crate::QuickTool::EnhanceCamera; - if let Some(i) = self.active - && let Some(view) = self.views.get_mut(i) - && let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned()) + "a11y.alt_text" => self.start_alt_text(), + "a11y.autotag_fields" => self.autotag_form_fields(), + "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), + "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), + "redact.mark" => { + self.quick_tool = crate::QuickTool::Redact; + self.left = crate::LeftPanel::Tool("redact"); + self.reveal_tools(); + // A text selection made first is marked right away. + if let Some(i) = active + && let Some(doc) = self.session.get(self.views[i].id) { - view.camera_done = None; - view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad { - page: view.current, - corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)), - grab: None, - }); + let info = &doc.info; + if let Some((page, quads)) = self.views[i].selection_quads(info) { + self.views[i].clear_selection(); + let author = self.comment_prefs.author.clone(); + self.apply_edit(self.redact_prefs.mark(page, quads, &author)); + } } - self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page."); } - "page.crop" => { - self.quick_tool = crate::QuickTool::Crop; - if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - v.organize = false; + "redact.pages" => { + if let Some(i) = active { + let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); + self.redact_pages_draft = crate::RedactPagesDraft { current: true, from: self.views[i].current + 1, to: n }; } - self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); + self.dialog = Some(Dialog::RedactPages); } - "page.boxes" => { - self.boxes_draft.seeded = None; - self.dialog = Some(Dialog::PageBoxes); + "redact.search" => { + self.redact_search.found = None; + self.dialog = Some(Dialog::RedactSearch); } - "page.extract" => self.open_extract_dialog(), - "page.rotate_dialog" => { - if let Some(i) = active { - let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); - self.rotate_draft.to = n; - self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 }; + "redact.properties" => { + self.redact_before = Some(self.redact_prefs.clone()); + self.dialog = Some(Dialog::RedactProps); + } + "redact.apply" => { + let marks = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.redaction_marks()); + if marks == 0 { + self.notify_tr("There are no redaction marks to apply"); + } else { + self.dialog = Some(Dialog::RedactApply); } - self.dialog = Some(Dialog::RotatePages); } - "page.split" => self.dialog = Some(Dialog::Split), - "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), - "help.about" => self.dialog = Some(Dialog::About), - "help.check_updates" => self.check_for_updates(), - _ => return false, + "redact.sanitize" => self.dialog = Some(Dialog::Sanitize), + "redact.clear" => { + self.apply_edit(Edit::ClearRedactions); + } + _ => return None, } - true + Some(true) } /// Shows the next tab (or the previous one) in the strip's order, wrapping around at either From bf89bf918c0c5589f6018e828ae511c7b7909586 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 21/38] Refactor ui dialogs for strict Clippy checks --- crates/ui-egui/src/dialogs.rs | 2821 +++++++++++++------------ crates/ui-egui/src/dialogs/prompts.rs | 2 +- 2 files changed, 1509 insertions(+), 1314 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a0c1ca880e..a2cbe902f2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,6 +23,48 @@ use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, sav const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// What the dialog's widgets asked for this frame; acted on after the modal is drawn. +#[derive(Default)] +struct Pending { + apply: bool, + split_now: Option, + split_ready: Option, + extract_now: bool, + rotate_now: bool, + link_now: Option, + recover: Option, + number_now: Option, + apply_number: bool, + link_command: Option<&'static str>, + protect_now: bool, + boxes_now: bool, + marks_now: bool, + export_now: bool, + props_now: bool, + field_props_now: bool, + bulk_field_props_now: bool, + redact_now: Option, + import_words: bool, + print_go: bool, + revert_now: bool, + overwrite_now: bool, + overwrite_elsewhere: bool, + summarize_now: bool, + optimize_now: bool, + duplicate_now: Option, + replace_now: bool, + open_revision: Option, + a11y_now: bool, + ocr_now: bool, + compare_now: bool, + images_now: bool, + stamp_now: bool, + alt_now: bool, + close: bool, + /// The dialog to switch to, when a widget asked for another one. + next: Option, +} + pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // The page sets hover cursors from the raw pointer position (form fields, links, comments) // before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from @@ -77,43 +119,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.view_draft = Some((id, doc.initial_view())); app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); } - let mut apply = false; - let mut split_now: Option = None; - let mut split_ready: Option = None; - let mut extract_now = false; - let mut rotate_now = false; - let mut link_now: Option = None; - let mut recover: Option = None; - let mut number_now: Option = None; - let mut apply_number = false; - let mut link_command: Option<&'static str> = None; - let mut protect_now = false; - let mut boxes_now = false; - let mut marks_now = false; - let mut export_now = false; - let mut props_now = false; - let mut field_props_now = false; - let mut bulk_field_props_now = false; - let mut redact_now: Option = None; - let mut import_words = false; - let mut print_go = false; - let mut revert_now = false; - let mut overwrite_now = false; - let mut overwrite_elsewhere = false; - let mut summarize_now = false; - let mut optimize_now = false; - let mut duplicate_now: Option = None; - let mut replace_now = false; - let mut open_revision: Option = None; - let mut a11y_now = false; - let mut ocr_now = false; - let mut compare_now = false; - let mut images_now = false; - let mut stamp_now = false; - let mut alt_now = false; - let t = Tokens::get(ctx); - let mut close = false; - let mut next = dialog; + let mut req = Pending::default(); let modal = widgets::show_modal(ctx, "dialog", |ui| { ui.set_width(match dialog { Dialog::Properties(_) => 640.0, @@ -122,1302 +128,1463 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::About => 780.0, _ => 520.0, }); - match dialog { - Dialog::WordCount => { - close = crate::word_count_ui::body(ui, app); + if dialog_body(ui, app, dialog, &mut req) { + dialog_footer(ui, app, dialog, &mut req); + } + }); + finish_dialog(app, dialog, modal.should_close(), &mut req); +} + +/// The dialog's content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + match dialog { + Dialog::WordCount => { + req.close = crate::word_count_ui::body(ui, app); + } + Dialog::CreateImages => { + let (go, cancel) = crate::create_ui::image_import_body(ui, app); + req.images_now = go; + req.close = go || cancel; + return false; + } + Dialog::Properties(tab) => { + if !dialog_properties(ui, app, req, tab) { + return false; + } + } + Dialog::Split => { + if !dialog_split(ui, app, req) { + return false; + } + } + Dialog::ReplacePages => { + if !dialog_replace_pages(ui, app, req) { + return false; + } + } + Dialog::RedactPages => { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::RedactSearch => { + let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); + if go { + req.redact_now = Some(dialog); + } + req.import_words = import; + req.close = cancel; + return false; + } + Dialog::RedactProps => { + if !dialog_redact_props(ui, app, req) { + return false; + } + } + Dialog::RedactApply => { + let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); + let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::LinkProps => { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.link_draft.as_mut() else { + req.close = true; + return false; + }; + let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); + if ok { + req.link_now = Some(crate::link_ui::edit_for(d)); + } + req.close = ok || cancel; + return false; + } + Dialog::Extract => { + if !dialog_extract(ui, app, req) { + return false; + } + } + Dialog::RotatePages => { + if !dialog_rotate_pages(ui, app, req) { + return false; + } + } + Dialog::DuplicateField => { + if !dialog_duplicate_field(ui, app, req) { + return false; + } + } + // Never reached: `dialogs` shows this movable window above and returns before the modal. + // The arm keeps the match exhaustive. + Dialog::FieldTable => req.close = true, + Dialog::Optimize => { + let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); + req.optimize_now = ok; + req.close = ok || cancel; + if std::mem::take(&mut app.optimize_draft.audit) { + app.refresh_space_audit(); + req.next = Some(Dialog::AuditSpace); + } + return false; + } + Dialog::CertificateViewer => { + if !dialog_certificate_viewer(ui, app, req) { + return false; + } + } + Dialog::AuditSpace => { + if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { + req.next = Some(Dialog::Optimize); + } + return false; + } + Dialog::Sign => { + if crate::sign_ui::dialog(ui, app, &t) { + app.sign_draft = None; + req.close = true; + } + return false; + } + Dialog::SummarizeComments => { + if !dialog_summarize_comments(ui, app, req) { + return false; + } + } + Dialog::Revert => { + if !dialog_revert(ui, app, req) { + return false; + } + } + Dialog::OverwriteChanged => { + if !dialog_overwrite_changed(ui, app, req) { + return false; + } + } + Dialog::Print => { + if !dialog_print(ui, app, req) { + return false; + } + } + Dialog::RemoveHidden => { + let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::Sanitize => { + let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::FieldProps => { + let Some(d) = app.field_props.as_mut() else { + req.close = true; + return false; + }; + let (go, cancel) = crate::prepare::body(ui, d, &t); + req.apply = go; + req.field_props_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::BulkFieldProps => { + let Some(d) = app.bulk_field_props.as_mut() else { + req.close = true; + return false; + }; + let (go, cancel) = crate::bulk_fields::body(ui, d, &t); + req.apply = go; + req.bulk_field_props_now = req.apply; + req.close = cancel; + return false; + } + Dialog::CommentProps => { + let (go, cancel) = crate::comment_props::body(ui, app, &t); + req.apply = go; + req.props_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Signature => { + if !dialog_signature(ui, app, req) { + return false; + } + } + Dialog::AltText => { + let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t); + req.alt_now = save; + req.close = save || cancel; + return false; + } + Dialog::CreateStamp => { + let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t); + req.stamp_now = save; + req.close = save || cancel; + return false; + } + Dialog::PdfA => { + req.close = crate::standards_ui::body(ui, app, &t); + return false; + } + Dialog::ActionWizard => { + ui.set_width(620.0); + req.close = crate::actions_ui::body(ui, app, &t); + return false; + } + Dialog::CompareFiles => { + let (go, cancel) = crate::compare_ui::body(ui, app, &t); + req.compare_now = go; + req.close = go || cancel; + return false; + } + Dialog::JsConsole => { + ui.set_width(640.0); + req.close = crate::js_ui::console_body(ui, app, &t); + return false; + } + Dialog::DocumentJs => { + ui.set_width(640.0); + req.close = crate::js_ui::document_js_body(ui, app, &t); + return false; + } + Dialog::Preferences => { + if !dialog_preferences(ui, app, req) { + return false; + } + } + Dialog::RecognizeText => { + let (go, cancel) = crate::ocr_ui::body(ui, app, &t); + req.ocr_now = go; + req.close = go || cancel; + return false; + } + Dialog::AccessibilityOptions => { + let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); + req.a11y_now = start; + req.close = start || cancel; + return false; + } + Dialog::Export(kind) => { + let (go, cancel) = crate::export_ui::body(ui, app, &t, kind); + req.apply = go; + req.export_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Marks(kind) => { + ui.set_width(720.0); + let (go, cancel) = crate::marks_ui::body(ui, app, &t, kind); + req.apply = go; + req.marks_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::PageBoxes => { + let (go, cancel) = crate::pageboxes::body(ui, app, &t); + req.apply = go; + req.boxes_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Protect => { + let (go, cancel) = crate::protect::body(ui, app, &t); + req.apply = go; + req.protect_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::NumberPages => { + if !dialog_number_pages(ui, app, req) { + return false; + } + } + Dialog::Recovery => { + if !dialog_recovery(ui, app) { + return false; + } + } + Dialog::Shortcuts => { + if !dialog_shortcuts(ui) { + return false; } - Dialog::CreateImages => { - let (go, cancel) = crate::create_ui::image_import_body(ui, app); - images_now = go; - close = go || cancel; - return; + } + Dialog::About => { + if !dialog_about(ui, req) { + return false; } - Dialog::Properties(tab) => { - ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - for (tb, label) in [ - (PropsTab::Description, tl!("Description")), - (PropsTab::InitialView, tl!("Initial View")), - (PropsTab::Security, tl!("Security")), - (PropsTab::Fonts, tl!("Fonts")), - (PropsTab::Custom, tl!("Custom")), - (PropsTab::Advanced, tl!("Advanced")), - (PropsTab::SpaceAudit, tl!("Space Audit")), - ] { - if widgets::mode_tab(ui, label, tab == tb).clicked() { - next = Dialog::Properties(tb); + } + } + true +} + +/// `Dialog::Properties(tab)` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending, tab: PropsTab) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + for (tb, label) in [ + (PropsTab::Description, tl!("Description")), + (PropsTab::InitialView, tl!("Initial View")), + (PropsTab::Security, tl!("Security")), + (PropsTab::Fonts, tl!("Fonts")), + (PropsTab::Custom, tl!("Custom")), + (PropsTab::Advanced, tl!("Advanced")), + (PropsTab::SpaceAudit, tl!("Space Audit")), + ] { + if widgets::mode_tab(ui, label, tab == tb).clicked() { + req.next = Some(Dialog::Properties(tb)); + } + } + }); + ui.separator(); + let units = app.units; + let Some((_, id)) = app.active_ids() else { return false }; + let Some(doc) = app.session.get(id) else { return false }; + let i = &doc.info; + // Description shows plain values among text fields; they start where the fields' text does. + let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); + let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; + let row = |ui: &mut egui::Ui, k: &str, v: String| { + // Row labels are UI chrome; values are document data and stay as they are. + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.horizontal(|ui| { + ui.add_space(inset); + ui.add(egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap()); + }); + ui.end_row(); + }; + // A file name or path is read left to right even in an RTL UI: the extension + // stays at its visual end, as in the tab strip. + let path_row = |ui: &mut egui::Ui, k: &str, v: String| { + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.scope(|ui| { + ui.set_max_width(420.0); + // Align path text with editable fields while keeping its LTR text order. + ui.add_space(inset); + if v.is_empty() { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } else { + crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); + } + }); + ui.end_row(); + }; + egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + if tab == PropsTab::SpaceAudit { + ui.add_space(4.0); + crate::optimize_ui::audit_table(ui, &app.space_audit, &t); + return; + } + egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { + PropsTab::Description => { + path_row(ui, "File", doc.name.clone()); + match app.props_draft.as_mut() { + Some((_, draft)) if editable_info => { + for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { + let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + // Subject and Keywords are often a sentence or a list: they wrap and grow + // instead of hiding most of the value past the field's edge. + let field = if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; + ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); + ui.end_row(); } } - }); - ui.separator(); - let units = app.units; - let Some((_, id)) = app.active_ids() else { return }; - let Some(doc) = app.session.get(id) else { return }; - let i = &doc.info; - // Description shows plain values among text fields; they start where the fields' text does. - let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); - let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; - let row = |ui: &mut egui::Ui, k: &str, v: String| { - // Row labels are UI chrome; values are document data and stay as they are. - ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.horizontal(|ui| { - ui.add_space(inset); - ui.add( - egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(), - ); - }); - ui.end_row(); - }; - // A file name or path is read left to right even in an RTL UI: the extension - // stays at its visual end, as in the tab strip. - let path_row = |ui: &mut egui::Ui, k: &str, v: String| { - ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.scope(|ui| { - ui.set_max_width(420.0); - // Align path text with editable fields while keeping its LTR text order. - ui.add_space(inset); - if v.is_empty() { - ui.label(egui::RichText::new("—").color(t.text_faint)); - } else { - crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); - } - }); - ui.end_row(); - }; - egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { - if tab == PropsTab::SpaceAudit { - ui.add_space(4.0); - crate::optimize_ui::audit_table(ui, &app.space_audit, &t); - return; + _ => { + row(ui, "Title", i.title.clone().unwrap_or_default()); + row(ui, "Author", i.author.clone().unwrap_or_default()); + row(ui, "Subject", i.subject.clone().unwrap_or_default()); + row(ui, "Keywords", i.keywords.clone().unwrap_or_default()); } - egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { - PropsTab::Description => { - path_row(ui, "File", doc.name.clone()); - match app.props_draft.as_mut() { - Some((_, draft)) if editable_info => { - for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { - let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - // Subject and Keywords are often a sentence or a list: they wrap and grow - // instead of hiding most of the value past the field's edge. - let field = - if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; - ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); - ui.end_row(); - } - } - _ => { - row(ui, "Title", i.title.clone().unwrap_or_default()); - row(ui, "Author", i.author.clone().unwrap_or_default()); - row(ui, "Subject", i.subject.clone().unwrap_or_default()); - row(ui, "Keywords", i.keywords.clone().unwrap_or_default()); - } - } - row(ui, "Application", i.creator.clone().unwrap_or_default()); - row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); - } - PropsTab::InitialView => { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let editable = doc.allows_modification(); - let pages = i.pages.len(); - let Some((_, v)) = app.view_draft.as_mut() else { return }; - ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); - ui.end_row(); - let label = ui.label(tl!("Navigation tab")); - ui.add_enabled_ui(editable, |ui| { - craft_ui::ComboBox::from_id_salt("iv-nav") - .selected_text(tl!(format!("{:?}", v.navigation) - .replace("PageOnly", "Page Only") - .replace("Pages", "Pages Panel and Page") - .as_str())) - .show_ui(ui, |ui| { - for (n, l) in [ - (N::PageOnly, tl!("Page Only")), - (N::Bookmarks, tl!("Bookmarks Panel and Page")), - (N::Pages, tl!("Pages Panel and Page")), - (N::Attachments, tl!("Attachments Panel and Page")), - (N::Layers, tl!("Layers Panel and Page")), - ] { - ui.selectable_value(&mut v.navigation, n, l); - } - }) - .response - .labelled_by(label.id); - }); - ui.end_row(); - let label = ui.label(tl!("Page layout")); - ui.add_enabled_ui(editable, |ui| { - let names = [ - (L::Default, tl!("Default")), - (L::SinglePage, tl!("Single Page")), - (L::SinglePageContinuous, tl!("Single Page Continuous")), - (L::TwoUp, tl!("Two-Up (Facing)")), - (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), - (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), - (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), - ]; - let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - craft_ui::ComboBox::from_id_salt("iv-layout") - .selected_text(shown) - .show_ui(ui, |ui| { - for (l, n) in names { - ui.selectable_value(&mut v.layout, l, n); - } - }) - .response - .labelled_by(label.id); - }); - ui.end_row(); - let label = ui.label(tl!("Magnification")); - ui.add_enabled_ui(editable, |ui| { - ui.horizontal(|ui| { - let names = [ - (M::Default, tl!("Default")), - (M::ActualSize, tl!("Actual Size")), - (M::FitPage, tl!("Fit Page")), - (M::FitWidth, tl!("Fit Width")), - (M::FitHeight, tl!("Fit Height")), - (M::FitVisible, tl!("Fit Visible")), - ]; - let shown = match v.magnification { - M::Percent(p) => format!("{p:.0}%"), - m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), - }; - craft_ui::ComboBox::from_id_salt("iv-mag") - .selected_text(shown) - .show_ui(ui, |ui| { - for (m, n) in names { - ui.selectable_value(&mut v.magnification, m, n); - } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { - ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); - } - }) - .response - .labelled_by(label.id); - }); - }); - ui.end_row(); - let label = ui.label(tl!("Open to page")); - ui.add_enabled_ui(editable, |ui| { - let mut p = v.page + 1; - if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { - v.page = p - 1; - } - ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); - }); - ui.end_row(); - ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); - ui.end_row(); - ui.label(""); - ui.add_enabled_ui(editable, |ui| { - ui.vertical(|ui| { - ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); - ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); - ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); - ui.horizontal(|ui| { - ui.label(tl!("Show:")); - widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); - widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); - }); - }); - }); - ui.end_row(); - ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); - ui.end_row(); - ui.label(""); - ui.add_enabled_ui(editable, |ui| { - ui.vertical(|ui| { - ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); - ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); - ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); - }); - }); - ui.end_row(); - } - PropsTab::Security => match doc.security_summary() { - None => { - row(ui, "Security method", tl!("No security").to_string()); - row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); - if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { - link_command = Some("protect.password"); - } - } - Some(sec) => { - row(ui, "Security method", tl!("Password security").to_string()); - row(ui, "Encryption", sec.method.clone()); - row( - ui, - "Opened with", - if sec.pending { - tl!("— (protection is applied when you save)").to_string() - } else if sec.owner { - tl!("Owner password (no restrictions apply)").to_string() - } else { - tl!("User password").to_string() - }, - ); - // A restricted document asks for its permissions password first. - if doc.allows_security_change() || doc.can_unlock() { - ui.horizontal(|ui| { - if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() { - link_command = Some("protect.unlock"); - } - if ui.button(tl!("Change settings…")).clicked() { - link_command = Some("protect.password"); - } - if ui.button(tl!("Remove security")).clicked() { - link_command = Some("protect.remove"); - } - }); - } - let p = sec.permissions; - let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); - row( - ui, - "Printing", - if !p.print() { - tl!("Not allowed").to_string() - } else if p.print_high_quality() { - tl!("High resolution").to_string() - } else { - tl!("Low resolution").to_string() - }, - ); - row(ui, "Changing the document", yes(p.modify())); - row(ui, "Document assembly", yes(p.assemble())); - row(ui, "Content copying", yes(p.copy())); - row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility())); - row(ui, "Commenting", yes(p.annotate())); - row(ui, "Filling of form fields", yes(p.fill_forms())); - } - }, - PropsTab::Fonts => { - if i.fonts.is_empty() { - row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); - } - for f in &i.fonts { - let mut detail = f.kind.clone(); - if let Some(e) = &f.encoding { - detail.push_str(&format!(" · {e}")); - } - detail.push_str(if f.subset { - " · Embedded subset" - } else if f.embedded { - " · Embedded" - } else { - " · Not embedded (substituted)" - }); - row(ui, &f.name, detail); + } + row(ui, "Application", i.creator.clone().unwrap_or_default()); + row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); + } + PropsTab::InitialView => { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let editable = doc.allows_modification(); + let pages = i.pages.len(); + let Some((_, v)) = app.view_draft.as_mut() else { return }; + ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); + ui.end_row(); + let label = ui.label(tl!("Navigation tab")); + ui.add_enabled_ui(editable, |ui| { + craft_ui::ComboBox::from_id_salt("iv-nav") + .selected_text(tl!(format!("{:?}", v.navigation) + .replace("PageOnly", "Page Only") + .replace("Pages", "Pages Panel and Page") + .as_str())) + .show_ui(ui, |ui| { + for (n, l) in [ + (N::PageOnly, tl!("Page Only")), + (N::Bookmarks, tl!("Bookmarks Panel and Page")), + (N::Pages, tl!("Pages Panel and Page")), + (N::Attachments, tl!("Attachments Panel and Page")), + (N::Layers, tl!("Layers Panel and Page")), + ] { + ui.selectable_value(&mut v.navigation, n, l); } - } - PropsTab::Custom => { - // Add takes a new name, Update changes the value of the listed property the - // Name field names, Delete removes it; OK writes the changes as one step. - let editable = doc.allows_modification(); - let Some((_, d)) = app.custom_draft.as_mut() else { return }; - let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); - ui.add_enabled( - editable, - widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name"), - ) - .labelled_by(l.id); - ui.end_row(); - let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); - ui.add_enabled( - editable, - widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value"), - ) - .labelled_by(l.id); - ui.end_row(); - // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. - let name = d.name.clone(); - let value = d.value.trim().to_owned(); - let listed = d.props.iter().position(|(k, _)| *k == name); - // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. - let file_props = doc.custom_info(); - let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); - let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); - ui.label(""); - // A file can hold a custom name that `check_custom_key` refuses, such as one - // with a space: the tab lists it, and Add, Update and Delete stay off for it. - let why = |b: egui::Response| match &refused { - Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), - None => b, - }; - // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. - let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); - // `custom_info` lists at most 256 properties, and a new name goes after the file's - // entries, so Add is off once the draft or the file's own list holds 256. - let room = d.props.len() < 256 && file_props.len() < 256; - ui.horizontal(|ui| { - let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); - if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { - d.props.push((name.clone(), value.clone())); - d.name.clear(); - d.value.clear(); - } - let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); - if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() - && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) - { - p.1 = value.clone(); - } - if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() - && let Some(n) = listed.filter(|n| *n < d.props.len()) - { - d.props.remove(n); - d.name.clear(); - d.value.clear(); - } - }); - ui.end_row(); - if d.props.is_empty() { - ui.label(""); - ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); - ui.end_row(); + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + let label = ui.label(tl!("Page layout")); + ui.add_enabled_ui(editable, |ui| { + let names = [ + (L::Default, tl!("Default")), + (L::SinglePage, tl!("Single Page")), + (L::SinglePageContinuous, tl!("Single Page Continuous")), + (L::TwoUp, tl!("Two-Up (Facing)")), + (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), + (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), + (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), + ]; + let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); + craft_ui::ComboBox::from_id_salt("iv-layout") + .selected_text(shown) + .show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); } - // A click on a property fills the fields with it; values are document data. - let mut picked = None; - for (k, v) in &d.props { - if ui.selectable_label(*k == name, k.as_str()).clicked() { - picked = Some((k.clone(), v.clone())); + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + let label = ui.label(tl!("Magnification")); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + let names = [ + (M::Default, tl!("Default")), + (M::ActualSize, tl!("Actual Size")), + (M::FitPage, tl!("Fit Page")), + (M::FitWidth, tl!("Fit Width")), + (M::FitHeight, tl!("Fit Height")), + (M::FitVisible, tl!("Fit Visible")), + ]; + let shown = match v.magnification { + M::Percent(p) => format!("{p:.0}%"), + m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), + }; + craft_ui::ComboBox::from_id_salt("iv-mag") + .selected_text(shown) + .show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); } - ui.scope(|ui| { - ui.set_max_width(420.0); - ui.add(egui::Label::new(v.as_str()).wrap()); - }); - ui.end_row(); - } - if let Some((k, v)) = picked { - d.name = k; - d.value = v; - } - } - PropsTab::Advanced => { - row(ui, "PDF version", i.pdf_version.clone()); - path_row(ui, "Location", doc.path.clone().unwrap_or_default()); - row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); - let p = &i.pages[0]; - row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); - row(ui, "Number of pages", i.pages.len().to_string()); - row(ui, "Tagged PDF", yes(i.tagged)); - row(ui, "Form fields", i.fields.len().to_string()); - row(ui, "Comments", i.annotations.len().to_string()); - row(ui, "Layers", i.layers.len().to_string()); - row(ui, "Attachments", i.attachments.len().to_string()); - row(ui, "JavaScript", yes(i.has_javascript)); - // Each incremental update is a revision; earlier ones open as their own document. - let ends = doc.revision_ends(); - if ends.len() > 1 { - ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - // Reserve the full wrapped height before the grid starts the next row. - ui.vertical(|ui| { - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); - } - } - }); - }); - ui.end_row(); - } - // Reading Options: binding and language. - if let Some((_, v)) = app.view_draft.as_mut() { - let editable = doc.allows_modification(); - ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); - ui.add_enabled_ui(editable, |ui| { - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); - widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); - }); - }); - ui.end_row(); - let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); - let mut lang = v.language.clone().unwrap_or_default(); - if ui - .add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) - .labelled_by(l.id) - .changed() - { - v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); } - ui.end_row(); - } - // What was repaired while reading a damaged file (fidelity: never silent). - let repairs = doc.repair_log(); - row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); - if !repairs.is_empty() { - ui.label(""); - egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { - for r in &repairs { - ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); - } - }); - ui.end_row(); - } - } - // Laid out above, outside the two-column grid. - PropsTab::SpaceAudit => {} + }) + .response + .labelled_by(label.id); }); }); - } - Dialog::Split => { - ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); - ui.add_space(8.0); - let Some((vi, id)) = app.active_ids() else { return }; - let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); - let selected: Vec = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect(); - let marks = app.session.bookmark_splits(id); - let draft = &mut app.split_draft; - use crate::SplitMode as M; - if selected.is_empty() && draft.mode == M::Selection { - draft.mode = M::Pages; - } - widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); - ui.add_enabled_ui(draft.mode == M::Pages, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - ui.label(tl!("Pages per file")); - ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); - }); + ui.end_row(); + let label = ui.label(tl!("Open to page")); + ui.add_enabled_ui(editable, |ui| { + let mut p = v.page + 1; + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { + v.page = p - 1; + } + ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); }); - widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); - ui.add_enabled_ui(draft.mode == M::Size, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - ui.label(tl!("At most")); - ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); + ui.end_row(); + ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); + ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); + ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); + ui.horizontal(|ui| { + ui.label(tl!("Show:")); + widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); + widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); + }); }); }); - ui.add_enabled_ui(!marks.is_empty(), |ui| { - widgets::radio_value( - ui, - &mut draft.mode, - M::Bookmarks, - crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), - ) - }); - ui.add_enabled_ui(!selected.is_empty(), |ui| { - widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) + ui.end_row(); + ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); + ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); + ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); + }); }); - let plan = match draft.mode { - M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), - M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)), - M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize), - M::Bookmarks => crate::SplitPlan::Bookmarks, - }; - let files = match &plan { - crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()), - crate::SplitPlan::Bookmarks => { - Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len()) + ui.end_row(); + } + PropsTab::Security => match doc.security_summary() { + None => { + row(ui, "Security method", tl!("No security").to_string()); + row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); + if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { + req.link_command = Some("protect.password"); } - crate::SplitPlan::Size(_) => None, - }; - ui.add_space(8.0); - let text = match files { - Some(f) => { - if f == 1 { - crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) + } + Some(sec) => { + row(ui, "Security method", tl!("Password security").to_string()); + row(ui, "Encryption", sec.method.clone()); + row( + ui, + "Opened with", + if sec.pending { + tl!("— (protection is applied when you save)").to_string() + } else if sec.owner { + tl!("Owner password (no restrictions req.apply)").to_string() } else { - crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) - } + tl!("User password").to_string() + }, + ); + // A restricted document asks for its permissions password first. + if doc.allows_security_change() || doc.can_unlock() { + ui.horizontal(|ui| { + if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() { + req.link_command = Some("protect.unlock"); + } + if ui.button(tl!("Change settings…")).clicked() { + req.link_command = Some("protect.password"); + } + if ui.button(tl!("Remove security")).clicked() { + req.link_command = Some("protect.remove"); + } + }); } - None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), - }; - ui.label(egui::RichText::new(text).color(t.text_muted)); - if files.is_none_or(|f| f > 1) { - split_ready = Some(plan); + let p = sec.permissions; + let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); + row( + ui, + "Printing", + if !p.print() { + tl!("Not allowed").to_string() + } else if p.print_high_quality() { + tl!("High resolution").to_string() + } else { + tl!("Low resolution").to_string() + }, + ); + row(ui, "Changing the document", yes(p.modify())); + row(ui, "Document assembly", yes(p.assemble())); + row(ui, "Content copying", yes(p.copy())); + row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility())); + row(ui, "Commenting", yes(p.annotate())); + row(ui, "Filling of form fields", yes(p.fill_forms())); } - } - Dialog::ReplacePages => { - let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); - let Some(d) = app.replace_draft.as_mut() else { - close = true; - return; - }; - ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - d.to = d.to.clamp(1, count); - d.from = d.from.clamp(1, d.to); - ui.horizontal(|ui| { - let from_label = ui.label(tl!("Original: replace pages")); - let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); - let to_label = ui.label(tl!("to")); - let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); - if from.changed() || to.changed() { - d.range_edited = true; + }, + PropsTab::Fonts => { + if i.fonts.is_empty() { + row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); + } + for f in &i.fonts { + let mut detail = f.kind.clone(); + if let Some(e) = &f.encoding { + detail.push_str(&format!(" · {e}")); } - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); - }); - let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; - let max_start = d.src_pages.saturating_sub(n) + 1; - d.src_from = d.src_from.clamp(1, max_start.max(1)); - ui.horizontal(|ui| { - let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); - ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); - ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); - }); - let fits = n <= d.src_pages; - ui.add_space(6.0); - ui.label( - egui::RichText::new(if fits { - tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") + detail.push_str(if f.subset { + " · Embedded subset" + } else if f.embedded { + " · Embedded" } else { - tl!("The replacement file doesn't have that many pages.") - }) - .small() - .color(t.text_faint), - ); - ui.add_space(10.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { - replace_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::RedactPages => { - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::RedactSearch => { - let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); - if go { - redact_now = Some(dialog); - } - import_words = import; - close = cancel; - return; - } - Dialog::RedactProps => { - let mut prefs = app.redact_prefs.clone(); - let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); - app.redact_prefs = prefs; - if ok && app.redact_prefs.remember { - app.redact_defaults = app.redact_prefs.clone(); - } - if cancel && let Some(before) = app.redact_before.take() { - app.redact_prefs = before; - } - if ok { - app.redact_before = None; - } - close = ok || cancel; - return; - } - Dialog::RedactApply => { - let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); - let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::LinkProps => { - let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let Some(d) = app.link_draft.as_mut() else { - close = true; - return; - }; - let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); - if ok { - link_now = Some(crate::link_ui::edit_for(d)); + " · Not embedded (substituted)" + }); + row(ui, &f.name, detail); } - close = ok || cancel; - return; } - Dialog::Extract => { - let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); - ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - // Page ranges to take instead of the selection (#3593); the hint is the selection. - let hint = match crate::combine_ui::range_of(&selected, total) { - all if all.is_empty() && total > 1 => format!("1-{total}"), - all if all.is_empty() => "1".to_string(), - some => some, + PropsTab::Custom => { + // Add takes a new name, Update changes the value of the listed property the + // Name field names, Delete removes it; OK writes the changes as one step. + let editable = doc.allows_modification(); + let Some((_, d)) = app.custom_draft.as_mut() else { return }; + let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); + ui.add_enabled(editable, widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name")) + .labelled_by(l.id); + ui.end_row(); + let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); + ui.add_enabled(editable, widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value")) + .labelled_by(l.id); + ui.end_row(); + // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. + let name = d.name.clone(); + let value = d.value.trim().to_owned(); + let listed = d.props.iter().position(|(k, _)| *k == name); + // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. + let file_props = doc.custom_info(); + let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); + let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); + ui.label(""); + // A file can hold a custom name that `check_custom_key` refuses, such as one + // with a space: the tab lists it, and Add, Update and Delete stay off for it. + let why = |b: egui::Response| match &refused { + Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), + None => b, }; + // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. + let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); + // `custom_info` lists at most 256 properties, and a new name goes after the file's + // entries, so Add is off once the draft or the file's own list holds 256. + let room = d.props.len() < 256 && file_props.len() < 256; ui.horizontal(|ui| { - let l = ui.label(tl!("Pages")); - ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); - }); - let chosen = app.extract_draft.pages_to_extract(selected, total); - match &chosen { - Ok(pages) if pages.len() == 1 => { - ui.label(tl!("1 page selected.")); + let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); + if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { + d.props.push((name.clone(), value.clone())); + d.name.clear(); + d.value.clear(); } - Ok(pages) => { - ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); + if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() + && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) + { + p.1 = value.clone(); } - Err(e) => { - ui.colored_label(ui.visuals().error_fg_color, e); + if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() + && let Some(n) = listed.filter(|n| *n < d.props.len()) + { + d.props.remove(n); + d.name.clear(); + d.value.clear(); } - } - ui.add_space(4.0); - // The name the pages are saved under (#737); separate files add " (page N)". - ui.horizontal(|ui| { - let l = ui.label(tl!("File name")); - ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); }); - ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); - ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { - extract_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; + ui.end_row(); + if d.props.is_empty() { + ui.label(""); + ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); + ui.end_row(); + } + // A click on a property fills the fields with it; values are document data. + let mut picked = None; + for (k, v) in &d.props { + if ui.selectable_label(*k == name, k.as_str()).clicked() { + picked = Some((k.clone(), v.clone())); } - }); - return; + ui.scope(|ui| { + ui.set_max_width(420.0); + ui.add(egui::Label::new(v.as_str()).wrap()); + }); + ui.end_row(); + } + if let Some((k, v)) = picked { + d.name = k; + d.value = v; + } } - Dialog::RotatePages => { - use pdfcraft_engine::{PageOrientation as O, PageParity as P}; - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let d = &mut app.rotate_draft; - ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Direction:")); - craft_ui::ComboBox::from_id_salt("rotate-dir") - .selected_text(match d.degrees { - 270 => tl!("Counterclockwise 90 degrees"), - 180 => tl!("180 degrees"), - _ => tl!("Clockwise 90 degrees"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); - ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); - ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); + PropsTab::Advanced => { + row(ui, "PDF version", i.pdf_version.clone()); + path_row(ui, "Location", doc.path.clone().unwrap_or_default()); + row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); + let p = &i.pages[0]; + row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); + row(ui, "Number of pages", i.pages.len().to_string()); + row(ui, "Tagged PDF", yes(i.tagged)); + row(ui, "Form fields", i.fields.len().to_string()); + row(ui, "Comments", i.annotations.len().to_string()); + row(ui, "Layers", i.layers.len().to_string()); + row(ui, "Attachments", i.attachments.len().to_string()); + row(ui, "JavaScript", yes(i.has_javascript)); + // Each incremental update is a revision; earlier ones open as their own document. + let ends = doc.revision_ends(); + if ends.len() > 1 { + ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + req.open_revision = Some(n); + } + } }); + }); ui.end_row(); - ui.label(tl!("Pages:")); - ui.vertical(|ui| { - widgets::radio_value(ui, &mut d.which, 0, tl!("All")); - widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); + } + // Reading Options: binding and language. + if let Some((_, v)) = app.view_draft.as_mut() { + let editable = doc.allows_modification(); + ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); + ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.which, 2, tl!("From")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); + widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); }); }); ui.end_row(); - ui.label(tl!("Rotate:")); - craft_ui::ComboBox::from_id_salt("rotate-parity") - .selected_text(match d.parity { - P::Both => tl!("Even and Odd Pages"), - P::Even => tl!("Even Pages Only"), - P::Odd => tl!("Odd Pages Only"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); - ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); - ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); - }); + let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); + let mut lang = v.language.clone().unwrap_or_default(); + if ui + .add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) + .labelled_by(l.id) + .changed() + { + v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); + } ui.end_row(); + } + // What was repaired while reading a damaged file (fidelity: never silent). + let repairs = doc.repair_log(); + row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); + if !repairs.is_empty() { ui.label(""); - craft_ui::ComboBox::from_id_salt("rotate-orient") - .selected_text(match d.orientation { - O::Both => tl!("Landscape and Portrait Pages"), - O::Landscape => tl!("Landscape Pages"), - O::Portrait => tl!("Portrait Pages"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); - ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); - ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); - }); + egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { + for r in &repairs { + ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); + } + }); ui.end_row(); - }); - d.to = d.to.max(d.from); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - rotate_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::DuplicateField => { - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let Some(d) = app.duplicate_draft.as_mut() else { - close = true; - return; - }; - ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); - widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.all, false, tl!("From")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - d.to = d.to.max(d.from); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; - duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - // Never reached: `dialogs` shows this movable window above and returns before the modal. - // The arm keeps the match exhaustive. - Dialog::FieldTable => close = true, - Dialog::Optimize => { - let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); - optimize_now = ok; - close = ok || cancel; - if std::mem::take(&mut app.optimize_draft.audit) { - app.refresh_space_audit(); - next = Dialog::AuditSpace; } - return; } - Dialog::CertificateViewer => { - ui.set_width(700.0); - let trusted = app.session.trusted_certificates().to_vec(); - let Some(v) = app.cert_viewer.as_mut() else { - close = true; - return; - }; - let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); - close = done; - match action { - Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), - Some(crate::sign_ui::CertAction::Export(c)) => { - app.export_certificate(&c); - } - None => {} - } - return; + // Laid out above, outside the two-column grid. + PropsTab::SpaceAudit => {} + }); + }); + true +} + +/// `Dialog::Split` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_split(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((vi, id)) = app.active_ids() else { return false }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let selected: Vec = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect(); + let marks = app.session.bookmark_splits(id); + let draft = &mut app.split_draft; + use crate::SplitMode as M; + if selected.is_empty() && draft.mode == M::Selection { + draft.mode = M::Pages; + } + widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); + ui.add_enabled_ui(draft.mode == M::Pages, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(tl!("Pages per file")); + ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); + }); + }); + widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); + ui.add_enabled_ui(draft.mode == M::Size, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(tl!("At most")); + ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); + }); + }); + ui.add_enabled_ui(!marks.is_empty(), |ui| { + widgets::radio_value( + ui, + &mut draft.mode, + M::Bookmarks, + crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), + ) + }); + ui.add_enabled_ui(!selected.is_empty(), |ui| { + widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) + }); + let plan = match draft.mode { + M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), + M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)), + M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize), + M::Bookmarks => crate::SplitPlan::Bookmarks, + }; + let files = match &plan { + crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()), + crate::SplitPlan::Bookmarks => { + Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len()) + } + crate::SplitPlan::Size(_) => None, + }; + ui.add_space(8.0); + let text = match files { + Some(f) => { + if f == 1 { + crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) + } else { + crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) } - Dialog::AuditSpace => { - if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { - next = Dialog::Optimize; - } - return; + } + None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), + }; + ui.label(egui::RichText::new(text).color(t.text_muted)); + if files.is_none_or(|f| f > 1) { + req.split_ready = Some(plan); + } + true +} + +/// `Dialog::ReplacePages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_replace_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let Some(d) = app.replace_draft.as_mut() else { + req.close = true; + return false; + }; + ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + d.to = d.to.clamp(1, count); + d.from = d.from.clamp(1, d.to); + ui.horizontal(|ui| { + let from_label = ui.label(tl!("Original: replace pages")); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); + if from.changed() || to.changed() { + d.range_edited = true; + } + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); + }); + let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; + let max_start = d.src_pages.saturating_sub(n) + 1; + d.src_from = d.src_from.clamp(1, max_start.max(1)); + ui.horizontal(|ui| { + let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); + ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); + }); + let fits = n <= d.src_pages; + ui.add_space(6.0); + ui.label( + egui::RichText::new(if fits { + tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") + } else { + tl!("The replacement file doesn't have that many pages.") + }) + .small() + .color(t.text_faint), + ); + ui.add_space(10.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { + req.replace_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::RedactProps` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_redact_props(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let mut prefs = app.redact_prefs.clone(); + let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); + app.redact_prefs = prefs; + if ok && app.redact_prefs.remember { + app.redact_defaults = app.redact_prefs.clone(); + } + if cancel && let Some(before) = app.redact_before.take() { + app.redact_prefs = before; + } + if ok { + app.redact_before = None; + } + req.close = ok || cancel; + false +} + +/// `Dialog::Extract` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_extract(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); + ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + // Page ranges to take instead of the selection (#3593); the hint is the selection. + let hint = match crate::combine_ui::range_of(&selected, total) { + all if all.is_empty() && total > 1 => format!("1-{total}"), + all if all.is_empty() => "1".to_string(), + some => some, + }; + ui.horizontal(|ui| { + let l = ui.label(tl!("Pages")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); + }); + let chosen = app.extract_draft.pages_to_extract(selected, total); + match &chosen { + Ok(pages) if pages.len() == 1 => { + ui.label(tl!("1 page selected.")); + } + Ok(pages) => { + ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + } + Err(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + } + ui.add_space(4.0); + // The name the pages are saved under (#737); separate files add " (page N)". + ui.horizontal(|ui| { + let l = ui.label(tl!("File name")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); + }); + ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); + ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { + req.extract_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::RotatePages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + use pdfcraft_engine::{PageOrientation as O, PageParity as P}; + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let d = &mut app.rotate_draft; + ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Direction:")); + craft_ui::ComboBox::from_id_salt("rotate-dir") + .selected_text(match d.degrees { + 270 => tl!("Counterclockwise 90 degrees"), + 180 => tl!("180 degrees"), + _ => tl!("Clockwise 90 degrees"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); + }); + ui.end_row(); + ui.label(tl!("Pages:")); + ui.vertical(|ui| { + widgets::radio_value(ui, &mut d.which, 0, tl!("All")); + widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.which, 2, tl!("From")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + }); + }); + ui.end_row(); + ui.label(tl!("Rotate:")); + craft_ui::ComboBox::from_id_salt("rotate-parity") + .selected_text(match d.parity { + P::Both => tl!("Even and Odd Pages"), + P::Even => tl!("Even Pages Only"), + P::Odd => tl!("Odd Pages Only"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); + ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); + ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); + }); + ui.end_row(); + ui.label(""); + craft_ui::ComboBox::from_id_salt("rotate-orient") + .selected_text(match d.orientation { + O::Both => tl!("Landscape and Portrait Pages"), + O::Landscape => tl!("Landscape Pages"), + O::Portrait => tl!("Portrait Pages"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); + ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); + ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); + }); + ui.end_row(); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.rotate_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::DuplicateField` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.duplicate_draft.as_mut() else { + req.close = true; + return false; + }; + ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); + widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.all, false, tl!("From")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; + req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::CertificateViewer` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_certificate_viewer(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.set_width(700.0); + let trusted = app.session.trusted_certificates().to_vec(); + let Some(v) = app.cert_viewer.as_mut() else { + req.close = true; + return false; + }; + let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); + req.close = done; + match action { + Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), + Some(crate::sign_ui::CertAction::Export(c)) => { + app.export_certificate(&c); + } + None => {} + } + false +} + +/// `Dialog::SummarizeComments` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_summarize_comments(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(tl!("Choose a layout:")); + let _ = ui.add(widgets::radio(true, tl!("Comments only"))); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(tl!("Sort comments by:")); + craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { + for s in pdfcraft_engine::SummarySort::ALL { + ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); } - Dialog::Sign => { - if crate::sign_ui::dialog(ui, app, &t) { - app.sign_draft = None; - close = true; - } - return; + }); + }); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { + req.summarize_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::Revert` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_revert(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt( + tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), + &[("name", &name)], + )); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Revert"), true).clicked() { + req.revert_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::OverwriteChanged` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_overwrite_changed(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + #[cfg(not(target_arch = "wasm32"))] + let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default(); + #[cfg(target_arch = "wasm32")] + let name = String::new(); + ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt( + tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."), + &[("name", &name)], + )); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() { + req.overwrite_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Save as…"), false).clicked() { + req.overwrite_elsewhere = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::Print` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_print(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let Some((i, id)) = app.active_ids() else { + req.close = true; + return false; + }; + if app.print_draft.handling == crate::print_ui::Handling::Label { + app.read_label_crops(id); + } + let Some(doc) = app.session.get(id) else { return false }; + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let rasters = crate::print_ui::preview_rasters( + &app.print_draft, + &sizes, + &labels, + ui.ctx().pixels_per_point(), + ui.ctx().input(|i| i.max_texture_side) as f32, + ); + let view = &mut app.views[i]; + view.set_print_grayscale(app.print_draft.grayscale); + view.queue_print_previews(&rasters); + let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { + view.need_thumbnail(p, true); + view.page_preview(p) + }); + req.print_go = go; + req.close = go || cancel; + false +} + +/// `Dialog::Signature` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let (go, cancel, browse) = + crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo); + req.apply = go; + if browse { + app.pick_signature_image(); + } + if req.apply { + let d = std::mem::take(&mut app.signature_draft); + let tool = if d.initials { + app.initials = d.saved(); + crate::fill_sign::FillTool::Initials + } else { + app.signature = d.saved(); + crate::fill_sign::FillTool::Signature + }; + app.quick_tool = crate::QuickTool::Fill(tool); + app.toast = None; + } + req.close = req.apply || cancel; + false +} + +/// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + // The settings outgrow a short window: scroll them rather than run off the screen. + // The OK row stays below the scroll, in reach however far the settings are scrolled. + let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); + // A scroll area starts from the room the dialog offers it, and a modal offers more + // each frame for the first few, so with OK outside the scroll the dialog would grow + // over those frames. Asking for the full height as the least it may scroll in + // (which only applies when the settings overflow it) opens the dialog at full size. + // A scrollbar of its own: the floating one lies over the right edge of the theme + // cards and other full-width rows (#3557). + ui.scope(|ui| { + ui.spacing_mut().scroll.floating = false; + egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); + }); + req.close = crate::js_ui::preferences_buttons(ui); + false +} + +/// `Dialog::NumberPages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + use pdfcraft_engine::LabelStyle as L; + ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((_, id)) = app.active_ids() else { return false }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let d = &mut app.number_draft; + d.to = d.to.clamp(1, n); + d.from = d.from.clamp(1, d.to); + egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Pages")); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); + }); + ui.end_row(); + ui.label(tl!("Style")); + let styles = [ + (L::Decimal, "1, 2, 3"), + (L::LowerRoman, "i, ii, iii"), + (L::UpperRoman, "I, II, III"), + (L::LowerAlpha, "a, b, c"), + (L::UpperAlpha, "A, B, C"), + (L::None, tl!("None (prefix only)")), + ]; + let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); + craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { + for (s, l) in styles { + ui.selectable_value(&mut d.style, s, l); } - Dialog::SummarizeComments => { - ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(tl!("Choose a layout:")); - let _ = ui.add(widgets::radio(true, tl!("Comments only"))); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(tl!("Sort comments by:")); - craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { - for s in pdfcraft_engine::SummarySort::ALL { - ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); - } - }); - }); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { - summarize_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; + }); + ui.end_row(); + let l = ui.label(tl!("Prefix")); + ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id); + ui.end_row(); + ui.label(tl!("Start")); + ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); + ui.end_row(); + }); + d.to = d.to.max(d.from); + let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); + ui.add_space(8.0); + let preview = if d.from == d.to { + label(d.start) + } else { + format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) + }; + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted), + ); + req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); + true +} + +/// `Dialog::Recovery` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_recovery(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> bool { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(crate::icons::image("clock-3", 22.0, t.accent)); + ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); + }); + ui.add_space(6.0); + ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); + ui.add_space(8.0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { + for m in &app.recoverable { + ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); + let mins = now.saturating_sub(m.saved_at) / 60; + let when = match mins { + 0 => tl!("just now").to_string(), + 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), + _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), + }; + let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; + ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); + ui.end_row(); + } + }); + true +} + +/// `Dialog::Shortcuts` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { + ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); + ui.add_space(8.0); + use crate::commands::{ + ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, ZOOM_OUT, + }; + // Registered commands first (always in sync with the real bindings), then the + // keys the document view handles itself. + let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS + .iter() + .filter_map(|c| c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string()))) + .collect(); + let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s); + let pair = |a, b| format!("{} / {}", key(a), key(b)); + // Key names, translated where a catalog has them (not scanned as UI literals). + let named = |k: &str| tl!(k).to_string(); + let scroll = crate::i18n::fmt(tl!("Zoom in / out (also pinch or {key}-scroll)"), &[("key", crate::commands::command_modifier_label(ui.ctx()))]); + for (k, v) in [ + (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), + (key(COPY), tl!("Copy selected text").to_string()), + (named("Double-click"), tl!("Select a word").to_string()), + (crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / req.close find").to_string()), + (key(ACTUAL_SIZE), tl!("Actual size").to_string()), + (key(PAGE_LEVEL), tl!("Zoom to page level").to_string()), + (key(FIT_WIDTH), tl!("Fit to width").to_string()), + (pair(ZOOM_IN, ZOOM_OUT), scroll), + (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), + (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), + (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), + (named("V"), tl!("Select (V)").to_string()), + (named("H / Space (hold)"), tl!("Hand (H)").to_string()), + (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), + (key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()), + ] { + rows.push((k, v)); + } + egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { + egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { + for (k, v) in &rows { + ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5))); + ui.label(v); + ui.end_row(); } - Dialog::Revert => { - let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt( - tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), - &[("name", &name)], - )); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Revert"), true).clicked() { - revert_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; + }); + }); + true +} + +/// `Dialog::About` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + // Tabs About · Contributors · Models (craftrules standards/contributors.md). + let tab_id = egui::Id::unique("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal_wrapped(|ui| { + for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { + let i = i as u8; + if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { + tab = i; } - Dialog::OverwriteChanged => { - #[cfg(not(target_arch = "wasm32"))] - let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default(); - #[cfg(target_arch = "wasm32")] - let name = String::new(); - ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt( - tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."), - &[("name", &name)], - )); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() { - overwrite_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Save as…"), false).clicked() { - overwrite_elsewhere = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.horizontal(|ui| { + widgets::artcraft_mark(ui, 40.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); + ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); }); - return; + }); + ui.add_space(6.0); + ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); + ui.label( + egui::RichText::new( + "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", + ) + .color(t.text_muted) + .small(), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); + widgets::artcraft_logo(ui, 16.0); + }); + ui.add_space(6.0); + if let Some(cmd) = widgets::community_links(ui) { + req.link_command = Some(cmd); } - Dialog::Print => { - let Some((i, id)) = app.active_ids() else { - close = true; - return; - }; - if app.print_draft.handling == crate::print_ui::Handling::Label { - app.read_label_crops(id); + } + } + true +} + +fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { + ui.add_space(16.0); + let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); + // One row high: on its own, a right-to-left layout centres the buttons in all the height + // left, which after a taller Properties tab is the room that tab left behind. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if dialog == Dialog::Recovery { + if widgets::pill_button(ui, tl!("Recover"), true).clicked() { + req.recover = Some(true); + req.close = true; } - let Some(doc) = app.session.get(id) else { return }; - let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); - let rasters = crate::print_ui::preview_rasters( - &app.print_draft, - &sizes, - &labels, - ui.ctx().pixels_per_point(), - ui.ctx().input(|i| i.max_texture_side) as f32, - ); - let view = &mut app.views[i]; - view.set_print_grayscale(app.print_draft.grayscale); - view.queue_print_previews(&rasters); - let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { - view.need_thumbnail(p, true); - view.page_preview(p) - }); - print_go = go; - close = go || cancel; - return; - } - Dialog::RemoveHidden => { - let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); - if ok { - redact_now = Some(dialog); + if widgets::pill_button(ui, tl!("Discard"), false).clicked() { + req.recover = Some(false); + req.close = true; } - close = ok || cancel; - return; - } - Dialog::Sanitize => { - let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); - if ok { - redact_now = Some(dialog); + } else if dialog == Dialog::NumberPages { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.apply_number = true; + req.close = true; } - close = ok || cancel; - return; - } - Dialog::FieldProps => { - let Some(d) = app.field_props.as_mut() else { - close = true; - return; - }; - let (apply, cancel) = crate::prepare::body(ui, d, &t); - field_props_now = apply; - close = apply || cancel; - return; - } - Dialog::BulkFieldProps => { - let Some(d) = app.bulk_field_props.as_mut() else { - close = true; - return; - }; - let (apply, cancel) = crate::bulk_fields::body(ui, d, &t); - bulk_field_props_now = apply; - close = cancel; - return; - } - Dialog::CommentProps => { - let (apply, cancel) = crate::comment_props::body(ui, app, &t); - props_now = apply; - close = apply || cancel; - return; - } - Dialog::Signature => { - let (apply, cancel, browse) = - crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo); - if browse { - app.pick_signature_image(); + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; } - if apply { - let d = std::mem::take(&mut app.signature_draft); - let tool = if d.initials { - app.initials = d.saved(); - crate::fill_sign::FillTool::Initials - } else { - app.signature = d.saved(); - crate::fill_sign::FillTool::Signature - }; - app.quick_tool = crate::QuickTool::Fill(tool); - app.toast = None; + } else if dialog == Dialog::Split { + if ui.add_enabled_ui(req.split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { + req.split_now = req.split_ready.clone(); + req.close = true; } - close = apply || cancel; - return; - } - Dialog::AltText => { - let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t); - alt_now = save; - close = save || cancel; - return; - } - Dialog::CreateStamp => { - let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t); - stamp_now = save; - close = save || cancel; - return; - } - Dialog::PdfA => { - close = crate::standards_ui::body(ui, app, &t); - return; - } - Dialog::ActionWizard => { - ui.set_width(620.0); - close = crate::actions_ui::body(ui, app, &t); - return; - } - Dialog::CompareFiles => { - let (go, cancel) = crate::compare_ui::body(ui, app, &t); - compare_now = go; - close = go || cancel; - return; - } - Dialog::JsConsole => { - ui.set_width(640.0); - close = crate::js_ui::console_body(ui, app, &t); - return; - } - Dialog::DocumentJs => { - ui.set_width(640.0); - close = crate::js_ui::document_js_body(ui, app, &t); - return; - } - Dialog::Preferences => { - // The settings outgrow a short window: scroll them rather than run off the screen. - // The OK row stays below the scroll, in reach however far the settings are scrolled. - let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); - // A scroll area starts from the room the dialog offers it, and a modal offers more - // each frame for the first few, so with OK outside the scroll the dialog would grow - // over those frames. Asking for the full height as the least it may scroll in - // (which only applies when the settings overflow it) opens the dialog at full size. - // A scrollbar of its own: the floating one lies over the right edge of the theme - // cards and other full-width rows (#3557). - ui.scope(|ui| { - ui.spacing_mut().scroll.floating = false; - egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); - }); - close = crate::js_ui::preferences_buttons(ui); - return; - } - Dialog::RecognizeText => { - let (go, cancel) = crate::ocr_ui::body(ui, app, &t); - ocr_now = go; - close = go || cancel; - return; - } - Dialog::AccessibilityOptions => { - let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); - a11y_now = start; - close = start || cancel; - return; - } - Dialog::Export(kind) => { - let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind); - export_now = apply; - close = apply || cancel; - return; - } - Dialog::Marks(kind) => { - ui.set_width(720.0); - let (apply, cancel) = crate::marks_ui::body(ui, app, &t, kind); - marks_now = apply; - close = apply || cancel; - return; - } - Dialog::PageBoxes => { - let (apply, cancel) = crate::pageboxes::body(ui, app, &t); - boxes_now = apply; - close = apply || cancel; - return; - } - Dialog::Protect => { - let (apply, cancel) = crate::protect::body(ui, app, &t); - protect_now = apply; - close = apply || cancel; - return; - } - Dialog::NumberPages => { - use pdfcraft_engine::LabelStyle as L; - ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - let Some((_, id)) = app.active_ids() else { return }; - let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); - let d = &mut app.number_draft; - d.to = d.to.clamp(1, n); - d.from = d.from.clamp(1, d.to); - egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Pages")); - ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); - }); - ui.end_row(); - ui.label(tl!("Style")); - let styles = [ - (L::Decimal, "1, 2, 3"), - (L::LowerRoman, "i, ii, iii"), - (L::UpperRoman, "I, II, III"), - (L::LowerAlpha, "a, b, c"), - (L::UpperAlpha, "A, B, C"), - (L::None, tl!("None (prefix only)")), - ]; - let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); - craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { - for (s, l) in styles { - ui.selectable_value(&mut d.style, s, l); - } - }); - ui.end_row(); - let l = ui.label(tl!("Prefix")); - ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id); - ui.end_row(); - ui.label(tl!("Start")); - ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); - ui.end_row(); - }); - d.to = d.to.max(d.from); - let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); - ui.add_space(8.0); - let preview = if d.from == d.to { - label(d.start) - } else { - format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) - }; - ui.label( - egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])) - .color(t.text_muted), - ); - number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); - } - Dialog::Recovery => { - ui.horizontal(|ui| { - ui.add(crate::icons::image("clock-3", 22.0, t.accent)); - ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); - }); - ui.add_space(6.0); - ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); - ui.add_space(8.0); - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { - for m in &app.recoverable { - ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); - let mins = now.saturating_sub(m.saved_at) / 60; - let when = match mins { - 0 => tl!("just now").to_string(), - 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), - _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), - }; - let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; - ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); - ui.end_row(); - } - }); - } - Dialog::Shortcuts => { - ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); - ui.add_space(8.0); - use crate::commands::{ - ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, - ZOOM_OUT, - }; - // Registered commands first (always in sync with the real bindings), then the - // keys the document view handles itself. - let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS - .iter() - .filter_map(|c| { - c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string())) - }) - .collect(); - let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s); - let pair = |a, b| format!("{} / {}", key(a), key(b)); - // Key names, translated where a catalog has them (not scanned as UI literals). - let named = |k: &str| tl!(k).to_string(); - let scroll = crate::i18n::fmt( - tl!("Zoom in / out (also pinch or {key}-scroll)"), - &[("key", crate::commands::command_modifier_label(ui.ctx()))], - ); - for (k, v) in [ - (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), - (key(COPY), tl!("Copy selected text").to_string()), - (named("Double-click"), tl!("Select a word").to_string()), - (crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / close find").to_string()), - (key(ACTUAL_SIZE), tl!("Actual size").to_string()), - (key(PAGE_LEVEL), tl!("Zoom to page level").to_string()), - (key(FIT_WIDTH), tl!("Fit to width").to_string()), - (pair(ZOOM_IN, ZOOM_OUT), scroll), - (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), - (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), - (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), - (named("V"), tl!("Select (V)").to_string()), - (named("H / Space (hold)"), tl!("Hand (H)").to_string()), - (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), - (key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()), - ] { - rows.push((k, v)); + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; } - egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { - egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { - for (k, v) in &rows { - ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5))); - ui.label(v); - ui.end_row(); - } - }); - }); - } - Dialog::About => { - // Tabs About · Contributors · Models (craftrules standards/contributors.md). - let tab_id = egui::Id::unique("about_tab"); - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal_wrapped(|ui| { - for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { - let i = i as u8; - if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { - tab = i; - } - } - }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.horizontal(|ui| { - widgets::artcraft_mark(ui, 40.0); - ui.vertical(|ui| { - ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); - ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); - }); - }); - ui.add_space(6.0); - ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); - ui.label( - egui::RichText::new( - "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", - ) - .color(t.text_muted) - .small(), - ); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); - widgets::artcraft_logo(ui, 16.0); - }); - ui.add_space(6.0); - if let Some(cmd) = widgets::community_links(ui) { - link_command = Some(cmd); - } - } + } else if changed { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.apply = true; + req.close = true; } - } - } - ui.add_space(16.0); - let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); - // One row high: on its own, a right-to-left layout centres the buttons in all the height - // left, which after a taller Properties tab is the room that tab left behind. - ui.horizontal(|ui| { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if dialog == Dialog::Recovery { - if widgets::pill_button(ui, tl!("Recover"), true).clicked() { - recover = Some(true); - close = true; - } - if widgets::pill_button(ui, tl!("Discard"), false).clicked() { - recover = Some(false); - close = true; - } - } else if dialog == Dialog::NumberPages { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply_number = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if dialog == Dialog::Split { - if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { - split_now = split_ready.clone(); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if changed { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { - close = true; + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; } - }) - }); + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { + req.close = true; + } + }) }); - if let Some(yes) = recover { +} + +/// Acts on what the dialog asked for, once the modal is drawn. +fn finish_dialog(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { + finish_requests(app, dialog, modal_closed, req); + finish_actions(app, req); + finish_redactions_and_fields(app, req); + finish_edits(app, dialog, req); + finish_close(app, dialog, modal_closed, req); +} + +/// Runs the one-shot requests the dialog made. +fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { + if let Some(yes) = req.recover { let keys: Vec = app.recoverable.iter().map(|m| m.key.clone()).collect(); if yes { app.recover(&keys); @@ -1426,7 +1593,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } } // The pages to replace belong to the document the dialog was opened on (#167). - if replace_now + if req.replace_now && let Some(d) = app.replace_draft.take() && app.still_pick_target(d.target) { @@ -1440,52 +1607,60 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); } // A print or save that fails keeps the dialog open, with the reason in a notice. - if print_go && !app.print_now() { - close = false; + if req.print_go && !app.print_now() { + req.close = false; } - if revert_now { + if req.revert_now { app.revert_active(); } #[cfg(not(target_arch = "wasm32"))] { - if overwrite_now { + if req.overwrite_now { app.overwrite_changed_file(); } - if overwrite_elsewhere { + if req.overwrite_elsewhere { app.save_changed_file_elsewhere(); } } #[cfg(target_arch = "wasm32")] - let _ = (overwrite_now, overwrite_elsewhere); + let _ = (req.overwrite_now, req.overwrite_elsewhere); // Cancelled or dismissed: the save is dropped. #[cfg(not(target_arch = "wasm32"))] - if dialog == Dialog::OverwriteChanged && (modal.should_close() || close) { + if dialog == Dialog::OverwriteChanged && (modal_closed || req.close) { app.pending_overwrite = None; } - if summarize_now { +} + +/// Runs the document actions the dialog asked for. +fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { + if req.summarize_now { app.summarize_comments(); } - if optimize_now { + if req.optimize_now { app.optimize_with_draft(); } - if let Some(e) = duplicate_now { + if let Some(e) = req.duplicate_now.take() { app.duplicate_draft = None; app.apply_edit(e); } - if extract_now { + if req.extract_now { app.extract_selection(); } - if let Some(e) = link_now { + if let Some(e) = req.link_now.take() { app.apply_edit(e); app.link_draft = None; } - if rotate_now { + if req.rotate_now { app.rotate_with_draft(); } - if import_words { + if req.import_words { app.pick_files(crate::files::FilePurpose::RedactWords, false); } - match redact_now { +} + +/// Redaction, hidden-data and field-properties requests. +fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { + match req.redact_now.take() { Some(Dialog::RedactPages) => app.redact_pages(), Some(Dialog::RedactSearch) => { let n = app.redact_search(); @@ -1517,10 +1692,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } _ => {} } - if bulk_field_props_now { - close = app.apply_bulk_field_props(); + if req.bulk_field_props_now { + req.close = app.apply_bulk_field_props(); } - if field_props_now + if req.field_props_now && let Some(d) = app.field_props.take() && let Some(props) = d.props() { @@ -1549,7 +1724,13 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.views[i].prepare.selected = Some((name, d.widget)); } } - if props_now && let Some(d) = app.comment_props.take() { +} + +/// Edits built from the dialog's drafts. +fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { + if req.props_now + && let Some(d) = app.comment_props.take() + { let mut edits = crate::comment_props::edits(&d); match edits.len() { 0 => {} @@ -1565,68 +1746,82 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.make_props_default(d.page, d.index, &d.edited); } } - if export_now && let Dialog::Export(kind) = dialog { + if req.export_now + && let Dialog::Export(kind) = dialog + { app.start_export(kind); } - if marks_now && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) { + if req.marks_now + && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) + { let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); let edit = crate::marks_ui::edit(&app.marks_draft, kind, count); app.apply_edit(edit); } - if boxes_now && let Some((i, id)) = app.active_ids() { + if req.boxes_now + && let Some((i, id)) = app.active_ids() + { let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); let edit = app.boxes_draft.edit(app.views[i].current, count); app.apply_edit(edit); app.boxes_draft.seeded = None; } - if protect_now && app.apply_edit(app.protect_draft.edit()) { + if req.protect_now && app.apply_edit(app.protect_draft.edit()) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } - if apply_number && let Some(edit) = number_now { + if req.apply_number + && let Some(edit) = req.number_now.take() + { app.apply_edit(edit); } - if let Some(by) = split_now { + if let Some(by) = req.split_now.take() { app.split_active(&by); } - if apply && let Some(edits) = draft_changes(app) { + if req.apply + && let Some(edits) = draft_changes(app) + { app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }); } +} + +/// Closes (or switches) the dialog, then runs what closing it asked for. +fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { // Protect / Remove security replace the Properties dialog. - let replaces = link_command.is_some_and(|c| c.starts_with("protect.")) || open_revision.is_some(); - if modal.should_close() || close || replaces { + let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some(); + if modal_closed || req.close || replaces { app.dialog = None; app.props_draft = None; app.view_draft = None; app.custom_draft = None; app.bulk_field_props = None; } else { - app.dialog = Some(next); + app.dialog = Some(req.next.unwrap_or(dialog)); } - if let Some(cmd) = link_command { + if let Some(cmd) = req.link_command { app.execute(cmd); } - if let Some(n) = open_revision { + if let Some(n) = req.open_revision { app.open_revision(n); } - if alt_now { + if req.alt_now { app.save_alt_text(); } - if stamp_now { + if req.stamp_now { app.save_custom_stamp(); } - if images_now { + if req.images_now { app.finish_image_import(); } else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) { app.image_import = None; } - if ocr_now { + if req.ocr_now { app.start_ocr(); } - if compare_now { + if req.compare_now { app.run_compare(); } - if a11y_now { + if req.a11y_now { if let Some((_, id)) = app.active_ids() { app.a11y_skipped.retain(|(doc, _)| *doc != id); } diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 557ddd3b8f..fff7eea9a9 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align, CloseRequest, Layout, PdfCraftApp, Tokens, theme, widgets}; /// The buttons of the "Save changes?" prompt, in the order they sit on screen, left to right. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] From 2d27306a8d3a655e0e89c444a19199d8722d8872 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 22/38] Refactor ui support for strict Clippy checks --- crates/ui-egui/src/app/input.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 25 +++ crates/ui-egui/src/app/window.rs | 2 +- crates/ui-egui/src/control/host.rs | 2 +- crates/ui-egui/src/control/pending.rs | 2 +- crates/ui-egui/src/control/plugin.rs | 2 +- crates/ui-egui/src/control/requests.rs | 2 +- crates/ui-egui/src/control/screenshot.rs | 2 +- crates/ui-egui/src/control/transport.rs | 18 +- crates/ui-egui/src/editing.rs | 157 +++++++++--------- crates/ui-egui/src/editing/close.rs | 2 +- crates/ui-egui/src/editing/save.rs | 2 +- crates/ui-egui/src/panels/bookmarks.rs | 2 +- crates/ui-egui/src/panels/edit.rs | 5 +- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/panels/tools.rs | 5 +- .../ui-egui/src/platform/open_worker/tests.rs | 142 +++++++++++----- crates/ui-egui/tests/it/signing.rs | 89 +++++++--- 19 files changed, 297 insertions(+), 170 deletions(-) diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index ad554c1dfd..953eb4a6eb 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GridDrop, GridFile, OpenRequest, PdfCraftApp, open_worker}; /// Read a file dropped on a page grid (see [`GridFile`]). fn read_grid_file((name, source): GridFile) -> Result<(String, Vec), (String, String)> { diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index ac8ac644ee..f64ba6d892 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -178,6 +178,15 @@ impl PdfCraftApp { let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); self.recent = r; } + self.restore_layout_prefs(&v); + self.restore_behavior_prefs(&v); + self.restore_view_prefs(&v); + self.restore_comment_prefs(&v); + self.restore_signature_prefs(&v); + } + + /// The panels, combine view, zoom and recent files. + fn restore_layout_prefs(&mut self, v: &serde_json::Value) { if let Some(on) = v["reopen_last_session"].as_bool() { self.reopen_last_session = on; } @@ -211,6 +220,10 @@ impl PdfCraftApp { if let Some((layout, continuous)) = v["default_layout"].as_str().and_then(canvas::parse_display_token) { (self.view_defaults.layout, self.view_defaults.continuous) = (layout, continuous); } + } + + /// Switches for reopening, window chrome and file watching. + fn restore_behavior_prefs(&mut self, v: &serde_json::Value) { if let Some(show) = v["default_page_gaps"].as_bool() { self.view_defaults.show_page_gaps = show; } @@ -252,6 +265,10 @@ impl PdfCraftApp { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; } + } + + /// Display defaults, units, language and sign-in details. + fn restore_view_prefs(&mut self, v: &serde_json::Value) { // Settings are untrusted: an unreadable value (a colour that isn't three bytes, say) // fails to deserialize and keeps Off. if let Ok(colors) = serde_json::from_value::(v["reading_colors"].clone()) { @@ -277,6 +294,10 @@ impl PdfCraftApp { if let Some(pinned) = v["comment_tool_pinned"].as_bool() { self.comment_prefs.pinned = pinned; } + } + + /// The author and per-tool comment styles. + fn restore_comment_prefs(&mut self, v: &serde_json::Value) { // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -305,6 +326,10 @@ impl PdfCraftApp { } } } + } + + /// Saved signatures, stamps, actions and trusted certificates. + fn restore_signature_prefs(&mut self, v: &serde_json::Value) { if let Ok(s) = serde_json::from_value::>>(v["signature"].clone()) && s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite()))) { diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index c950b54a55..e7035b5000 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{PdfCraftApp, QuickTool}; impl PdfCraftApp { /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 62693cda2e..1166c0e7ec 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Host, Reply, Value, json}; impl Host for crate::PdfCraftApp { /// Cancel any in-progress pointer gesture in every open view. diff --git a/crates/ui-egui/src/control/pending.rs b/crates/ui-egui/src/control/pending.rs index 9e63849cf0..d87ae037a9 100644 --- a/crates/ui-egui/src/control/pending.rs +++ b/crates/ui-egui/src/control/pending.rs @@ -1,5 +1,5 @@ use super::screenshot::{Geometry, MAX_IMAGE_BYTES, retried_reservation, screenshot_png}; -use super::*; +use super::{Arc, Control, Host, Pending, Reply, Sender, Value, not_drawn, timeout}; /// A screenshot pending in the `finish_pending` pass, with ownership of its reply. struct PendingCapture { diff --git a/crates/ui-egui/src/control/plugin.rs b/crates/ui-egui/src/control/plugin.rs index be0dff826e..a6def94c4f 100644 --- a/crates/ui-egui/src/control/plugin.rs +++ b/crates/ui-egui/src/control/plugin.rs @@ -1,5 +1,5 @@ use super::requests::Handled; -use super::*; +use super::{Arc, Control, Mutex, RequestBatches, Shared, Value, timeout}; /// Track only presses that reached egui and have not yet been released. A pointer release is /// retained as an ownership marker, never as a timeout event. diff --git a/crates/ui-egui/src/control/requests.rs b/crates/ui-egui/src/control/requests.rs index 1e2a24b979..0c926ab2ab 100644 --- a/crates/ui-egui/src/control/requests.rs +++ b/crates/ui-egui/src/control/requests.rs @@ -1,5 +1,5 @@ use super::screenshot::Geometry; -use super::*; +use super::{Action, Control, Host, NodeId, Reply, Value, accesskit, json}; impl Control { /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with diff --git a/crates/ui-egui/src/control/screenshot.rs b/crates/ui-egui/src/control/screenshot.rs index 9eb3e50bbd..c6fbb0ed83 100644 --- a/crates/ui-egui/src/control/screenshot.rs +++ b/crates/ui-egui/src/control/screenshot.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Control, Host, Pending, Reply, Sender, json}; /// Screenshot admission limits: bounded requests, per-image bytes and aggregate retained bytes. const MAX_PENDING_SCREENSHOTS: usize = 4; diff --git a/crates/ui-egui/src/control/transport.rs b/crates/ui-egui/src/control/transport.rs index 81e30ff108..23a5181de7 100644 --- a/crates/ui-egui/src/control/transport.rs +++ b/crates/ui-egui/src/control/transport.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ControlClient, Value, json}; // ---- transport (native only) ----------------------------------------------------------------- @@ -331,7 +331,7 @@ mod transport_tests { #[test] fn malformed_input_before_auth_closes_the_connection() { use std::io::{BufRead, BufReader, ErrorKind, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); @@ -375,7 +375,7 @@ mod transport_tests { #[test] fn an_overlong_line_before_auth_is_refused() { use std::io::{BufRead, BufReader, ErrorKind, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); @@ -429,7 +429,7 @@ mod transport_tests { #[test] fn an_unauthenticated_connection_is_closed_at_the_deadline() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); // Idle. let started = std::time::Instant::now(); @@ -468,7 +468,7 @@ mod transport_tests { #[test] fn a_client_refused_with_input_unread_gets_the_reason() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); let s = connect(ep.port); let mut w = s.try_clone().unwrap(); @@ -488,7 +488,7 @@ mod transport_tests { use std::io::{BufReader, Write}; // A linger far longer than the checks below take, even on a loaded machine. let limits = super::Limits { linger: std::time::Duration::from_secs(5), ..TEST_LIMITS }; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, limits).unwrap(); let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); // Refused with most of its line unread, and left open: it lingers for `linger`. @@ -517,7 +517,7 @@ mod transport_tests { use std::io::{BufReader, Write}; // A linger far longer than the checks below take, even on a loaded machine. let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, limits).unwrap(); let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; // The first refused connection takes the only lingering place and keeps it. @@ -553,7 +553,7 @@ mod transport_tests { #[test] fn only_so_many_connections_may_wait_to_authenticate() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); let first = connect(ep.port); @@ -593,7 +593,7 @@ mod transport_tests { #[test] fn long_lines_after_auth_are_still_read() { use std::io::{BufRead, BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ba2d3cbf96..4d09b5cf4b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -27,6 +27,79 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } +/// Keeps the pages (or comment) the user acted on selected, where they now are. +fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_render::DocInfo, quick_tool: crate::QuickTool) { + match edit { + Edit::MovePages { pages, to } => { + let n = pages.iter().collect::>().len(); + let to = to.min(info.pages.len() - n); + view.select_pages(&(to..to + n).collect::>()); + } + Edit::DuplicatePages { pages, at: Some(at) } => { + let n = pages.iter().collect::>().len(); + let at = at.min(info.pages.len().saturating_sub(n)); + view.select_pages(&(at..at + n).collect::>()); + } + Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), + Edit::DeletePages { .. } => view.select_pages(&[]), + // A stroke drawn with the pen stays unselected, so the selection box and + // author popup don't sit over the next stroke (#429). + Edit::AddAnnotation(a) + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) + && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + { + view.comments.selected = None; + } + Edit::AddAnnotation(a) => { + // Select the new comment (appended last among the page's comments). + let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); + view.comments.selected = newest.map(|n| (a.page, n)); + view.comments.reveal = true; + // A new highlight opens its note for typing straight away, as in Acrobat, + // unless a note typed into another card is still unsaved. + if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) + && a.contents.is_empty() + && view.comments.editing.is_none() + { + view.comments.open_editor(a.page, n, String::new()); + } + } + Edit::DeleteAnnotation { .. } => view.comments.selected = None, + _ => {} + } +} + +/// The pages whose widgets differ between two form states, and the (field, page) pairs whose +/// value changed and so must not be drawn from the baked page. +fn form_edit_pages( + before: &[pdfcraft_engine::FormField], + after: &[pdfcraft_engine::FormField], +) -> (std::collections::BTreeSet, Vec<(String, usize)>) { + let mut changed_pages = std::collections::BTreeSet::new(); + let mut unbaked = Vec::new(); + for f in after { + let pages = |field: &pdfcraft_engine::FormField| field.widgets.iter().filter_map(|w| w.page).collect::>(); + match before.iter().find(|old| old.name == f.name) { + Some(old) if old == f => {} + Some(old) => { + changed_pages.extend(pages(f)); + changed_pages.extend(pages(old)); + if old.value != f.value { + unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p))); + } + } + None => { + changed_pages.extend(pages(f)); + unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p))); + } + } + } + for old in before.iter().filter(|old| !after.iter().any(|f| f.name == old.name)) { + changed_pages.extend(old.widgets.iter().filter_map(|w| w.page)); + } + (changed_pages, unbaked) +} + impl PdfCraftApp { /// Apply an edit to the active document. Returns `true` on success; failures are shown. pub fn apply_edit(&mut self, edit: Edit) -> bool { @@ -68,49 +141,8 @@ impl PdfCraftApp { && doc.xfa.is_none() && is_form_edit(&edit) { - let mut changed_pages = std::collections::BTreeSet::new(); - for f in doc.form.iter() { - match form_before.iter().find(|old| old.name == f.name) { - Some(old) => { - if old != f { - for w in &f.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - for w in &old.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - if old.value != f.value { - for w in &f.widgets { - if let Some(p) = w.page { - view.forms.unbaked.insert((f.name.clone(), p)); - } - } - } - } - } - None => { - for w in &f.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - view.forms.unbaked.insert((f.name.clone(), p)); - } - } - } - } - } - for old in form_before.iter() { - if !doc.form.iter().any(|f| f.name == old.name) { - for w in &old.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - } - } + let (changed_pages, unbaked) = form_edit_pages(&form_before, &doc.form); + view.forms.unbaked.extend(unbaked); for page in changed_pages { view.page_changed(page); } @@ -121,44 +153,7 @@ impl PdfCraftApp { view.comments.comment_added(); } // Keep the pages the user acted on selected, where they now are. - match edit { - Edit::MovePages { pages, to } => { - let n = pages.iter().collect::>().len(); - let to = to.min(info.pages.len() - n); - view.select_pages(&(to..to + n).collect::>()); - } - Edit::DuplicatePages { pages, at: Some(at) } => { - let n = pages.iter().collect::>().len(); - let at = at.min(info.pages.len().saturating_sub(n)); - view.select_pages(&(at..at + n).collect::>()); - } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), - Edit::DeletePages { .. } => view.select_pages(&[]), - // A stroke drawn with the pen stays unselected, so the selection box and - // author popup don't sit over the next stroke (#429). - Edit::AddAnnotation(a) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && self.quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => - { - view.comments.selected = None; - } - Edit::AddAnnotation(a) => { - // Select the new comment (appended last among the page's comments). - let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); - view.comments.selected = newest.map(|n| (a.page, n)); - view.comments.reveal = true; - // A new highlight opens its note for typing straight away, as in Acrobat, - // unless a note typed into another card is still unsaved. - if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) - && a.contents.is_empty() - && view.comments.editing.is_none() - { - view.comments.open_editor(a.page, n, String::new()); - } - } - Edit::DeleteAnnotation { .. } => view.comments.selected = None, - _ => {} - } + select_after_edit(view, edit, info, self.quick_tool); if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 5317c0ccd0..55d884cd9a 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -1,5 +1,5 @@ +use super::PdfCraftApp; use super::save::SaveTarget; -use super::*; /// What the user was doing when we asked whether to save. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 59eba1fbc6..f685247c9d 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{PdfCraftApp, StagingName, staging_suffixes}; /// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file /// on disk is another version than the document came from (#2166). diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index 3753c84940..ec61bf2fe2 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocInfo, Nav, OutlineItem, Rect, Sense, Tokens, icons, pos2, theme, vec2}; /// What the user asked to do with a bookmark (applied after the panel is drawn). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e10d0d01ee..fa4cc2536a 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1,5 +1,8 @@ use super::tools::row_label; -use super::*; +use super::{ + Align2, Availability, Color32, CornerRadius, LeftPanel, PdfCraftApp, Rect, Sense, Stroke, Tokens, ToolGroup, ToolItem, catalog, icons, theme, + vec2, widgets, +}; /// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does. const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"]; diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index e7afe45e85..1157b64a49 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align, Align2, Color32, CornerRadius, DocInfo, Layout, Nav, Rect, Sense, Stroke, Tokens, icons, pos2, theme, vec2}; /// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel, /// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 4f6c150c46..7b6502b9c0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1,6 +1,8 @@ use super::bookmarks::{BmAction, OutlineCtx, outline_item, outline_matches}; use super::pages::{page_tools, pages, thumbnail_size}; -use super::*; +use super::{ + Align, Align2, Color32, DocInfo, FieldKind, Layout, Nav, PdfCraftApp, Rect, RightPanel, Sense, Stroke, Tokens, human_size, icons, theme, vec2, +}; /// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 3a1cccd713..4ff76a7c43 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -1,5 +1,8 @@ use super::edit::{actions_menu, edit_panel, stamp_palette}; -use super::*; +use super::{ + Align2, Availability, Color32, CornerRadius, LeftPanel, PdfCraftApp, Rect, Sense, Stroke, TOOL_GROUPS, Tokens, ToolGroup, catalog, icons, theme, + vec2, widgets, +}; const COLLAPSED_TOOLS: usize = 14; diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs index af847b9a2e..fe2a0ae410 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -556,55 +556,117 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() { held.release.send(()).expect("release ordinary B"); held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B reply is also ready"); assert_eq!(harness.state().pending_opens.len(), 2); + let files = PickerFiles { + picked: &picked, + other: &other, + insert: &insert, + original_a: &original_a, + original_b: &original_b, + original_c: &original_c, + }; + let a_id = assert_picker_keeps_a_visible(&mut harness, &files); + assert_picker_completion_releases_b(&mut harness, a_id, cancel, &files); + } +} - // Poll admission separately from consuming the picker callback: this reproduces - // the same ordering as a UI frame, with both worker replies already available. - harness.state_mut().poll_pending_opens(); - assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker"); - assert_eq!(harness.state().active, Some(0)); - assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none()); - let a_id = harness.state().views[0].id; - let doc = harness.state().session.get(a_id).expect("picked A before picker completion"); - assert_eq!(doc.path.as_deref(), Some(picked.0.to_string_lossy().as_ref())); +struct PickerFiles<'a> { + picked: &'a ToolOpenFile, + other: &'a ToolOpenFile, + insert: &'a ToolOpenFile, + original_a: &'a [u8], + original_b: &'a [u8], + original_c: &'a [u8], +} + +fn assert_picker_keeps_a_visible(harness: &mut Harness<'static, PdfCraftApp>, files: &PickerFiles<'_>) -> pdfcraft_engine::DocId { + // Poll admission separately from consuming the picker callback: this reproduces + // the same ordering as a UI frame, with both worker replies already available. + harness.state_mut().poll_pending_opens(); + assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker"); + assert_eq!(harness.state().active, Some(0)); + assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none()); + let a_id = harness.state().views[0].id; + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "picked A before picker completion"); + if let Some(doc) = doc { + assert_eq!(doc.path.as_deref(), Some(files.picked.0.to_string_lossy().as_ref())); assert_eq!(doc.info.pages.len(), 2); assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none()); - assert_eq!(harness.state().pending_opens.len(), 1); - assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(other.0.to_string_lossy().as_ref())); - harness.state_mut().poll_pending_opens(); - assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed"); - assert_eq!(harness.state().active, Some(0)); - - // Consume the actual native-picker callback (or its empty-path cancellation). - harness.state_mut().process_picked(); - let doc = harness.state().session.get(a_id).expect("A after picker completion"); - assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 }); + } + assert_eq!(harness.state().pending_opens.len(), 1); + assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(files.other.0.to_string_lossy().as_ref())); + harness.state_mut().poll_pending_opens(); + assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed"); + assert_eq!(harness.state().active, Some(0)); + a_id +} + +fn assert_picker_completion_releases_b( + harness: &mut Harness<'static, PdfCraftApp>, + a_id: pdfcraft_engine::DocId, + cancel: bool, + files: &PickerFiles<'_>, +) { + // Consume the actual native-picker callback (or its empty-path cancellation). + harness.state_mut().process_picked(); + assert_document_a_after_picker(harness, a_id, cancel); + assert_eq!(harness.state().views.len(), 1); + assert_eq!(harness.state().pending_opens.len(), 1); + assert_eq!(harness.state().active, Some(0)); + + harness.step(); + assert!(harness.state().pending_opens.is_empty()); + assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once"); + let active = harness.state().active; + assert!(active.is_some(), "B activates after picker completion"); + if let Some(active) = active { + assert_document_b_after_picker(harness, active, files); + } + harness.run_steps(2); + assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none()); + assert_eq!(harness.state().views.len(), 2); + assert_eq!(harness.state().active, active); + assert_document_a_after_picker(harness, a_id, cancel); + assert_a_saved_source(harness, a_id, files.original_a); + assert_picker_files_unchanged(files); +} + +fn assert_document_a_after_picker(harness: &Harness<'static, PdfCraftApp>, a_id: pdfcraft_engine::DocId, cancel: bool) { + let expected_pages = if cancel { 2 } else { 3 }; + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "A after picker completion"); + if let Some(doc) = doc { + assert_eq!(doc.info.pages.len(), expected_pages); assert_eq!(doc.dirty, !cancel); assert_eq!(doc.can_undo().is_some(), !cancel); assert!(doc.can_redo().is_none()); - assert_eq!(harness.state().views.len(), 1); - assert_eq!(harness.state().pending_opens.len(), 1); - assert_eq!(harness.state().active, Some(0)); + } +} - harness.step(); - assert!(harness.state().pending_opens.is_empty()); - assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once"); - let active = harness.state().active.expect("B activates after picker completion"); - let doc = harness.state().session.get(harness.state().views[active].id).expect("ordinary B document"); - assert_eq!(doc.path.as_deref(), Some(other.0.to_string_lossy().as_ref())); +fn assert_document_b_after_picker(harness: &Harness<'static, PdfCraftApp>, active: usize, files: &PickerFiles<'_>) { + let doc = harness.state().session.get(harness.state().views[active].id); + assert!(doc.is_some(), "ordinary B document"); + if let Some(doc) = doc { + assert_eq!(doc.path.as_deref(), Some(files.other.0.to_string_lossy().as_ref())); assert_eq!(doc.info.pages.len(), 1); assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none()); - harness.run_steps(2); - assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none()); - assert_eq!(harness.state().views.len(), 2); - assert_eq!(harness.state().active, Some(active)); - let doc = harness.state().session.get(a_id).expect("A retains exactly one insertion or cancellation"); - assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 }); - assert_eq!(doc.dirty, !cancel); - assert_eq!(doc.can_undo().is_some(), !cancel); - assert!(doc.can_redo().is_none()); - assert_eq!(doc.saved_bytes().as_slice(), original_a.as_slice(), "insertion preserves A's saved source"); - for (file, original) in [(&picked, &original_a), (&other, &original_b), (&insert, &original_c)] { - assert_eq!(std::fs::read(&file.0).expect("read unchanged original picker fixture"), *original); + } +} + +fn assert_a_saved_source(harness: &Harness<'static, PdfCraftApp>, a_id: pdfcraft_engine::DocId, original: &[u8]) { + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "A retains exactly one insertion or cancellation"); + if let Some(doc) = doc { + assert_eq!(doc.saved_bytes().as_slice(), original, "insertion preserves A's saved source"); + } +} + +fn assert_picker_files_unchanged(files: &PickerFiles<'_>) { + for (file, original) in [(files.picked, files.original_a), (files.other, files.original_b), (files.insert, files.original_c)] { + let bytes = std::fs::read(&file.0); + assert!(bytes.is_ok(), "read unchanged original picker fixture: {bytes:?}"); + if let Ok(bytes) = bytes { + assert_eq!(bytes, original); } } } diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 62d97d4e58..0d8bc72de4 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -97,23 +97,32 @@ fn ctrl_alt_q_types_an_email_without_quitting() { fn drawing_a_signature_creating_an_id_signing_and_trusting() { let dir = dir(); let mut h = harness(dir.clone()); + create_signing_id(&mut h); + sign_with_created_id(&mut h, &dir); + trust_signer_and_inspect_certificate(&mut h); + verify_signed_snapshot_reuse(&mut h); + verify_dirty_snapshot_close(&mut h); + assert_trust_and_id_persist(&h); +} + +fn create_signing_id(h: &mut Harness<'static, PdfCraftApp>) { assert!(h.state_mut().execute("sign.digital")); assert_eq!(h.state().quick_tool, QuickTool::SignArea { certify: false }); - let (a, b) = (at(&h, 40.0, 90.0), at(&h, 220.0, 40.0)); - drag(&mut h, a, b); + let (a, b) = (at(h, 40.0, 90.0), at(h, 220.0, 40.0)); + drag(h, a, b); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert_eq!(h.state().sign_draft.as_ref().map(|d| d.step), Some(SignStep::Configure), "no IDs yet: configure one"); // Create a new digital ID (P-256 keeps the test fast). h.get_by_label("Configure a Digital ID for Signing"); - field(&h, "Name").click(); + field(h, "Name").click(); h.run_steps(1); - field(&h, "Name").type_text("Grace Hopper"); + field(h, "Name").type_text("Grace Hopper"); h.state_mut().sign_draft.as_mut().unwrap().new_id.key = 3; for (label, text) in [("Password", "secret12"), ("Confirm Password", "secret12")] { - field(&h, label).click(); + field(h, label).click(); h.run_steps(1); - field(&h, label).type_text(text); + field(h, label).type_text(text); h.run_steps(1); } h.get_by_label("Save").click(); @@ -124,31 +133,41 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("Continue").click(); h.run_steps(2); h.get_by_label("Sign as \"Grace Hopper\""); +} +fn sign_with_created_id(h: &mut Harness<'static, PdfCraftApp>, dir: &std::path::Path) { // A wrong password says so. - field(&h, "Digital ID password").click(); + field(h, "Digital ID password").click(); h.run_steps(1); - field(&h, "Digital ID password").type_text("nope"); - support::outside_menus(&h, "Sign").click(); + field(h, "Digital ID password").type_text("nope"); + 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(); + field(h, "Reason").click(); h.run_steps(1); - field(&h, "Reason").type_text("I approve"); - support::outside_menus(&h, "Sign").click(); + field(h, "Reason").type_text("I approve"); + support::outside_menus(h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.dialog, None); assert!(dir.join("contract_signed.pdf").exists()); - let doc = s.session.get(s.views[0].id).unwrap(); - assert_eq!(doc.name, "contract_signed.pdf"); - let sig = doc.signatures.iter().find(|x| x.signed).expect("signed"); - assert_eq!((sig.status, sig.reason.as_deref(), sig.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + let doc = s.session.get(s.views[0].id); + assert!(doc.is_some(), "signed document remains open"); + if let Some(doc) = doc { + assert_eq!(doc.name, "contract_signed.pdf"); + let signature = doc.signatures.iter().find(|signature| signature.signed); + assert!(signature.is_some(), "the saved document contains a signature"); + if let Some(signature) = signature { + assert_eq!((signature.status, signature.reason.as_deref(), signature.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + } + } assert_eq!(s.right, Some(RightPanel::Signatures)); h.get_by_label("At least one signature has problems."); +} +fn trust_signer_and_inspect_certificate(h: &mut Harness<'static, PdfCraftApp>) { // Signatures panel: expand, trust the signer → valid. h.get_by_label_contains("Signed by Grace Hopper").click(); h.run_steps(2); @@ -160,9 +179,13 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.run_steps(3); h.get_by_label("Signed and all signatures are valid."); let s = h.state(); - assert_eq!(s.session.get(s.views[0].id).unwrap().signatures[0].status, SignatureStatus::Valid); + let doc = s.session.get(s.views[0].id); + assert!(doc.is_some(), "the original signed document remains open"); + if let Some(doc) = doc { + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(!doc.dirty, "trust alone is not an edit"); + } assert!(s.views[0].find.as_ref().is_some_and(|f| f.matches.is_empty()), "trust invalidates the view without zooming"); - assert!(!s.session.get(s.views[0].id).unwrap().dirty, "trust alone is not an edit"); // Show certificate: the Certificate Viewer. h.get_by_label("Show certificate…").click(); h.run_steps(3); @@ -181,13 +204,19 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("OK").click(); h.run_steps(2); assert_eq!(h.state().dialog, None); +} + +fn verify_signed_snapshot_reuse(h: &mut Harness<'static, PdfCraftApp>) { // View signed version opens the bytes the signature covers as a new document. h.get_by_label("View signed version").click(); h.run_steps(3); let s = h.state(); assert_eq!(s.views.len(), 2); - let v = s.session.get(s.views[1].id).unwrap(); - assert!(v.name.contains("signed version") && v.signatures.iter().any(|x| x.signed)); + let signed_version = s.session.get(s.views[1].id); + assert!(signed_version.is_some(), "the signed version opens in its own tab"); + if let Some(version) = signed_version { + assert!(version.name.contains("signed version") && version.signatures.iter().any(|signature| signature.signed)); + } assert!(s.views[0].snapshot.is_none()); assert!(s.views[1].snapshot.as_ref().is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::SignedVersion)); // A repeat click reuses the snapshot tab instead of opening another identical one. @@ -219,15 +248,20 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { let Some(up) = node.parent() else { break }; node = up; } - let close = close.expect("the notice bar holds its own Close"); + assert!(close.is_some(), "the notice bar holds its own Close"); // The bar's Close sits in the bar's row and the tab chrome's sits above it. Without // this the walk could reach the chrome's button, which closes the same tab, and the // test would pass with the bar's own button never pressed. - assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); - close.click(); - h.run_steps(2); - assert_eq!(h.state().views.len(), 1); - assert!(h.state().close_request.is_none()); + if let Some(close) = close { + assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); + close.click(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().close_request.is_none()); + } +} + +fn verify_dirty_snapshot_close(h: &mut Harness<'static, PdfCraftApp>) { // Closing a dirty snapshot asks about saving instead of closing at once. h.get_by_label("View signed version").click(); h.run_steps(3); @@ -240,6 +274,9 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("Don't save").click(); h.run_steps(2); assert_eq!(h.state().views.len(), 1); +} + +fn assert_trust_and_id_persist(h: &Harness<'static, PdfCraftApp>) { // Trusted certificates and the ID list persist. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); From 21cae663d155442b76b4c3f16c9028a7ea215104 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 23/38] Refactor ui tools for strict Clippy checks --- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/comments/geometry.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 516 ++++++++++-------- crates/ui-egui/src/tools/comments/menu.rs | 5 +- crates/ui-egui/src/tools/comments/paint.rs | 5 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- crates/ui-egui/src/tools/edit_text_ui.rs | 2 +- .../src/tools/edit_text_ui/background.rs | 2 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 338 +++++++----- .../ui-egui/src/tools/edit_text_ui/layout.rs | 2 +- crates/ui-egui/src/tools/fill_sign/scan.rs | 5 +- crates/ui-egui/src/tools/forms_ui.rs | 172 +++--- crates/ui-egui/src/tools/measure_ui.rs | 67 ++- crates/ui-egui/src/tools/prepare/draft.rs | 2 +- .../ui-egui/src/tools/prepare/properties.rs | 2 +- crates/ui-egui/src/tools/prepare/tabs.rs | 2 +- crates/ui-egui/src/tools/sign_ui/dialog.rs | 5 +- crates/ui-egui/src/tools/sign_ui/panel.rs | 2 +- 18 files changed, 634 insertions(+), 502 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 8c83c3ba15..fa2d7f9b1a 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -1,7 +1,10 @@ use super::geometry::{drawn_text_box_rect, page_rotation, text_box_rect}; use super::paint::{edited_in_view, new_comment}; use super::state::{ComposerKind, PageCx}; -use super::*; +use super::{ + Color32, CommentPrefs, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NoteIcon, PagePointer, QuickTool, Shape, Tokens, pos2, + theme, +}; /// The floating composer card (new note, new text box, edit). Returns the edit to apply. pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 75d56c2258..051cf09dba 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -1,5 +1,5 @@ use super::state::PageCx; -use super::*; +use super::{Annotation, DocInfo, PageInfo, PageText, Pos2, QuickTool, Rect, pos2, vec2}; pub(super) fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index d8cc33400b..ef0341b716 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -4,7 +4,7 @@ use super::geometry::{ }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::*; +use super::{Annotation, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; /// Comment input on one page, before text selection runs. Returns `true` when the pointer /// gesture belongs to commenting (text selection must ignore it). @@ -31,247 +31,15 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.context_at = Some((cx.page, cx.to_user(p))); cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); } + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { - // The shape just drawn is selected with its handles showing: they resize it without a - // trip to the Select tool (#2338). Anywhere else the tool keeps drawing. QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), - QuickTool::Comment(tool) if tool.draws() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - let p = clamp_to(page_rect, p); - let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { - let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); - if last.distance(p) >= 1.5 { - points.push(u); - } - } else { - points.truncate(1); - points.push(u); - } - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if tool == CommentTool::Eraser { - view.pending_edit = erase(cx, &points); - } else if let Some(shape) = drawn_shape(tool, &points) { - let now = ui.input(|i| i.time); - cv.settle(now, page, tool, &points); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats - // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a - // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink - && resp.clicked() - && over_page - && let Some(p) = pointer - && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) - { - cv.selected = None; - let now = ui.input(|i| i.time); - cv.settle(now, cx.page, tool, &[cx.to_user(p)]); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - true - } - QuickTool::Comment(tool) if tool.clicks_points() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); - // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. - // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. - if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) - && !ui.ctx().egui_wants_keyboard_input() - && ui.input_mut(|i| { - i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) - }) - { - cv.undo_point(); - return true; - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let u = cx.to_user(p); - match cv.gesture.as_mut() { - Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { - // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); - if closes { - let pts = std::mem::take(points); - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &pts) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - return true; - } - if !repeat { - points.push(u); - } - } - _ => { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); - cv.selected = None; - } - } - } - if finish - && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() - && page == cx.page - && t == tool - { - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &points) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - true - } - QuickTool::Comment(CommentTool::Callout) => { - // Drag from what the callout points at to where its text box goes. - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let [point, at] = points[..] - && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 - { - cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); - } - } - true - } - QuickTool::Comment(CommentTool::Attach) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - cv.attach_at = Some((cx.page, cx.to_user(p))); - cv.selected = None; - } - true - } - QuickTool::Comment(CommentTool::Caret) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let (vx, vy) = cx.xf.screen_to_view(p); - let snapped = cx.info.pages.get(cx.page).zip(caret_text.as_deref()).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); - let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); - cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); - cv.selected = None; - } - true - } - QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its - // text wraps inside it (#2669). - if tool == CommentTool::TextBox { - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let (Some(first), Some(last)) = (points.first(), points.last()) { - let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); - // A drag of a few pixels is a click: the box fits its text. - let drawn = - (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); - let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); - cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); - } - } - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; - cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); - cv.selected = None; - } - true - } + QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), + QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), + QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), + QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), + QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), + QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here), QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), _ => false, @@ -512,6 +280,274 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut } } +/// What the pointer is doing on one page, for the comment tools that draw or place things. +#[derive(Clone, Copy)] +struct Gestures<'a> { + ui: &'a egui::Ui, + resp: &'a egui::Response, + cx: &'a PageCx<'a>, + pointer: Option, + origin: Option, + page_rect: Rect, + pressed_here: bool, + over_page: bool, +} + +impl Gestures<'_> { + fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + let p = clamp_to(page_rect, p); + let u = cx.to_user(p); + if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); + if last.distance(p) >= 1.5 { + points.push(u); + } + } else { + points.truncate(1); + points.push(u); + } + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if tool == CommentTool::Eraser { + view.pending_edit = erase(cx, &points); + } else if let Some(shape) = drawn_shape(tool, &points) { + let now = ui.input(|i| i.time); + cv.settle(now, page, tool, &points); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats + // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a + // single PDF point is visible and remains editable like any other drawing. + if tool == CommentTool::Ink + && resp.clicked() + && over_page + && let Some(p) = pointer + && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) + { + cv.selected = None; + let now = ui.input(|i| i.time); + cv.settle(now, cx.page, tool, &[cx.to_user(p)]); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + true + } + + fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. + // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. + if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) + && !ui.ctx().egui_wants_keyboard_input() + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) + { + cv.undo_point(); + return true; + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let u = cx.to_user(p); + match cv.gesture.as_mut() { + Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { + // Clicking the first point again closes a polygon. + let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + if closes { + let pts = std::mem::take(points); + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &pts) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + return true; + } + if !repeat { + points.push(u); + } + } + _ => { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); + cv.selected = None; + } + } + } + if finish + && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() + && page == cx.page + && t == tool + { + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &points) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + true + } + + fn callout(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + // Drag from what the callout points at to where its text box goes. + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let [point, at] = points[..] + && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 + { + cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); + } + } + true + } + + fn attach(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + cv.attach_at = Some((cx.page, cx.to_user(p))); + cv.selected = None; + } + true + } + + fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let (vx, vy) = cx.xf.screen_to_view(p); + let snapped = cx.info.pages.get(cx.page).zip(caret_text).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); + let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); + cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); + cv.selected = None; + } + true + } + + fn note_or_box(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its + // text wraps inside it (#2669). + if tool == CommentTool::TextBox { + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let (Some(first), Some(last)) = (points.first(), points.last()) { + let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); + // A drag of a few pixels is a click: the box fits its text. + let drawn = + (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); + let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); + cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); + } + } + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; + cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); + cv.selected = None; + } + true + } +} + /// Delete / Escape handling for comments (only while no text field has focus). pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { if !ctx.input(|i| i.pointer.any_down()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 3d9a793111..d3de66a4e7 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,6 +1,9 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind}; -use super::*; +use super::{ + Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, + icons, theme, tool_for, vec2, +}; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 55e99d79ac..bfcd67982b 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,6 +1,9 @@ use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; use super::state::{Gesture, PageCx, SETTLE_SECS}; -use super::*; +use super::{ + Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, + Stroke, color32, vec2, +}; /// Paint comment selection, hover and gesture previews on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 97ccb365a2..36149bb2a0 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -1,5 +1,5 @@ use super::geometry::{corner_ratio, is_markup, resized}; -use super::*; +use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect}; /// The user's login name, which Acrobat uses as the default comment author. pub(super) fn login_name() -> String { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 66daa6edb8..297c3d9a31 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -20,7 +20,7 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, ImageSelection}; -pub(crate) use image::{ImageAction, image_input, image_owns_pointer}; +pub(crate) use image::{ImageAction, ImageCx, image_input, image_owns_pointer}; #[cfg(test)] use image::{rotate_knob, turn_degrees}; #[cfg(test)] diff --git a/crates/ui-egui/src/tools/edit_text_ui/background.rs b/crates/ui-egui/src/tools/edit_text_ui/background.rs index 71b2bffd31..591cfbf2a2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/background.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/background.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Color32, Edit, LineEditor, PageXform, Pos2, TextTarget}; /// A cached, read-only page background. The source glyphs are removed before rasterization; /// colour, pictures and unselected text beneath the editor remain visible. diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 9f55a7d5ed..2c231c19d8 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box}; /// A selected page image, and what the pointer is doing to it. #[derive(Clone, Debug, PartialEq)] @@ -46,6 +46,18 @@ impl CropMode { } } +/// What the pointer and the page are, for the image tools on one page. +#[derive(Clone, Copy)] +pub(crate) struct ImageCx<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub info: &'a DocInfo, + pub allow_interaction: bool, +} + /// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left /// edge midpoints. fn crop_handles(k: Rect) -> [Pos2; 8] { @@ -54,19 +66,8 @@ fn crop_handles(k: Rect) -> [Pos2; 8] { /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` /// (the pointer and keys are the crop's while it lasts). -#[allow(clippy::too_many_arguments)] -fn crop_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - view: &mut DocView, - i: usize, - b: Rect, - allow_interaction: bool, -) -> bool { +fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; @@ -276,44 +277,26 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { if snap { (d / 15.0).round() * 15.0 } else { d } } -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. -#[allow(clippy::too_many_arguments)] -pub(crate) fn image_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, - allow_interaction: bool, -) -> bool { - let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); - let painter = ui.painter(); - let hover_pos = ptr.pos; - let image_contains = |index: usize, point: Pos2| { - let (x, y) = xf.screen_to_view(point); - let Some(p) = info.pages.get(page) else { return false }; - let user = p.view_to_user(x, y); - images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) - }; - let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); - let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); - // Few images: faint outlines for all of them, as before. Many: only the selected and - // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. - // Fully clipped placements (empty visible frames) get no outline. - let faint_all = boxes.len() <= MAX_OUTLINES; - // The picture being dragged has its frame at the pointer; its outline where it was would - // trail behind until the release. - let moving = selected.filter(|_| dragging); +/// Which boxes are outlined on the page, and why. +struct OutlineState { + selected: Option, + hovered_box: Option, + /// The picture being dragged, whose outline would trail behind until the release. + moving: Option, + dragging: bool, + /// Few images: faint outlines for all of them. + faint_all: bool, +} + +/// Outlines the images: all of them faintly when there are few, else the selected and hovered +/// ones; the hovered or selected one is emphasised. +fn draw_outlines(painter: &egui::Painter, boxes: &[Rect], state: OutlineState) { + let OutlineState { selected, hovered_box, moving, dragging, faint_all } = state; if faint_all { for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } - let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); if !faint_all { for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { @@ -332,24 +315,169 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } +} + +fn draw_handles(painter: &egui::Painter, rect: Rect) { + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } +} + +/// Turning the picture by its rotate handle: the turned outline as a preview (the engine redraws +/// the pixels on release). +fn turn_preview(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect, knob: Pos2) { + let ImageCx { ui, resp, ptr, page, .. } = *cx; + let painter = ui.painter(); + let hover_pos = ptr.pos; + let shift = ui.input(|i| i.modifiers.shift); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); + // The turned outline as a preview; the engine redraws the pixels on release. + let centre = b.center(); + let (sin, cos) = degrees.to_radians().sin_cos(); + let turn = |c: Pos2| { + let v = c - centre; + centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) + }; + let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); + painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); + let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); + let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); + painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + s.turning = false; + } + if degrees.abs() >= 0.05 { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); + } + } +} + +/// Where the screen may still show the picture from before an applied move, and whether the +/// page's whole raster is the old one. +#[derive(Clone, Copy)] +struct Blanked { + left_at: Option, + raster_old: bool, +} + +/// A frame of the picture being moved or resized: the stretched picture, its frame and handles, +/// and on release the edit. +fn drag_preview( + cx: &ImageCx<'_>, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + (i, b): (usize, Rect), + (start, opposite, p): (Pos2, Option, Pos2), + blanked: Blanked, +) { + let ImageCx { ui, resp, xf, ptr, page, info, .. } = *cx; + let painter = ui.painter(); + let Blanked { left_at, raster_old } = blanked; + let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); + let preview = match opposite { + // Resize from the opposite corner, keeping the aspect ratio. + Some(fixed) => { + let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); + // Use the axis with the larger relative pointer change. Taking the maximum + // absolute size freezes shrinking when the other axis hasn't moved. Measure + // from the press point so grabbing a handle off-centre cannot cause a jump. + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + None => b.translate(p - start), + }; + // Move/resize the full placement by the visible frame's change. The engine + // transforms an isolated crop together with its image, preserving its contents. + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the + // placement's scale instead of producing inf/NaN. + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + if preview != b + && let Some(tex) = view.page_raster(page) + { + // Its pixels: where the whole-page raster has it. + let source = left_at.filter(|_| raster_old).map_or(b, screen); + let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); + stretched_picture(painter, xf, tex, &blank, source, preview); + } + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(painter, preview); + } + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + let norm = |r: Rect| { + let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); + Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) + }; + s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + } + } + ui.ctx().set_cursor_icon(match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Move, + }); +} + +/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView, action: &mut Option) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); + let painter = ui.painter(); + let hover_pos = ptr.pos; + let image_contains = |index: usize, point: Pos2| { + let (x, y) = xf.screen_to_view(point); + let Some(p) = info.pages.get(page) else { return false }; + let user = p.view_to_user(x, y); + images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) + }; + let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); + // Few images: faint outlines for all of them, as before. Many: only the selected and + // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + // Fully clipped placements (empty visible frames) get no outline. + let faint_all = boxes.len() <= MAX_OUTLINES; + // The picture being dragged has its frame at the pointer; its outline where it was would + // trail behind until the release. + let moving = selected.filter(|_| dragging); + let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); + draw_outlines(painter, &boxes, OutlineState { selected, hovered_box, moving, dragging, faint_all }); // The selected image: frame, corner handles, dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); + return crop_input(cx, view, i, b); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); - } - }; // Until the page is rendered again its raster has an applied move or resize's picture // where it was: it stays blanked there and drawn where it went. // The whole-page raster and its tiles are replaced separately: the old frame stays blanked @@ -372,7 +500,7 @@ pub(crate) fn image_input( } if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); + draw_handles(painter, b); } if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); @@ -408,99 +536,13 @@ pub(crate) fn image_input( } } if view.image_selection.as_ref().is_some_and(|s| s.turning) { - let shift = ui.input(|i| i.modifiers.shift); - let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); - // The turned outline as a preview; the engine redraws the pixels on release. - let centre = b.center(); - let (sin, cos) = degrees.to_radians().sin_cos(); - let turn = |c: Pos2| { - let v = c - centre; - centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) - }; - let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); - painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); - let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); - let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); - painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - s.turning = false; - } - if degrees.abs() >= 0.05 { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); - } - } + turn_preview(cx, view, i, b, knob); return true; } if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) && let Some(p) = hover_pos { - let preview = match opposite { - // Resize from the opposite corner, keeping the aspect ratio. - Some(fixed) => { - let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; - Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) - } - None => b.translate(p - start), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - // The pixels follow the frame: the page raster's picture of the image, stretched - // into the preview over a blank where it was. Re-rendering every frame of a drag - // would lag; the edit renders it properly on release. - if preview != b - && let Some(tex) = view.page_raster(page) - { - // Its pixels: where the whole-page raster has it. - let source = left_at.filter(|_| raster_old).map_or(b, screen); - let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); - stretched_picture(painter, xf, tex, &blank, source, preview); - } - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(preview); - } - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - let norm = |r: Rect| { - let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); - Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) - }; - s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); - } - if preview != b { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); - } - } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Move, - }); + drag_preview(cx, images, view, (i, b), (start, opposite, p), Blanked { left_at, raster_old }); return true; } if allow_interaction diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index a59c10899c..533cfa7314 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Color32, FontFamily, FontId, LineEditor, Rect}; /// The look shown for a paragraph: the family and weight guessed from its PDF font name. fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> pdfcraft_engine::AddedText { diff --git a/crates/ui-egui/src/tools/fill_sign/scan.rs b/crates/ui-egui/src/tools/fill_sign/scan.rs index 2b247f53a8..6179a9ab81 100644 --- a/crates/ui-egui/src/tools/fill_sign/scan.rs +++ b/crates/ui-egui/src/tools/fill_sign/scan.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, PhotoRect, Pos2, SCAN_MAX_SIDE, SavedSig, ScanError, Sense, SigDraft, + SignatureImage, SignaturePhoto, Stroke, Tokens, Turn, image_preview, pos2, vec2, +}; /// The Scan tab's photo, the frame around the signature and how strongly faint ink is picked up. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 28494160c5..5a5ba3e1a5 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -623,6 +623,100 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() } +/// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the +/// caret where the press that opened the editor landed, and takes focus when asked. +fn settle_text_editor( + ui: &mut egui::Ui, + fx: &mut Focus, + r: &egui::Response, + laid_out: Option<(std::sync::Arc, Pos2)>, + fitted: Option<(&form_appearance::TextBox, String)>, +) { + if let Some((tb, before)) = fitted + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } + if let Some(press) = fx.press.take() + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The press went to the page, not to this editor, which did not exist yet. + let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); + st.store(ui.ctx(), r.id); + if ui.input(|i| i.pointer.primary_down()) { + ui.ctx().set_dragged_id(r.id); + } + } + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } +} + +/// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab +/// order, returning the edit to apply (the commit's, or `edit`). +fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { + commit(view, form); + let pending = view.pending_edit.take(); + // The next field that can be filled in, check boxes and radio buttons included (#2223), in + // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. + let mut order: Vec<(usize, &FormField, usize)> = form + .iter() + .filter(|x| fillable(x)) + .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) + .collect(); + order.sort_by_key(|o| o.0); + if let Some(pos) = + order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) + { + let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; + let (_, target, wi) = order[k]; + view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); + if let Some(p) = target.widgets[wi].page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + edit.or(pending) +} + +/// The arrows move to the group's next or previous button and select it, wrapping. +fn step_radio(view: &mut DocView, f: &FormField, widget: usize, forward: bool) -> Option { + let mut edit = None; + let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); + if let Some(pos) = stops.iter().position(|&i| i == widget) { + let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; + if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { + view.forms.focus = Some(open_focus(f, wi)); + edit = select_edit(f, target); + if let Some(p) = target.page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + } + edit +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. /// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor /// then takes no more than fits. @@ -714,42 +808,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - if let (Some(tb), Some(before)) = (text_box, before) - && fx.text != before - { - let (kept, caret) = tb.fitted_edit(&before, &fx.text); - if kept != fx.text { - fx.text = kept; - if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); - st.store(ui.ctx(), r.id); - } - ui.ctx().request_repaint(); - } - } - if let Some(press) = fx.press.take() - && let Some((galley, pos)) = laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); - st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { - ui.ctx().set_dragged_id(r.id); - } - } - if fx.request_focus { - r.request_focus(); - fx.request_focus = false; - if std::mem::take(&mut fx.select_all) - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - let n = fx.text.chars().count(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); - st.store(ui.ctx(), r.id); - } - } + settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -890,20 +949,7 @@ pub(crate) fn overlay( // turns the selected button off. edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) }; } else if let Some(forward) = step { - // The arrows move to the group's next or previous button and select it, wrapping. - let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); - if let Some(pos) = stops.iter().position(|&i| i == focus.widget) { - let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; - if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { - view.forms.focus = Some(open_focus(f, wi)); - edit = select_edit(f, target); - if let Some(p) = target.page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - } + edit = step_radio(view, f, focus.widget, forward); } } _ => close = true, @@ -912,31 +958,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - commit(view, form); - let pending = view.pending_edit.take(); - // The next field that can be filled in, check boxes and radio buttons included (#2223), in - // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = order - .iter() - .position(|(_, x, wi)| x.name == f.name && *wi == focus.widget) - .or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; - view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - edit = edit.or(pending); + edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 2e9e600dfc..c72e039b1b 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -166,6 +166,42 @@ impl MeasureView { self.scale = None; } } +/// Snaps `point` to the page's drawn geometry (endpoints, midpoints, intersections, paths), +/// recording in `state` why it couldn't when the content is too complex or unreadable. +fn snap_to_content( + state: &mut MeasureView, + doc: &Document, + page: usize, + view_xy: [f32; 2], + pixels_per_unit: f32, + point: Point, +) -> Option<(Point, measure::snap::SnapKind)> { + if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { + state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); + } + if let Some((_, _, Err(e))) = &state.paths { + state.error = Some(e.clone()); + } + if let Some((_, _, Ok(paths))) = &state.paths { + if paths.truncated { + state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); + } else if paths.unreadable > 0 { + state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); + } + let tolerance = doc + .measurement_to_user(page, [f64::from(view_xy[0]) + state.sensitivity / f64::from(pixels_per_unit), f64::from(view_xy[1])]) + .map(|p| measure::distance(point, p)) + .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); + if state.snaps.intersections && paths.intersection_limited(point, tolerance) { + state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); + } + if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { + return Some((hit.point, hit.kind)); + } + } + None +} + pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, @@ -204,32 +240,13 @@ pub(crate) fn page_input( let (vx, vy) = xf.screen_to_view(pos); let [x, y] = info.view_to_user(vx, vy); let mut point = [f64::from(x), f64::from(y)]; + let mut snapped = None; if state.snap_enabled { - if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { - state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); - } - let pixels_per_unit = xf.zoom().max(1e-6); - if let Some((_, _, Err(e))) = &state.paths { - state.error = Some(e.clone()); - } - if let Some((_, _, Ok(paths))) = &state.paths { - if paths.truncated { - state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); - } else if paths.unreadable > 0 { - state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); - } - let tolerance = doc - .measurement_to_user(page, [f64::from(vx) + state.sensitivity / f64::from(pixels_per_unit), f64::from(vy)]) - .map(|p| measure::distance(point, p)) - .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); - if state.snaps.intersections && paths.intersection_limited(point, tolerance) { - state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); - } - if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { - point = hit.point; - snap = Some(hit.kind); - } - } + snapped = snap_to_content(state, doc, page, [vx, vy], xf.zoom().max(1e-6), point); + } + if let Some((snapped_point, kind)) = snapped { + point = snapped_point; + snap = Some(kind); } if ui.input(|i| i.modifiers.shift) && let Some(last) = state.points.last() diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs index 5bba8fe185..c6f9bf4451 100644 --- a/crates/ui-egui/src/tools/prepare/draft.rs +++ b/crates/ui-egui/src/tools/prepare/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Calculate, FieldLook, FieldProps, FormField, FormFieldKind, Format, Validate}; // ───────────────────────────────────────────────────────────────────────── Field Properties diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index 8099d57489..f922143f6d 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -1,6 +1,6 @@ use super::draft::{FieldDraft, FieldTab}; use super::tabs::{actions_tab, calculate_tab, format_tab, validate_tab}; -use super::*; +use super::{BorderStyle, FieldFont, FormFieldKind, theme, widgets}; /// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index b74e67dd19..04831353bd 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -1,5 +1,5 @@ use super::draft::{ACTION_KINDS, ActionDraft, FieldDraft}; -use super::*; +use super::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value, theme, widgets}; /// The categories of the Format tab. #[derive(Clone, Copy, PartialEq)] diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index ad5ef4b01d..feb4fdd207 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic, + SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets, +}; /// Draw the signing dialogs; returns `true` when the dialog should close. pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { diff --git a/crates/ui-egui/src/tools/sign_ui/panel.rs b/crates/ui-egui/src/tools/sign_ui/panel.rs index af5bfd77cf..415c035f00 100644 --- a/crates/ui-egui/src/tools/sign_ui/panel.rs +++ b/crates/ui-egui/src/tools/sign_ui/panel.rs @@ -1,5 +1,5 @@ use super::dialog::title; -use super::*; +use super::{Align, Certificate, Color32, Layout, SignatureInfo, SignatureStatus, Tokens, icons, sign, theme, widgets}; /// The message bar for signed documents: (icon, colour, template, argument). /// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows From 2ca92751cfb87dc9126eb0112278c4f4225dcb21 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 24/38] Refactor ui workflows for strict Clippy checks --- .../src/workflows/combine_grid/card.rs | 217 +++++++++++------- .../src/workflows/combine_grid/thumbs.rs | 2 +- .../src/workflows/combine_ui/actions.rs | 149 +++++++----- .../ui-egui/src/workflows/combine_ui/files.rs | 2 +- .../ui-egui/src/workflows/combine_ui/parts.rs | 2 +- .../src/workflows/combine_ui/session.rs | 2 +- .../ui-egui/src/workflows/combine_ui/table.rs | 5 +- .../ui-egui/src/workflows/combine_ui/view.rs | 2 +- crates/ui-egui/src/workflows/print_ui/body.rs | 5 +- .../ui-egui/src/workflows/print_ui/draft.rs | 2 +- 10 files changed, 239 insertions(+), 149 deletions(-) diff --git a/crates/ui-egui/src/workflows/combine_grid/card.rs b/crates/ui-egui/src/workflows/combine_grid/card.rs index 63cf14c5ee..3e98daa545 100644 --- a/crates/ui-egui/src/workflows/combine_grid/card.rs +++ b/crates/ui-egui/src/workflows/combine_grid/card.rs @@ -1,5 +1,8 @@ use super::thumbs::{Key, Look, MAX_SIDE, PREVIEW, PREVIEW_SIDE, SIZE_STEP, Thumbs, Want}; -use super::*; +use super::{ + ABOVE, Align2, Arc, BAR_H, Color32, CombineFile, CornerRadius, ERROR, FALLBACK_PAGE, GridDrag, Lock, MAX_EXPAND, ON_PAPER, PdfCraftApp, Rect, + RowAction, Sense, Stroke, TextureHandle, Tokens, Vec2, WARNING, fit_name, icons, paper_size, pos2, theme, vec2, +}; /// One card's place and what it shows. pub(super) struct Card { @@ -18,6 +21,16 @@ pub(super) struct Card { pub(super) fit: Vec2, } +/// The paper box a card's page is drawn in. +struct Paper { + rect: Rect, + size: Vec2, + /// The page's size in points. + size_pt: (f32, f32), + /// Whether the page's own size is known (else a fallback is drawn and no preview is asked for). + known: bool, +} + impl Card { pub(super) fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { match self.page { @@ -81,56 +94,7 @@ impl Card { painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); // What goes on the paper: the thumbnail, or why there isn't one. - let mut why: Option<(&str, Color32, String)> = None; - if let Some(lock) = f.lock { - let text = match lock { - Lock::Open => tl!("Password-protected"), - Lock::Permissions => tl!("Its security settings don't allow copying pages"), - }; - why = Some(("lock", ON_PAPER, text.to_owned())); - } else if let Some(p) = &f.problem { - why = Some(("circle-x", ERROR, p.clone())); - } else { - match page { - Err(e) => why = Some(("circle-x", ERROR, e)), - // The pages couldn't be counted, or the page's size is unknown (some passwords - // open a file for combining but not for reading it here): nothing is drawn (the - // engine decides at Combine), and the card says so rather than look blank. - Ok(p) if p >= f.pages || known.is_none() => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), - Ok(p) => { - // Pixels per point: the size the paper is drawn at, on both sides, and never a - // side longer than a thumbnail's (a long, thin page fits by its long side). - let (w, h) = size_pt; - let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); - let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); - let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; - let look = thumbs.look((f.group, p), key); - let mut want = false; - match look { - Look::Ready(tex) => { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), - Look::Pending(stand_in) => { - if let Some(tex) = stand_in { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - want = true; - } - } - if want && scale.is_finite() && scale > 0.0 { - thumbs.wants.push(Want { - slot: (f.group, p), - key, - bytes: f.bytes.clone(), - password: f.password().map(Arc::from), - scale, - visible: ui.is_rect_visible(c), - }); - } - } - } - } + let why = self.paper_content(ui, &painter, f, thumbs, page, Paper { rect: paper, size, size_pt, known: known.is_some() }); if let Some((icon, colour, _)) = &why { icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(28.0)), icon, 28.0, *colour); } @@ -187,26 +151,7 @@ impl Card { let previewable = why.is_none(); // A file that adds more pages than one can be spread out into them. let expandable = self.page.is_none() && takes > 1 && takes <= MAX_EXPAND && f.lock.is_none() && f.problem.is_none() && self.check.is_ok(); - // Shown large: the file from its first page, or a page card's page. - let preview = match self.page { - Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), - None => RowAction::Preview(f.id), - }; - // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, - // or the page), show large. - let mut buttons: Vec<(&str, String, RowAction)> = Vec::new(); - if expandable { - buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); - } - if let Some((nth, ..)) = self.page { - buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); - buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); - } else { - buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); - } - if previewable { - buttons.push(("search", tl!("Preview").to_owned(), preview)); - } + let buttons = self.card_buttons(f, expandable, previewable); // Nothing of the card shows over a dialog (the preview, the password box). let covered = ui.ctx().memory(|m| m.top_modal_layer().is_some()); let mut over_bar = false; @@ -227,24 +172,7 @@ impl Card { } } - // The full name and everything known about the file, on hover (on a page card, which page). - let mut tip = match &page_label { - Some(page) => format!("{}\n{page}", f.name), - None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())), - }; - for line in why.iter().map(|(_, _, s)| s).chain(&f.notes) { - tip.push('\n'); - tip.push_str(line); - } - if self.twice { - tip.push('\n'); - tip.push_str(tl!("Added more than once")); - } - // Page ranges are typed in the list. - if self.page.is_none() && self.check.is_err() { - tip.push('\n'); - tip.push_str(tl!("Switch to List view to change the pages it takes.")); - } + let tip = self.tooltip(f, page_label.as_deref(), &pages, why.as_ref().map(|(_, _, reason)| reason.as_str())); // (Over the bar, its buttons say what they do instead.) let resp = if covered || over_bar { resp } else { resp.on_hover_text(tip) }; @@ -279,6 +207,117 @@ impl Card { } action } + + /// The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, or + /// the page), show large. + fn card_buttons(&self, f: &CombineFile, expandable: bool, previewable: bool) -> Vec<(&'static str, String, RowAction)> { + // Shown large: the file from its first page, or a page card's page. + let preview = match self.page { + Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), + None => RowAction::Preview(f.id), + }; + // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, + // or the page), show large. + let mut buttons: Vec<(&'static str, String, RowAction)> = Vec::new(); + if expandable { + buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); + } + if let Some((nth, ..)) = self.page { + buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); + buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); + } else { + buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); + } + if previewable { + buttons.push(("search", tl!("Preview").to_owned(), preview)); + } + buttons + } + + /// The full name and everything known about the file, on hover (on a page card, which page). + fn tooltip(&self, f: &CombineFile, page_label: Option<&str>, pages: &str, why: Option<&str>) -> String { + let mut tip = match page_label { + Some(page) => format!("{}\n{page}", f.name), + None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())), + }; + for line in why.into_iter().chain(f.notes.iter().map(String::as_str)) { + tip.push('\n'); + tip.push_str(line); + } + if self.twice { + tip.push('\n'); + tip.push_str(tl!("Added more than once")); + } + // Page ranges are typed in the list. + if self.page.is_none() && self.check.is_err() { + tip.push('\n'); + tip.push_str(tl!("Switch to List view to change the pages it takes.")); + } + tip + } + + /// What goes on the paper: the thumbnail, or why there isn't one (an icon, its colour and the reason). + fn paper_content( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + f: &CombineFile, + thumbs: &mut Thumbs, + page: Result, + paper: Paper, + ) -> Option<(&'static str, Color32, String)> { + let mut why: Option<(&'static str, Color32, String)> = None; + if let Some(lock) = f.lock { + let text = match lock { + Lock::Open => tl!("Password-protected"), + Lock::Permissions => tl!("Its security settings don't allow copying pages"), + }; + why = Some(("lock", ON_PAPER, text.to_owned())); + } else if let Some(p) = &f.problem { + why = Some(("circle-x", ERROR, p.clone())); + } else { + match page { + Err(e) => why = Some(("circle-x", ERROR, e)), + // The pages couldn't be counted, or the page's size is unknown (some passwords + // open a file for combining but not for reading it here): nothing is drawn (the + // engine decides at Combine), and the card says so rather than look blank. + Ok(p) if p >= f.pages || !paper.known => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), + Ok(p) => { + // Pixels per point: the size the paper is drawn at, on both sides, and never a + // side longer than a thumbnail's (a long, thin page fits by its long side). + let (w, h) = paper.size_pt; + let scale = (paper.size.x * self.ppp / w).min(paper.size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; + let look = thumbs.look((f.group, p), key); + let mut want = false; + match look { + Look::Ready(tex) => { + painter.image(tex, paper.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), + Look::Pending(stand_in) => { + if let Some(tex) = stand_in { + painter.image(tex, paper.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + want = true; + } + } + if want && scale.is_finite() && scale > 0.0 { + thumbs.wants.push(Want { + slot: (f.group, p), + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: ui.is_rect_visible(self.rect), + }); + } + } + } + } + why + } } /// The file a card's magnifier opened, shown large over the page: one of the pages it adds at a diff --git a/crates/ui-egui/src/workflows/combine_grid/thumbs.rs b/crates/ui-egui/src/workflows/combine_grid/thumbs.rs index 5494cdc004..28c54b4bef 100644 --- a/crates/ui-egui/src/workflows/combine_grid/thumbs.rs +++ b/crates/ui-egui/src/workflows/combine_grid/thumbs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, HashMap, RenderConfig, RenderPool, RenderRequest, Retiring, TextureHandle}; /// Render threads for thumbnails at once, counting those of retired pools until they exit. pub(super) const MAX_THREADS: usize = 3; diff --git a/crates/ui-egui/src/workflows/combine_ui/actions.rs b/crates/ui-egui/src/workflows/combine_ui/actions.rs index 1eee3f8d39..a4f80abdc8 100644 --- a/crates/ui-egui/src/workflows/combine_ui/actions.rs +++ b/crates/ui-egui/src/workflows/combine_ui/actions.rs @@ -1,7 +1,7 @@ use super::files::{Preview, RowAction}; use super::parts::{moved_pages, removed_pages, step_to}; use super::view::expandable; -use super::*; +use super::{BTreeSet, PdfCraftApp}; impl PdfCraftApp { pub(crate) fn combine_apply(&mut self, action: RowAction) { @@ -29,11 +29,38 @@ impl PdfCraftApp { a => a, }; // Page edits work on the list as parts. + let Some(action) = self.combine_page_action(action) else { return }; + // A file's card clicked (or the list's keys): the files are selected, no page. + if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { + self.combine_tab.selected_pages.clear(); + } match action { - RowAction::ClickPage(id, nth, m) => return self.combine_click_page(id, nth, m), + RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. } | RowAction::Unlock(..) => self.combine_select_action(action), + RowAction::MoveUp + | RowAction::MoveDown + | RowAction::Remove + | RowAction::RemoveFile(_) + | RowAction::Drop { .. } + | RowAction::DropAt { .. } + | RowAction::Sort(_) => { + self.combine_order_action(action); + } + // (Page edits were done above.) + RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} + _ => self.combine_preview_action(action), + } + } + + /// Page edits work on the list as parts. Returns the action back when it is not one. + fn combine_page_action(&mut self, action: RowAction) -> Option { + match action { + RowAction::ClickPage(id, nth, m) => { + self.combine_click_page(id, nth, m); + return None; + } RowAction::MovePages { page, revision, gap } => { if revision != self.combine_tab.revision { - return; + return None; } let moving = if self.combine_tab.selected_pages.contains(&page) { self.combine_tab.selected_pages.clone() } else { BTreeSet::from([page]) }; @@ -47,7 +74,7 @@ impl PdfCraftApp { if let Some(moved) = moved_pages(&parts, &moving, gap, &mut new_id) { self.combine_set_parts(moved); } - return; + return None; } RowAction::RemovePages(one) => { let removing = match one { @@ -55,7 +82,7 @@ impl PdfCraftApp { None => self.combine_tab.selected_pages.clone(), }; if removing.is_empty() { - return; + return None; } let parts = self.combine_parts(); let next = &mut self.combine_tab.next_id; @@ -68,14 +95,15 @@ impl PdfCraftApp { if let Some(left) = removed_pages(&parts, &removing, &mut new_id) { self.combine_set_parts(left); } - return; + return None; } _ => {} } - // A file's card clicked (or the list's keys): the files are selected, no page. - if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { - self.combine_tab.selected_pages.clear(); - } + Some(action) + } + + /// Selecting files, and asking for the passwords of locked ones. + fn combine_select_action(&mut self, action: RowAction) { let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); let n = ids.len(); let index_of = |id: Option| id.and_then(|id| ids.iter().position(|x| *x == id)); @@ -129,6 +157,31 @@ impl PdfCraftApp { tab.cursor = Some(id); tab.reveal = Some(id); } + RowAction::Unlock(row, at) => { + let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); + let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { + // A selected row brings the rest of the selection. + Some(id) if tab.selected.contains(&id) => { + ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() + } + Some(id) => { + tab.selected = BTreeSet::from([id]); + (tab.anchor, tab.cursor) = (Some(id), Some(id)); + vec![id] + } + None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), + }; + self.combine_ask_password(rows, at); + } + _ => {} + } + } + + /// Moving, removing, dropping and sorting files. + fn combine_order_action(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let tab = &mut self.combine_tab; + match action { RowAction::MoveUp | RowAction::MoveDown => { let up = matches!(action, RowAction::MoveUp); let selected: Vec = ids.iter().map(|id| tab.selected.contains(id)).collect(); @@ -181,6 +234,39 @@ impl PdfCraftApp { self.combine_tab.cursor = None; } } + RowAction::Drop { from, to } => { + let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; + // Dragging a selected file takes the whole selection along. + let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; + // Dropped on the selection itself: nothing to do. + if moving.contains(&target) { + return; + } + // Below the row when dragged down, above it when dragged up. + self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); + } + RowAction::DropAt { file, revision, gap } => { + // The list changed under the drag (e.g. undo): the gap no longer means the same. + if revision != tab.revision || !ids.contains(&file) { + return; + } + let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; + self.combine_move_to_gap(moving, gap); + } + RowAction::Sort(key) => { + let ascending = !matches!(tab.sort, Some((k, true)) if k == key); + self.combine_record(); + self.combine_sort(key, ascending); + } + _ => {} + } + } + + /// Previewing and expanding files. + fn combine_preview_action(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let tab = &mut self.combine_tab; + match action { RowAction::Preview(id) => { if ids.contains(&id) { self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None }); @@ -228,48 +314,7 @@ impl PdfCraftApp { self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None }); } } - // (Page edits were done above.) - RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} - RowAction::Drop { from, to } => { - let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; - // Dragging a selected file takes the whole selection along. - let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; - // Dropped on the selection itself: nothing to do. - if moving.contains(&target) { - return; - } - // Below the row when dragged down, above it when dragged up. - self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); - } - RowAction::DropAt { file, revision, gap } => { - // The list changed under the drag (e.g. undo): the gap no longer means the same. - if revision != tab.revision || !ids.contains(&file) { - return; - } - let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; - self.combine_move_to_gap(moving, gap); - } - RowAction::Unlock(row, at) => { - let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); - let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { - // A selected row brings the rest of the selection. - Some(id) if tab.selected.contains(&id) => { - ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() - } - Some(id) => { - tab.selected = BTreeSet::from([id]); - (tab.anchor, tab.cursor) = (Some(id), Some(id)); - vec![id] - } - None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), - }; - self.combine_ask_password(rows, at); - } - RowAction::Sort(key) => { - let ascending = !matches!(tab.sort, Some((k, true)) if k == key); - self.combine_record(); - self.combine_sort(key, ascending); - } + _ => {} } } } diff --git a/crates/ui-egui/src/workflows/combine_ui/files.rs b/crates/ui-egui/src/workflows/combine_ui/files.rs index fdfa41deac..4947e4f8b1 100644 --- a/crates/ui-egui/src/workflows/combine_ui/files.rs +++ b/crates/ui-egui/src/workflows/combine_ui/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Modifiers, SourceProblem, SystemTime}; /// A column of the list: what it shows and what it sorts by. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs index 74c6683825..7de66b074d 100644 --- a/crates/ui-egui/src/workflows/combine_ui/parts.rs +++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BTreeSet, MAX_FOLDER_DEPTH, MAX_FOLDER_FILES, Ordering, SystemTime}; /// The PDFs, images and text files in `dir` (and, if `recursive`, the folders inside it, not /// following links), in natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index c18b64e0eb..ad8d2a4029 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -2,7 +2,7 @@ use super::files::{AssessError, Assessed, CombineFile, Incoming, Secret, Snapsho #[cfg(not(target_arch = "wasm32"))] use super::parts::combinable_in; use super::parts::{Part, items_of, moved_order, natural, parts_of, range_of, title_of}; -use super::*; +use super::{Arc, BTreeSet, CombineTab, HISTORY, MAX_FOLDER_FILES, Modifiers, PdfCraftApp}; impl PdfCraftApp { /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. diff --git a/crates/ui-egui/src/workflows/combine_ui/table.rs b/crates/ui-egui/src/workflows/combine_ui/table.rs index 3a95e87068..86015db39c 100644 --- a/crates/ui-egui/src/workflows/combine_ui/table.rs +++ b/crates/ui-egui/src/workflows/combine_ui/table.rs @@ -1,6 +1,9 @@ use super::files::{Columns, Lock, RowAction, SortKey}; use super::parts::{ago, utc_stamp}; -use super::*; +use super::{ + Align, Color32, Column, CornerRadius, ERROR, Layout, Modifiers, PdfCraftApp, Rect, Sense, Stroke, TableBuilder, Tokens, WARNING, icons, theme, + vec2, +}; /// A column heading being dragged to a new place. #[derive(Clone, Copy)] diff --git a/crates/ui-egui/src/workflows/combine_ui/view.rs b/crates/ui-egui/src/workflows/combine_ui/view.rs index 1e20332307..4e1d0c610f 100644 --- a/crates/ui-egui/src/workflows/combine_ui/view.rs +++ b/crates/ui-egui/src/workflows/combine_ui/view.rs @@ -1,6 +1,6 @@ use super::files::{CombineFile, CombineView, Lock, RowAction}; use super::table::{TableEvents, table}; -use super::*; +use super::{Align, BTreeSet, CornerRadius, ERROR, Layout, Modifiers, PdfCraftApp, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; /// The Combine files page. pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 5243820396..d5cdbf16ed 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -1,5 +1,8 @@ use super::draft::{Handling, PrintDraft, Which}; -use super::*; +use super::{ + Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, + Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, +}; /// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. /// Only choices away from the printer's defaults are kept, and sent with the job. diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 1609262abb..be2a22efb3 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { From 9530dbb3e804992e4d57407dd60b4db08b03ff61 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 25/38] Refactor xfa for strict Clippy checks --- crates/xfa/src/data/build.rs | 2 +- crates/xfa/src/data/dates.rs | 2 +- crates/xfa/src/data/import.rs | 2 +- crates/xfa/src/data/merge.rs | 2 +- crates/xfa/src/data/passwords.rs | 2 +- crates/xfa/src/data/path.rs | 2 +- crates/xfa/src/data/values.rs | 2 +- crates/xfa/src/data/write.rs | 2 +- crates/xfa/src/layout/common.rs | 2 +- crates/xfa/src/layout/draw.rs | 5 +- crates/xfa/src/layout/field.rs | 5 +- crates/xfa/src/layout/flow.rs | 2 +- crates/xfa/src/layout/items.rs | 2 +- crates/xfa/src/layout/measure.rs | 5 +- crates/xfa/src/layout/pages.rs | 7 +- crates/xfa/src/pdf.rs | 128 +++++++++++++++++-------------- crates/xfa/src/pdf/common.rs | 2 +- crates/xfa/src/pdf/strip.rs | 2 +- crates/xfa/src/tests.rs | 16 ++++ 19 files changed, 118 insertions(+), 74 deletions(-) diff --git a/crates/xfa/src/data/build.rs b/crates/xfa/src/data/build.rs index 68e8a2a364..98e34f0f49 100644 --- a/crates/xfa/src/data/build.rs +++ b/crates/xfa/src/data/build.rs @@ -1,5 +1,5 @@ use super::values::{FieldDatum, Want, wanted, with_template}; -use super::*; +use super::{Document, HashMap, MAX_DEPTH, XFA_DATA_NS}; pub(super) fn xml_name(s: &str) -> String { let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs index 029d91b273..565c44117d 100644 --- a/crates/xfa/src/data/dates.rs +++ b/crates/xfa/src/data/dates.rs @@ -1,5 +1,5 @@ use super::path::template_field; -use super::*; +use super::{Field, Template}; /// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as /// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs index cec30b11b3..7582d86763 100644 --- a/crates/xfa/src/data/import.rs +++ b/crates/xfa/src/data/import.rs @@ -2,7 +2,7 @@ use super::build::{escape, fresh_datasets, xml_name}; use super::merge::{Op, WRAP_CLOSE, WRAP_OPEN, qname, start_tag_end}; use super::values::Want; use super::write::{DatasetsWrite, rewrite}; -use super::*; +use super::{DataPath, Document, MAX_DEPTH, MAX_NODES, ObjRef, Object, XFA_DATA_NS, XfaError}; /// Data read from a file, held as a tree so it can be written into any packet with the /// prefix that packet uses, never as the file's own markup. diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs index 6888b1439a..48b4cedabf 100644 --- a/crates/xfa/src/data/merge.rs +++ b/crates/xfa/src/data/merge.rs @@ -2,7 +2,7 @@ use super::build::{Build, CONFLICT, CONTROL, NewNodes, STRUCTURED, TOO_MANY, bui use super::import::{ImportNode, serialize_nodes}; use super::values::Want; use super::write::MAX_PACKET_WRITE; -use super::*; +use super::{DataPath, HashMap, MAX_NODES, XFA_DATA_NS}; /// What a rewrite of the datasets packet does. #[derive(Clone, Copy)] diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs index 78503bb53c..0f98a3f2b4 100644 --- a/crates/xfa/src/data/passwords.rs +++ b/crates/xfa/src/data/passwords.rs @@ -3,7 +3,7 @@ use super::merge::Op; use super::path::{data_path, parse_datasets}; use super::values::{Want, path_of, text_of, with_template}; use super::write::{datasets_stream_ref, rewrite}; -use super::*; +use super::{DataNode, DataPath, Document, MAX_DEPTH, Node, ObjRef, Object, SomIndices, Template, Ui, XfaError}; /// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, /// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index 90cc4346f2..ef61cdbd59 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template}; fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { let name = n.tag_name().name().to_string(); diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 41fb6644e9..8042e774fe 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -1,6 +1,6 @@ use super::dates::{data_date_pattern, data_to_iso, iso_to_data, iso_to_pattern, pattern_to_iso}; use super::path::{data_path, parse_datasets, som_to_path}; -use super::*; +use super::{DataPath, Dict, Document, ObjRef, Object, Template}; /// What a field holds, for writing the datasets. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs index 282dd33ef5..4667036fe5 100644 --- a/crates/xfa/src/data/write.rs +++ b/crates/xfa/src/data/write.rs @@ -2,7 +2,7 @@ use super::build::{MAX_INDEX, build_children, fresh_datasets}; use super::merge::{Op, Within, merge_all, readable}; use super::path::path_som; use super::values::{FieldDatum, Want, wanted, with_template}; -use super::*; +use super::{DataOp, DataPath, Dict, Document, Encoding, HashMap, ObjRef, Object, PdfString, Stream, Template, XfaError, encode}; /// What [`write_datasets`] did. #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 3b60f8a6a0..625f305a6b 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -1,5 +1,5 @@ use super::items::{Action, Item, Rounded}; -use super::*; +use super::{HashMap, Layout, MAX_DEPTH, Node, Rect, Script, date_letters, iso_to_pattern}; pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { if depth > MAX_DEPTH { diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 4f318a73d6..f6520c1628 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -1,6 +1,9 @@ use super::common::clean_name; use super::items::{Item, Rounded}; -use super::*; +use super::{ + Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke, + Subform, Value, XfaError, data_path, text, +}; impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── drawing diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs index 40ec1497de..cc15f16788 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -1,6 +1,9 @@ use super::common::{button_action, date_pattern}; use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; -use super::*; +use super::{ + Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, XfaError, + data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, +}; impl Layouter<'_> { /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 48c57cd888..8f656d7eaf 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,6 +1,6 @@ use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; use super::items::Item; -use super::*; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index 51383792c1..8a68388684 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Face, HAlign, Rect, Span}; /// How far below its baseline an underline is drawn, in the text's size: an underline is the /// level line pushed just before its span, this far below it (the PDF writer tells it apart from diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index cd004df982..2fffb3dfa0 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -1,6 +1,9 @@ use super::common::clamp_opt; use super::field::auto_check_reserve; -use super::*; +use super::{ + Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui, + Value, data_path, text, +}; impl Layouter<'_> { /// The width and height `node` takes in `avail_w`: its own size, else what its content needs diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index 13047058b3..dac17c1169 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -1,5 +1,10 @@ +#[cfg(test)] +use super::EMBED_COPIED; use super::items::{Item, Page, UNDERLINE_DROP}; -use super::*; +use super::{ + Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect, + SomIndices, XfaError, text, +}; impl Layouter<'_> { pub(super) fn warn_once(&mut self, msg: impl Into) { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index ab5d94bb45..0c21507abf 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -163,38 +163,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } - /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with - /// its border, background and kind, and return the annotation's object. - fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { + fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> (i64, bool, Option) { let r = w.rect; - let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"F".to_vec(), Object::Int(4)); - d.set(b"P".to_vec(), Object::Ref(page_ref)); - d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); - d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); - if let Some(t) = &w.tooltip { - d.set(b"TU".to_vec(), Object::String(PdfString::text(t))); - } - d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som))); - let mut mk = Dict::new(); - if let Some(c) = w.border.color - && w.border.shape != BorderShape::None - { - mk.set(b"BC".to_vec(), rgb_array(c)); - } - if let Some(c) = w.border.fill { - mk.set(b"BG".to_vec(), rgb_array(c)); - } - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); - bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); - d.set(b"BS".to_vec(), Object::Dict(bs)); - if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { - d.set(ROUND_KEY.to_vec(), Object::Bool(true)); - } let mut ff: i64 = 0; if w.read_only { ff |= 1; @@ -383,34 +353,45 @@ impl Emitter<'_> { d.set(b"FT".to_vec(), Object::name("Btn")); ff |= 1 << 16; mk.set(b"CA".to_vec(), Object::String(PdfString::text(caption))); - if let Some(Action::Script(_)) = &w.action { - // An XFA click script is no Acrobat JavaScript: no PDF action, which other - // viewers would run; PdfCraft finds the script by the field's SOM path. - d.set(CLICK_KEY.to_vec(), Object::Bool(true)); - } else if let Some(a) = &w.action { - let mut ad = Dict::new(); - match a { - Action::Reset => { - ad.set(b"S".to_vec(), Object::name("ResetForm")); - } - Action::Print => { - ad.set(b"S".to_vec(), Object::name("Named")); - ad.set(b"N".to_vec(), Object::name("Print")); - } - Action::SaveAs => { - ad.set(b"S".to_vec(), Object::name("Named")); - ad.set(b"N".to_vec(), Object::name("SaveAs")); - } - Action::Url(u) => { - ad.set(b"S".to_vec(), Object::name("URI")); - ad.set(b"URI".to_vec(), Object::String(PdfString::literal(u.as_bytes().to_vec()))); - } - Action::Script(_) => {} - } - d.set(b"A".to_vec(), Object::Dict(ad)); - } + write_button_action(w.action.as_ref(), d); } } + (ff, top_level, under) + } + + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. + fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { + let r = w.rect; + let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"F".to_vec(), Object::Int(4)); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); + if let Some(t) = &w.tooltip { + d.set(b"TU".to_vec(), Object::String(PdfString::text(t))); + } + d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som))); + let mut mk = Dict::new(); + if let Some(c) = w.border.color + && w.border.shape != BorderShape::None + { + mk.set(b"BC".to_vec(), rgb_array(c)); + } + if let Some(c) = w.border.fill { + mk.set(b"BG".to_vec(), rgb_array(c)); + } + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); + bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); + d.set(b"BS".to_vec(), Object::Dict(bs)); + if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { + d.set(ROUND_KEY.to_vec(), Object::Bool(true)); + } + let (ff, top_level, under) = self.populate_widget_kind(w, &mut d, &mut mk); if !mk.is_empty() { d.set(b"MK".to_vec(), Object::Dict(mk)); } @@ -751,6 +732,39 @@ impl Emitter<'_> { } } +fn write_button_action(action: Option<&Action>, d: &mut Dict) { + match action { + Some(Action::Script(_)) => { + // An XFA click script is no Acrobat JavaScript: no PDF action, which other + // viewers would run; PdfCraft finds the script by the field's SOM path. + d.set(CLICK_KEY.to_vec(), Object::Bool(true)); + } + Some(action) => { + let mut ad = Dict::new(); + match action { + Action::Reset => { + ad.set(b"S".to_vec(), Object::name("ResetForm")); + } + Action::Print => { + ad.set(b"S".to_vec(), Object::name("Named")); + ad.set(b"N".to_vec(), Object::name("Print")); + } + Action::SaveAs => { + ad.set(b"S".to_vec(), Object::name("Named")); + ad.set(b"N".to_vec(), Object::name("SaveAs")); + } + Action::Url(url) => { + ad.set(b"S".to_vec(), Object::name("URI")); + ad.set(b"URI".to_vec(), Object::String(PdfString::literal(url.as_bytes().to_vec()))); + } + Action::Script(_) => {} + } + d.set(b"A".to_vec(), Object::Dict(ad)); + } + None => {} + } +} + /// The link annotation a page's text last ended in: the next piece of the same link on the same /// line widens it. struct LinkPiece { diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs index e1325b19ae..7cbb1ce7a1 100644 --- a/crates/xfa/src/pdf/common.rs +++ b/crates/xfa/src/pdf/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Family, Object, PdfString, Rect, Rounded, Stream}; /// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT /// markers). `None` for anything else. diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index de395e37e9..2f3084ec93 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CLICK_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError}; /// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). pub(crate) const USAGE_RIGHTS_REMOVED: &str = diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 39a1c93483..ca129abceb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1512,6 +1512,11 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> { /// widget, and their AcroForm fields and image XObjects once written. #[test] fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { + assert_choice_widget_layout(); + assert_choice_fields_and_picture_streams(); +} + +fn assert_choice_widget_layout() { let form = layout_xml(&fields_template("")).unwrap(); let p1 = &form.pages[0]; let w = widgets(p1); @@ -1546,7 +1551,9 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}"); assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings); assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings); +} +fn assert_choice_fields_and_picture_streams() { // Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password // field, and Flate images (the PNG with a soft mask). let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); @@ -3556,6 +3563,11 @@ fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { + assert_stripping_leaves_ordinary_document(); + assert_stripping_handles_unbound_data_and_shared_field_tree(); +} + +fn assert_stripping_leaves_ordinary_document() { use crate::pdf::strip; let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); render_into(&mut doc).unwrap(); @@ -3606,6 +3618,10 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { let r = strip(&mut doc).unwrap(); assert_eq!(r, crate::Stripped::default()); assert!(!doc.is_modified()); +} + +fn assert_stripping_handles_unbound_data_and_shared_field_tree() { + use crate::pdf::strip; // Data no field holds is counted; a form whose click buttons ran scripts too; the shape // check needs no packet decoded and tells a never-laid-out dynamic form from one to strip. let mut doc = Document::open(Arc::new(shell(&scripted_template().replace("", "
3kept nowherex
")))).unwrap(); From 92bc2f9acd38107f24516b9c3cdd8c214eb423d4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 26/38] Refactor xfdf for strict Clippy checks --- crates/xfdf/src/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xfdf/src/common.rs b/crates/xfdf/src/common.rs index 5670415d38..c4ac9f600d 100644 --- a/crates/xfdf/src/common.rs +++ b/crates/xfdf/src/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DataError, Dict, Document, Object}; // ── names ─────────────────────────────────────────────────────────────────────────────────── From d95ff3898d6ca8071fe322d2c427a00893d3173c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 27/38] Enforce workspace Clippy standards and document lint policy --- CLAUDE.md | 1 + Cargo.toml | 3 +++ clippy.toml | 1 + xtask/Cargo.toml | 3 +++ 4 files changed, 8 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56e..a37cec4ca6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. - **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. - **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`). - **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. diff --git a/Cargo.toml b/Cargo.toml index 99f9fa251b..24313021bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ web-time = "1.1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/clippy.toml b/clippy.toml index e908d13581..31e54da30f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,6 +13,7 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7a33bad875..028282e073 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" publish = false license.workspace = true +[lints] +workspace = true + [dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-cos = { workspace = true } From 63bc58a3d57ed7a521d35c10cd7aa54c4889e36e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:16:56 +0000 Subject: [PATCH 28/38] Fix menu overflow and form pointer handling --- crates/ui-egui/src/canvas.rs | 10 +++++++--- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/tools/forms_ui.rs | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index adf22626b2..ee556ba4a3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -886,6 +886,7 @@ struct PageOut { open_initials: bool, open_signature: bool, open_table: Option<(usize, [f64; 4])>, + primary_down: bool, refused: Option, stamp_placed: bool, } @@ -1908,6 +1909,7 @@ fn draw_pages( open_initials, open_signature, open_table, + primary_down: _, refused, stamp_placed, } = page_out; @@ -2081,6 +2083,7 @@ fn draw_pages( open_initials, open_signature, open_table, + primary_down: ptr.primary_down, refused, stamp_placed, }; @@ -2542,7 +2545,7 @@ fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::sc /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. -fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hover_text: Option<(Pos2, String)>) -> bool { +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. @@ -2566,7 +2569,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hove view.pending_edit = Some(e); } let text_box = crate::forms_ui::text_box(doc, view, form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, text_box.as_ref(), today) { + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { @@ -3077,6 +3080,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_initials, open_signature, open_table, + primary_down, mut refused, stamp_placed, } = page_out; @@ -3103,7 +3107,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let pan_held = track_scroll(ui, view, &out, glide); plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); - let text_done = frame_overlays(ui, view, &env, hover_text); + let text_done = frame_overlays(ui, view, &env, primary_down, hover_text); if let Some(why) = view.objects.notice.take() { refused = Some(why); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7c8bef1cd..d5a33aea34 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -254,7 +254,7 @@ fn english(labeled: Labeled<'_>) -> Option { } fn translate(english: &'static str) -> String { - tl!(english).to_owned() + if english == "⋯" { tl!("More").to_owned() } else { tl!(english).to_owned() } } /// The layouts' own labels (About, Quit, Window…) through the app's translations. diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 5a5ba3e1a5..6bcec64697 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -629,6 +629,7 @@ fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, r: &egui::Response, + primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, ) { @@ -653,7 +654,7 @@ fn settle_text_editor( let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { + if primary_down { ui.ctx().set_dragged_id(r.id); } } @@ -725,11 +726,12 @@ pub(crate) fn overlay( view: &mut DocView, info: &DocInfo, form: &[FormField], + primary_down: bool, text_box: Option<&form_appearance::TextBox>, today: (i64, u32, u32), ) -> Option { let pressed = view.forms.pressed; - if !ctx.input(|i| i.pointer.primary_down()) { + if !primary_down { view.forms.pressed = false; } let toggle = std::mem::take(&mut view.forms.toggle); @@ -808,7 +810,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); + settle_text_editor(ui, fx, &r, primary_down, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) From 81c63c037a70cb01dca676f6c9e0293b952699b2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:25:39 +0000 Subject: [PATCH 29/38] Group annotation, form and document operation parameters --- crates/annot/README.md | 2 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 30 ++++-------------- crates/annot/src/tests.rs | 59 ++++++++++++++++++++++++++--------- crates/annot/src/types.rs | 27 ++++++++++++++++ crates/engine/src/edit/run.rs | 22 +++++++++++-- crates/engine/src/open.rs | 28 +++++++++-------- crates/forms/src/actions.rs | 14 ++++++--- crates/measure/src/tests.rs | 2 +- crates/xfdf/src/tests.rs | 17 ++++++++-- 10 files changed, 140 insertions(+), 63 deletions(-) diff --git a/crates/annot/README.md b/crates/annot/README.md index 8108201835..999baec20a 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 1d85d0bd64..e9e0359ce0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,7 +41,7 @@ pub use modify::{ }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, rect_quad, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, }; pub mod appearance; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 7872a6c817..fc81348075 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -6,6 +6,7 @@ use super::info::{line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, + StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; @@ -499,17 +500,8 @@ fn border_width_of(d: &Dict) -> f64 { } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. -#[allow(clippy::too_many_arguments)] -pub fn set_style( - doc: &mut Document, - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - endings: Option<&[LineEnding]>, - meta: &Meta, -) -> Result<(), AnnotError> { +pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; unlocked(doc, r)?; let d = annot_dict(doc, r); @@ -521,7 +513,7 @@ pub fn set_style( if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { return Err(AnnotError::Invalid("invalid style".into())); } - if let Some(ends) = endings { + if let Some(ends) = &endings { let callout = subtype == "FreeText" && d.contains(b"CL"); let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { @@ -691,18 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { /// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that /// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id /// from the caller, so the edit stays deterministic. -#[allow(clippy::too_many_arguments)] -pub fn add_text_replacement( - doc: &mut Document, - page: usize, - quads: &[[f64; 8]], - replacement: &str, - author: &str, - strike: &Style, - caret: &Style, - meta: &Meta, - caret_meta: &Meta, -) -> Result { +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( doc, diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb9..eb8d1a0f15 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() { fn text_box_font_size_changes_the_da_and_grows_the_box() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); let height = |doc: &Document| { let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); @@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() { let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); assert!(props(&doc, 0, index).unwrap().fillable); set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); - set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); let saved = reopen(&doc); let properties = props(&saved, 0, index).unwrap(); assert_eq!(properties.fill, Some([1.0; 3])); @@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() { b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", ]); assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); - set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[0]; assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 40ed744df3..bd3409b5ae 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -563,6 +563,33 @@ pub struct Style { pub fill: Option, } +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + impl Default for Style { fn default() -> Self { Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7bf5467fcf..49f19fa637 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -331,7 +331,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + pdfcraft_annot::add_text_replacement( + doc, + pdfcraft_annot::TextReplacement { + page: *page, + quads, + replacement: text, + author, + strike, + caret, + strike_meta: &strike_meta, + caret_meta: &caret_meta, + }, + )?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; @@ -341,7 +353,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + pdfcraft_annot::set_style( + doc, + *page, + *index, + pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() }, + &cx.meta(), + )?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index d8950d1d6d..58bc8327e2 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -25,6 +25,17 @@ pub struct PreparedDocument { document: Document, } +struct DocumentOpenData<'a> { + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + structure: Result, Box>, + render_password: Option, + password: Option<&'a str>, + xfa: Option, +} + impl OpenContext { /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( @@ -241,7 +252,8 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = 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); + let mut document = + Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: 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); @@ -254,18 +266,8 @@ impl Session { } /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { + fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5eb57c6c1b..5ebe3269cc 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from /// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. -#[allow(clippy::too_many_arguments)] +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + fn replace_trigger( doc: &mut Document, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget, - t: Trigger, - new: Option<&FieldAction>, - old: Option, + update: TriggerUpdate<'_>, aa: &mut Dict, a_entry: &mut Option, ) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); if new.is_some() && new == old_action.as_ref() { return Ok(()); @@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; - replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?; + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d69a05cf0..5b277e5546 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2bb54f9232..52a4a5b86d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -151,7 +151,20 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; - add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &meta("strike"), + caret_meta: &meta("caret"), + }, + ) + .unwrap(); assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); let xfdf = export_xfdf(&src, true, false, "a.pdf"); From 5fecdb9cee0fa6d3c3bbe4a7a9f81a5fbeefdfcb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:25:40 +0000 Subject: [PATCH 30/38] Group signing and timestamp validation inputs --- crates/sign/src/cms.rs | 27 +++++++++------- crates/sign/src/pdf/validate.rs | 57 +++++++++++++++++---------------- crates/sign/src/timestamp.rs | 30 ++++++++++------- crates/sign/tests/it/pdf.rs | 37 ++++++++++++++------- 4 files changed, 89 insertions(+), 62 deletions(-) diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index b148ea4d19..4e232af616 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -376,7 +376,7 @@ pub fn sign_detached( alg: DigestAlg, content_digest: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, DATA, None, content_digest) + build(SignedDataInput { key, cert, chain, alg, content_type: DATA, content: None, content_digest }) } /// A CMS SignedData that encapsulates `content` under `content_type` (an RFC 3161 token wraps @@ -389,20 +389,23 @@ pub fn sign_encapsulated( content_type: &str, content: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, content_type, Some(content), &alg.digest(&[content])) + let content_digest = alg.digest(&[content]); + build(SignedDataInput { key, cert, chain, alg, content_type, content: Some(content), content_digest: &content_digest }) } -// The parameters mirror the SignedData fields being assembled. -#[allow(clippy::too_many_arguments)] -fn build( - key: &PrivateKey, - cert: &Certificate, - chain: &[Certificate], +/// The values needed to assemble a CMS SignedData object. +struct SignedDataInput<'a> { + key: &'a PrivateKey, + cert: &'a Certificate, + chain: &'a [Certificate], alg: DigestAlg, - content_type: &str, - content: Option<&[u8]>, - content_digest: &[u8], -) -> Result, SignError> { + content_type: &'a str, + content: Option<&'a [u8]>, + content_digest: &'a [u8], +} + +fn build(input: SignedDataInput<'_>) -> Result, SignError> { + let SignedDataInput { key, cert, chain, alg, content_type, content, content_digest } = input; let cert_hash = DigestAlg::Sha256.digest(&[&cert.raw]); let general_names = der::seq(&[&der::explicit(4, &cert.issuer.raw)]); let ess = der::seq(&[&der::seq(&[&der::seq(&[&der::octets(&cert_hash), &der::seq(&[&general_names, &der::uint(&cert.serial)])])])]); diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 668798c20b..08b482c4b4 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -5,6 +5,22 @@ use super::{ Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, }; +struct SignatureValidation<'a> { + doc: &'a Document, + bytes: &'a [u8], + trust: &'a TrustStore, + info: &'a mut SignatureInfo, + cache: &'a DigestCache, +} + +struct VerifiedSignature<'a> { + covered: usize, + certificate: Certificate, + embedded: &'a [Certificate], + cms_time: Option