From 12e17a1f648e31fbcc831536d144639b0279f503 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:52:29 +0100 Subject: [PATCH 01/49] refactor(ui): crop mode's hit is a hit test of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crop mode owns every press on its page, the controls beside its frame included (0595d4d39). `PageHits::at` made that hit itself. A `Hit` comes from a module's hit test, named `hit` or `…_hit`, so the cursor, the tip and the press agree, and the Page presses guard holds the page modules to it. The picture module's `crop_hit` makes it now, and `PageHits::at` asks it first, as before. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 12 +++--------- crates/ui-egui/src/tools/edit_text_ui.rs | 3 ++- crates/ui-egui/src/tools/edit_text_ui/image.rs | 8 ++++++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56d2674213..b281d044a5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1804,18 +1804,12 @@ impl PageHits<'_> { images, clip, } = *self; - // Crop mode owns presses on its page, including the controls beside its - // frame. Underlying paragraphs and the empty-page marquee must not take - // a crop button's press before the image module receives it. + // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). if tool == QuickTool::EditText && can_modify - && let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some()) + && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) { - return vec![crate::page_router::Hit::new( - crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body }, - crate::page_cursor::Layer::Handle, - egui::CursorIcon::Default, - )]; + return vec![crop]; } let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 19a165c152..9100c6d603 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -23,7 +23,8 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; pub(crate) use image::{ - ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen, + ImageCx, Matrix, body_hit as picture_hit, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, + user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; 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 281f7de80e..53d3fb1483 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -519,6 +519,14 @@ pub(crate) fn handle_hit( Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle))) } +/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its +/// frame included, so the paragraphs under it and the empty page's marquee never take a crop +/// button's press before the picture does. `None` when no picture on `page` is being cropped. +pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option { + let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?; + Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default)) +} + /// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the /// selected one. On the text layer, asked before the paragraphs: editable text on top of a /// picture takes the press. From 0cbd61d611568ba176b148369e9efac2632689cd Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:03:36 +0100 Subject: [PATCH 02/49] test(ui): one source-scan guard for the Page presses rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5426 took the page pointer router from #5300 at 11bc8c56, before #5300 added its guard. The guard on main checks seven named press handlers, eight hit tests called by path and one `router.press(` caller. Planted inside `#[cfg(any())]`, it caught one of five violations: a second `link_hit` in `page_tool_input`, a bare `hit(` in comments' `page_after_text`, `view.router.cancel()` and `press_cancelled = true` in redact_ui all passed. This brings in #5300's rules beside main's press-handler check, in one test, `page_modules_act_on_a_press_only_through_the_router` (from aef301d82): - only `page_tool_input`, `draw_pages` and `cancel_pointer_gestures` press, settle or cancel the router; - only `cancel_on_escape` ignores a press until the button is up; - every `hit` and `…_hit` call, bare or by path, is in a function allowed to make one, with why; - a `Hit` is made only in a hit test; - an allowlist entry that is no longer used fails the test. It caught all five planted violations, and a stale ROUTER_WRITERS entry. CLAUDE.md's "Page presses" line names the test. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 146 +++++++++++++++--- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e9328f8d3..7b1d7f5023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2cbde2b995..e92d4c01df 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -263,13 +263,11 @@ fn uses(line: &str, name: &str) -> bool { line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } -/// Each use of one of `names` in the page modules' code (their tests left out) outside the -/// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses -/// none of them any more, as "src/file: fn function unused". -fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { +/// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, +/// the top-level function or `impl` method it is in, line, name). +fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut problems = Vec::new(); - let mut needed = vec![false; allowed.len()]; + let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); // Tests drive egui's input and read its output themselves. @@ -287,14 +285,36 @@ fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &s if line.trim_start().starts_with("//") { continue; } - for name in names.iter().filter(|name| uses(line, name)) { - match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) { - Some(k) => needed[k] = true, - None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)), - } + for name in find(line) { + found.push((file, function.map(str::to_owned), n + 1, name)); } } } + found +} + +/// Each use of one of `names` in the page modules' code (their tests left out). +fn page_module_uses(names: &[&str]) -> Vec<(&'static str, Option, usize, String)> { + page_module_matches(|line| names.iter().filter(|name| uses(line, name)).map(|name| (*name).to_owned()).collect()) +} + +/// Each use of one of `names` in the page modules' code (their tests left out) outside the +/// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses +/// none of them any more, as "src/file: fn function unused". +fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { + misuses_in(page_module_uses(names), allowed, why, unused) +} + +/// [`misuses`], of what was `found`. +fn misuses_in(found: Vec<(&str, Option, usize, String)>, allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { + let mut problems = Vec::new(); + let mut needed = vec![false; allowed.len()]; + for (file, function, n, name) in found { + match allowed.iter().position(|(f, function_name, _)| *f == file && function.as_deref() == Some(*function_name)) { + Some(k) => needed[k] = true, + None => problems.push(format!("src/{file}:{n}: `{name}` {why}")), + } + } for ((file, name, _), needed) in allowed.iter().zip(needed) { if !needed { problems.push(format!("src/{file}: fn {name} {unused}")); @@ -362,18 +382,68 @@ fn page_modules_claim_the_cursor_and_never_set_it() { assert!(problems.is_empty(), "{}", problems.join("\n")); } -/// Hit producers are called only by the central routing table, not again by an input handler. -const PAGE_HIT_CALLS: [&str; 8] = [ +/// What decides, keeps or drops the press the pages hold. +const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.cancel("]; + +/// The functions that decide, keep or drop the press the pages hold: (file, function, why). +const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ + ("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), + ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"), +]; + +/// Ways to make a `Hit`. +const HIT_BUILDS: [&str; 2] = ["Hit::new(", "Hit {"]; + +/// Whether `name` is a hit test's: `hit`, or `…_hit`. +fn is_hit_test(name: &str) -> bool { + name == "hit" || name.ends_with("_hit") +} + +/// The hit tests `line` calls, by path or bare. A method, after its `.`, is left out: +/// `PageCx::hit` finds the comment under a point, not a `Hit`. +fn hit_test_calls(line: &str) -> Vec { + line.match_indices("hit(") + .filter_map(|(at, _)| { + let end = at + "hit".len(); + let start = line[..end].char_indices().rev().find(|(_, c)| !(c.is_alphanumeric() || *c == '_')).map_or(0, |(i, c)| i + c.len_utf8()); + let (before, name) = (&line[..start], &line[start..end]); + (is_hit_test(name) && !before.ends_with('.') && !before.ends_with("fn ")).then(|| name.to_owned()) + }) + .collect() +} + +/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the +/// press point; the others ask where the pointer is. +const HIT_TESTERS: [(&str, &str, &str); 4] = [ + ("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"), + ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), + ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), +]; + +/// What ignores the press the pages hold until the button is up. +const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; + +/// The one function that ignores a press until the button is up: (file, function, why). +const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; + +/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who +/// may). +const PAGE_HIT_CALLS: [&str; 9] = [ "prepare::hit(", "forms_ui::hit(", "comments::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", + "edit_text_ui::crop_hit(", "object_ui::hit(", "content_ui::hit(", ]; +/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test +/// of its own to choose one. fn routed_press_handler(code: &str) -> bool { let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::>().join("\n"); code.contains("router.owner(") @@ -383,21 +453,30 @@ fn routed_press_handler(code: &str) -> bool { && !uses(&code, "handle_hit(") } -/// Source-level guard for the Page presses rule, alongside the raw-pointer and cursor guards. -/// Runtime routing, capture lifetime and Escape behavior have separate behavioral tests. +/// Page modules act on a press only when the router says it is theirs. The hits at the press +/// point decide its owner once, in one place, and a `Hit` comes only from a module's hit test, +/// so what the cursor promised is what the press does. Where each module tested the press +/// point again, or asked another whether it wanted the press, the answers disagreed: a press on +/// one check box released over the next ticked the second, and a drag could be taken over +/// halfway. Only `PageHits::at` asks the hit tests at the press point. Escape is the one way to +/// drop a press and ignore it until the button is up. #[test] -fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { +fn page_modules_act_on_a_press_only_through_the_router() { let mut problems = misuses( - &PAGE_HIT_CALLS, - &[("canvas.rs", "at", "the central PageHits table")], - "repeats a module hit test outside PageHits::at; use the captured router owner", - "no longer collects module hits", + &ROUTER_WRITES, + &ROUTER_WRITERS, + "decides, keeps or drops the press the pages hold. Only the hits at the press point decide its owner, once \ + (src/canvas/page_router.rs): a page module reads `view.router.owner(page)`. Drop a press with \ + `DocView::cancel_pointer_gestures`. A new place that must? Add the function to ROUTER_WRITERS in \ + src/canvas/page_pointer/tests.rs, with why.", + "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", ); problems.extend(misuses( - &["router.press("], - &[("canvas.rs", "page_tool_input", "the central press capture")], - "captures a page press outside the central routing path", - "no longer captures page presses", + &PRESS_IGNORES, + &PRESS_IGNORERS, + "ignores the press the pages hold. Escape cancels a press, in `cancel_on_escape`: drop a gesture with \ + `DocView::cancel_pointer_gestures` instead.", + "no longer ignores a press: remove it from PRESS_IGNORERS.", )); let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for (file, function) in [ @@ -425,6 +504,25 @@ fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); } } + // A hit test is a function named `hit` or `…_hit`: one answer to "what of mine is here?", + // which gives the module's cursor, its tip and its press. + problems.extend(misuses_in( + page_module_matches(hit_test_calls), + &HIT_TESTERS, + "calls a hit test. The hits at the press point decide its owner once, in `PageHits::at`: act on the press when \ + `view.router.owner(page)` says it is yours, rather than testing its point again. Testing where the pointer \ + is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ + src/canvas/page_pointer/tests.rs, with why.", + "calls no hit test any more: remove it from HIT_TESTERS.", + )); + let builds = page_module_uses(&HIT_BUILDS); + assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); + problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { + format!( + "src/{file}:{n}: `{name}` makes a hit outside a hit test. A page module answers \"what of mine is \ + here?\" in one function, named `hit` or `…_hit`, so its cursor, its tip and its press agree." + ) + })); assert!(problems.is_empty(), "{}", problems.join("\n")); } From 71d222cacf118eeb343c608d93668f618bff8fbb Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 11:01:16 +0100 Subject: [PATCH 03/49] refactor(ui): move page input out of canvas.rs into canvas/page_input.rs What the tools do with the pointer on one page now lives in its own file: `PageInput`, `PageToolContext` and `page_tool_input`; `PageHits`, `edit_inventory`, `link_hit` and `cancel_on_escape`; the text layer, `text_layer` to `finish_text_pointer_gesture`; and `page_overlays`, which follows links and picks images. `cached_edit_images` stays in canvas.rs, where painting and the menus use it too. No change in behaviour. canvas.rs goes from 4,073 to 3,265 lines. The guard's page modules gain `canvas/page_input.rs`, and its allowlist entries move with the functions. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 810 +---------------- crates/ui-egui/src/canvas/page_input.rs | 815 ++++++++++++++++++ .../ui-egui/src/canvas/page_pointer/tests.rs | 12 +- 3 files changed, 826 insertions(+), 811 deletions(-) create mode 100644 crates/ui-egui/src/canvas/page_input.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b281d044a5..289edf811d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; -use crate::page_cursor::{Layer, Subject}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -28,6 +28,7 @@ pub(super) mod image_pick; mod navigation; mod organize; pub(super) mod page_cursor; +mod page_input; pub(super) mod page_pointer; pub(super) mod page_router; pub mod scroll_speed; @@ -63,6 +64,8 @@ pub(crate) use organize::grid_page_rect; use organize::organize_grid; #[cfg(test)] use organize::sharp_enough; +pub(crate) use page_input::PageToolContext; +use page_input::{PageInput, cancel_on_escape, page_overlays, page_tool_input, text_layer}; use selection::{Selection, Span, click_unit, selection_actions}; pub use xform::PageXform; use xform::snap_to_pixels; @@ -1444,308 +1447,6 @@ fn paint_page_tiles( } } -/// 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, - /// Redact or Highlight draws boxes off text. - area_tool: bool, -} - -/// Common geometry and pointer state for a tool acting on one page. -#[derive(Clone, Copy)] -pub(crate) struct PageToolContext<'a> { - pub ui: &'a egui::Ui, - pub resp: &'a egui::Response, - pub xf: &'a PageXform, - pub ptr: &'a PagePointer, - pub page: usize, - pub doc: &'a pdfcraft_engine::Document, - pub info: &'a DocInfo, -} - -/// 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, - fill_text_color, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - let (lines, images) = - if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; - 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 prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let hits = PageHits { - page: i, - xf: &xf, - info, - tool, - pcx: &pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines: &lines, - images: &images, - clip: ui.clip_rect(), - }; - if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); - view.router.press(i, at, tool); - } - 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) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // 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; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // 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( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - 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(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } - let on_field = if preparing { - let o = crate::prepare::page_input(cx, form, field_tool, prepare_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() - } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) - }; - // 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(cx, over_text, view) - }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - // Add text acts on the page's own paragraphs over them, and on the one its press was on until - // the button is up: a drag that leaves the box still ends, and no new box takes it over. - let over_source = tool == QuickTool::AddText - && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) - || pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, 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( - cx, - 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 && { - // 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(cx, &lines, view, !others_own_pointer); - let image_cx = crate::edit_text_ui::ImageCx { - ui, - resp, - xf: &xf, - ptr, - page: i, - info, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; - let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); - !others_own_pointer && (text_used || image_used) - }; - if tool == QuickTool::EditText { - if others_own_pointer { - if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { - view.image_selection = None; - } - } else { - crate::content_ui::sync_image_selection(view, i, &images, added); - } - } - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, 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); - // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the - // last claim on a layer stands). - if frame.env.unobstructed - && let Some(p) = pointer - { - for h in hits.at(view, p) { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip); - } - } - PageInput { consumed, area_tool } -} - -/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. -fn edit_inventory( - view: &mut DocView, - doc: &pdfcraft_engine::Document, - i: usize, -) -> (Vec, Vec) { - let generation = doc.edit_generation(); - let lines = 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 - } - }; - let images = cached_edit_images(view, doc, i).to_vec(); - (lines, images) -} - /// Images parsed once per page and document edit generation, shared by painting and menus. fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] { let generation = doc.edit_generation(); @@ -1756,472 +1457,6 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document &entry.1 } -/// What the page modules have under a point on one page, for the page router. -struct PageHits<'a> { - page: usize, - xf: &'a PageXform, - info: &'a DocInfo, - tool: QuickTool, - pcx: &'a comments::PageCx<'a>, - form: &'a [pdfcraft_engine::FormField], - can_fill: bool, - can_modify: bool, - /// Edit a PDF is open: added text and pictures can be picked up. - editing_content: bool, - preparing: bool, - field_tool: Option, - /// Prepare may draw and move fields (not while a table is being set up). - prepare_allowed: bool, - added: &'a [pdfcraft_engine::Added], - lines: &'a [pdfcraft_engine::TextBlock], - images: &'a [pdfcraft_engine::PageImage], - /// The pages' visible area (the picture's rotate knob stays inside it). - clip: Rect, -} - -impl PageHits<'_> { - /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text - /// on top of artwork takes the press), added content, the objects selected together and the - /// open paragraph's frame, the selected picture's grips over them all; then form fields - /// (Prepare's, being moved, over the comments; filled ones under added content and the - /// comments that move). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - let PageHits { - page, - xf, - info, - tool, - pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines, - images, - clip, - } = *self; - // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). - if tool == QuickTool::EditText - && can_modify - && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) - { - return vec![crop]; - } - let fields = if preparing { - crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) - } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, can_fill, p) - } else { - None - }; - // With Add text, only the paragraphs and pictures not under added content. - let editing = - can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); - let (pictures, paragraphs, objects, grips) = if editing { - let open = view.line_editor.as_ref().filter(|e| e.page == page); - let frame = open - .filter(|e| !e.has_changes()) - .and_then(|e| lines.get(e.block)) - .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); - // Add text acts on the page's paragraphs only in their boxes, edges included (it - // finishes the box being typed there, `over_source`): not on pictures, nor beside a - // paragraph, where a click starts a new box. - let edit_text = tool == QuickTool::EditText; - let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); - ( - edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), - on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), - edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), - edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), - ) - } else { - (None, None, None, None) - }; - // Added text and pictures, over the page's own paragraphs and under the objects selected - // together. - let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); - let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() - } -} - -/// 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 }); - } - } -} - -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); - } - } - } - 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); - } - } -} - -struct TextPointer<'a> { - page: usize, - xf: PageXform, - rect: Rect, - info: &'a DocInfo, - tool: QuickTool, - resp: &'a egui::Response, - ptr: PagePointer, - pointer: Pos2, -} - -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 } = 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(view, &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(view: &mut DocView, gesture: &TextGesture<'_>) { - // A press on a link selects nothing (it is the link's), so no cursor here says it would. - if gesture.column && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); - } else if gesture.over_text && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Text); - } -} - -fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on - // text markup lets its drag select the text underneath. - let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { - 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 && !view.router.keeps_drag() { - 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)) - }; - } - } -} - -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; - } - } - } - 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; - } -} - -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 { - 0 - }; - 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 && !view.router.keeps_drag() { - 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, - } - } -} - -/// 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 tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - 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); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { - 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(tool_cx, form, view); - 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(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { - frame.calc_hover = Some(field); - } - } - } 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); - // Links: the hand and the link's target, which count only for the tools that follow or - // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is - // open, even beside it. A click follows the link its press was on, released over it: one that - // slips off the link first follows nothing, as a button's does. - if unobstructed - && resp.clicked() - && tool == QuickTool::Select - && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) - { - frame.out.clicked_link = Some(l.target.clone()); - } - // Comments say what they are with any tool, beside its cursor, as Acrobat's do. - comments::describe(&pcx, view); - 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); - // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with - // any tool; a link's or a comment's tip wins over any badge. - if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { - view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); - } - } -} - -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; @@ -2283,43 +1518,6 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i page_overlays(ui, view, frame, &slot, &input); } -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - 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)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - -/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is -/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The -/// frame of the release still belongs to the cancelled press. -fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { - // Over once the button is up and its release seen, or when a new press comes (its release and - // the next press can share a frame). - let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); - if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { - view.press_cancelled = false; - } - if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - // A text selection being dragged goes with its drag: the markup, redaction or Replace - // Text that its end would make is not made. - if view.selection_drag { - view.clear_selection(); - } - view.cancel_pointer_gestures(); - view.press_cancelled = true; - } -} - #[allow(clippy::cognitive_complexity)] fn draw_pages( ui: &mut egui::Ui, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs new file mode 100644 index 0000000000..32500d7154 --- /dev/null +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -0,0 +1,815 @@ +//! Page input: what the tools do with the pointer on one page, what lies under it for the page +//! router, the text layer, and what is drawn over the page after its text. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; +use pdfcraft_render::{DocInfo, LinkTarget, PageText}; + +use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; +use crate::page_cursor::{Layer, Subject}; +use crate::page_pointer::PagePointer; +use crate::{QuickTool, comments, icons}; + +/// What the page's tools did with the pointer this frame. +pub(super) struct PageInput { + /// A tool, a field or a comment took the pointer: text selection must leave it alone. + pub(super) consumed: bool, + /// Redact or Highlight draws boxes off text. + pub(super) area_tool: bool, +} + +/// Common geometry and pointer state for a tool acting on one page. +#[derive(Clone, Copy)] +pub(crate) struct PageToolContext<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub doc: &'a pdfcraft_engine::Document, + pub info: &'a DocInfo, +} + +/// 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. +pub(super) 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, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let (lines, images) = + if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + 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 prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let hits = PageHits { + page: i, + xf: &xf, + info, + tool, + pcx: &pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines: &lines, + images: &images, + clip: ui.clip_rect(), + }; + if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { + let at = hits.at(view, o); + view.router.press(i, at, tool); + } + 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) + { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { + // 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; + } + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && let Some(p) = ptr.within(xf.rect) + { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { + // 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( + cx, + crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }, + view, + ) { + 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(resp, &xf, ptr, i, view, can_modify); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) + { + view.current = i; + frame.out.open_boxes = true; + } + let on_field = if preparing { + let o = crate::prepare::page_input(cx, form, field_tool, prepare_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() + } else { + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) + }; + // 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(cx, over_text, view) + }; + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + // Add text acts on the page's own paragraphs over them, and on the one its press was on until + // the button is up: a drag that leaves the box still ends, and no new box takes it over. + let over_source = tool == QuickTool::AddText + && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) + || pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, 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( + cx, + 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 && { + // 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(cx, &lines, view, !others_own_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); + !others_own_pointer && (text_used || image_used) + }; + if tool == QuickTool::EditText { + if others_own_pointer { + if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { + view.image_selection = None; + } + } else { + crate::content_ui::sync_image_selection(view, i, &images, added); + } + } + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, 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); + // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the + // last claim on a layer stands). + if frame.env.unobstructed + && let Some(p) = pointer + { + for h in hits.at(view, p) { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip); + } + } + PageInput { consumed, area_tool } +} + +/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. +fn edit_inventory( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + i: usize, +) -> (Vec, Vec) { + let generation = doc.edit_generation(); + let lines = 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 + } + }; + let images = cached_edit_images(view, doc, i).to_vec(); + (lines, images) +} + +/// What the page modules have under a point on one page, for the page router. +struct PageHits<'a> { + page: usize, + xf: &'a PageXform, + info: &'a DocInfo, + tool: QuickTool, + pcx: &'a comments::PageCx<'a>, + form: &'a [pdfcraft_engine::FormField], + can_fill: bool, + can_modify: bool, + /// Edit a PDF is open: added text and pictures can be picked up. + editing_content: bool, + preparing: bool, + field_tool: Option, + /// Prepare may draw and move fields (not while a table is being set up). + prepare_allowed: bool, + added: &'a [pdfcraft_engine::Added], + lines: &'a [pdfcraft_engine::TextBlock], + images: &'a [pdfcraft_engine::PageImage], + /// The pages' visible area (the picture's rotate knob stays inside it). + clip: Rect, +} + +impl PageHits<'_> { + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text + /// on top of artwork takes the press), added content, the objects selected together and the + /// open paragraph's frame, the selected picture's grips over them all; then form fields + /// (Prepare's, being moved, over the comments; filled ones under added content and the + /// comments that move). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + let PageHits { + page, + xf, + info, + tool, + pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines, + images, + clip, + } = *self; + // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). + if tool == QuickTool::EditText + && can_modify + && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) + { + return vec![crop]; + } + let fields = if preparing { + crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) + } else if tool == QuickTool::Select { + crate::forms_ui::hit(xf, info, page, form, can_fill, p) + } else { + None + }; + // With Add text, only the paragraphs and pictures not under added content. + let editing = + can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); + let (pictures, paragraphs, objects, grips) = if editing { + let open = view.line_editor.as_ref().filter(|e| e.page == page); + let frame = open + .filter(|e| !e.has_changes()) + .and_then(|e| lines.get(e.block)) + .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); + // Add text acts on the page's paragraphs only in their boxes, edges included (it + // finishes the box being typed there, `over_source`): not on pictures, nor beside a + // paragraph, where a click starts a new box. + let edit_text = tool == QuickTool::EditText; + let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); + ( + edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), + on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), + edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), + edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), + ) + } else { + (None, None, None, None) + }; + // Added text and pictures, over the page's own paragraphs and under the objects selected + // together. + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] + .into_iter() + .flatten() + .collect() + } +} + +/// Text layer: find matches, selection, I-beam and drag-to-select. +pub(super) 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 }); + } + } +} + +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); + } + } + } + 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); + } + } +} + +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, +} + +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 } = 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(view, &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(view: &mut DocView, gesture: &TextGesture<'_>) { + // A press on a link selects nothing (it is the link's), so no cursor here says it would. + if gesture.column && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); + } else if gesture.over_text && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on + // text markup lets its drag select the text underneath. + let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); + if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { + 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 && !view.router.keeps_drag() { + 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)) + }; + } + } +} + +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; + } + } + } + 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; + } +} + +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 { + 0 + }; + 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 && !view.router.keeps_drag() { + 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, + } + } +} + +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +pub(super) 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 tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + 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); + // A cancelled press ends no selection: no markup or redaction is made from it. + if !view.press_cancelled { + 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(tool_cx, form, view); + 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(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { + frame.calc_hover = Some(field); + } + } + } 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); + // Links: the hand and the link's target, which count only for the tools that follow or + // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is + // open, even beside it. A click follows the link its press was on, released over it: one that + // slips off the link first follows nothing, as a button's does. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.get(index) + { + frame.out.clicked_link = Some(l.target.clone()); + } + // Comments say what they are with any tool, beside its cursor, as Acrobat's do. + comments::describe(&pcx, view); + 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); + // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with + // any tool; a link's or a comment's tip wins over any badge. + if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { + view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); + } + } +} + +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); + } + } + } +} + +/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows +/// links and the Link tool edits them (`Layer::reached_by`). +fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + 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)]), + }; + let owner = crate::page_router::Owner::Link { index }; + Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is +/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The +/// frame of the release still belongs to the cancelled press. +pub(super) fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { + // Over once the button is up and its release seen, or when a new press comes (its release and + // the next press can share a frame). + let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); + if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { + view.press_cancelled = false; + } + if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + // A text selection being dragged goes with its drag: the markup, redaction or Replace + // Text that its end would make is not made. + if view.selection_drag { + view.clear_selection(); + } + view.cancel_pointer_gestures(); + view.press_cancelled = true; + } +} diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index e92d4c01df..248b627298 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,8 +150,9 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 35] = [ +const PAGE_MODULES: [&str; 36] = [ "canvas.rs", + "canvas/page_input.rs", "canvas/area.rs", "canvas/bars.rs", "canvas/cache.rs", @@ -387,7 +388,7 @@ const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.can /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ - ("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"), ]; @@ -416,8 +417,8 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. const HIT_TESTERS: [(&str, &str, &str); 4] = [ - ("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), - ("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"), + ("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), ]; @@ -426,7 +427,8 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [ const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; /// The one function that ignores a press until the button is up: (file, function, why). -const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; +const PRESS_IGNORERS: [(&str, &str, &str); 1] = + [("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). From 2b256ab0c79eff8c97b54efdf143685faab4b65c Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:31:01 +0100 Subject: [PATCH 04/49] refactor(ui): one link test for the Select tool, the Link tool and right-clicks Three link tests disagreed. The Select tool's hand and click (`link_hit`) took the topmost link the renderer lists, with no margin, by its place in that list. The Link tool's selection and Select's right-click took the bottom one of every link the engine lists, 2 px around each, by `/Annots` index. Comments had a fourth (`on_link`). Now `link_ui::hit` is the one test, and `Owner::Link` carries the `/Annots` index: - the Link tool finds every link the engine lists, 2 px around each, and takes the topmost; a drag from a link still draws a new one (the hit declines the drag); - every other tool finds the links the renderer can follow, with no margin (so Select shows no hand over a link with no action, as before), and takes the topmost; - following a link looks its target up by that index; - `comments::hit` is told whether a link is under the point. The Link tool is told the link its press is on, and the one a right-click is on, instead of testing the pointer itself. A right-click goes to the top hit under the pointer, so with the Select tool a right-click on a note over a link selects the note and opens its menu, and one just beside a link no longer opens the link's. Behaviour changes (the user's rulings, 2026-10-31): the Link tool takes the topmost of stacked links; Select's right-click has no margin; a right-click on a note over a link is the note's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 54 ++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 3 +- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 15 +- crates/ui-egui/src/tools/link_ui.rs | 66 ++++++--- crates/ui-egui/tests/it/pointer.rs | 134 +++++++++++++++++- 6 files changed, 215 insertions(+), 59 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 32500d7154..5ca60f8684 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,11 +2,12 @@ //! router, the text layer, and what is drawn over the page after its text. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; -use pdfcraft_render::{DocInfo, LinkTarget, PageText}; +use pdfcraft_render::{DocInfo, PageText}; use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; +use crate::page_router::{Owner, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -88,6 +89,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut field_tool, prepare_allowed, added, + doc_links, lines: &lines, images: &images, clip: ui.clip_rect(), @@ -96,6 +98,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } + // A right-click goes to what a press there would go to: the top hit under the pointer. + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -270,14 +274,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut crate::content_ui::sync_image_selection(view, i, &images, added); } } - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); + let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, 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)) + && let Some(index) = link_context { - view.links.context = Some((i, l.index)); + view.links.context = Some((i, index)); } let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the @@ -292,6 +295,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageInput { consumed, area_tool } } +/// The link `owner` is, by its `/Annots` index. +fn link_of(owner: Option) -> Option { + match owner { + Some(Owner::Link { index }) => Some(index), + _ => None, + } +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView, @@ -328,6 +339,8 @@ struct PageHits<'a> { /// Prepare may draw and move fields (not while a table is being set up). prepare_allowed: bool, added: &'a [pdfcraft_engine::Added], + /// The document's links, as the engine lists them (the Link tool edits every one). + doc_links: &'a [pdfcraft_engine::LinkItem], lines: &'a [pdfcraft_engine::TextBlock], images: &'a [pdfcraft_engine::PageImage], /// The pages' visible area (the picture's rotate knob stays inside it). @@ -355,6 +368,7 @@ impl PageHits<'_> { field_tool, prepare_allowed, added, + doc_links, lines, images, clip, @@ -400,10 +414,9 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() + let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); + let comment = comments::hit(pcx, view, p, link.is_some()); + [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() } } @@ -694,9 +707,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) + && let Some(owner @ Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); } @@ -777,21 +790,6 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf } } -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - 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)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - /// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is /// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The /// frame of the release still belongs to the cancelled press. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 248b627298..4f9398be85 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -432,7 +432,8 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 9] = [ +const PAGE_HIT_CALLS: [&str; 10] = [ + "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 8b91cc865c..a9f7500189 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -18,7 +18,7 @@ use crate::page_cursor::Layer; pub(crate) enum Owner { /// A comment (or a Fill & Sign mark), by its `/Annots` index, and the part of it. Comment { index: usize, part: CommentPart }, - /// A link, by its index in the document's links. + /// A link, by its index in its page's `/Annots`. Link { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a7ef6c92e1..527df65d01 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -54,8 +54,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// only, as Acrobat picks them up: a press anywhere else places the tool's mark. With a shape /// tool (rectangle, oval, line, polygon…), only the selected comment's handles, so the shape just /// drawn can be resized straight away (#2338); a press anywhere else, the shape itself included, -/// draws. `None` with other tools, off the page, or with comments hidden. -pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { +/// draws. `None` with other tools, off the page, or with comments hidden. `on_link`: a link lies +/// under `p` ([`crate::link_ui::hit`]). +pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } @@ -93,7 +94,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { } let a = cx.hit(p).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. - if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link(cx, p) { + if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; } let markup = is_markup(&a.subtype); @@ -116,7 +117,8 @@ fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { if cx.ptr.any_down { return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); } - cx.ptr.pos.and_then(|p| hit(cx, view, p)).is_some_and(|h| !h.declines_drag) + // Only the Select tool lets a link through a highlight, and it doesn't come here. + cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) } /// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select @@ -130,11 +132,6 @@ fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) } -/// Whether `p` is on one of this page's links. -fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { - cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index a0840e1b89..699383cbfe 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -4,14 +4,17 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; -use pdfcraft_render::DocInfo; +use pdfcraft_render::{DocInfo, LinkTarget}; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner}; use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, widgets}; +use crate::{PdfCraftApp, QuickTool, widgets}; const LINK_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// How far around a link (screen points) the Link tool finds it, so a thin one can be picked. +const LINK_TOOL_MARGIN: f32 = 2.0; #[derive(Clone, Debug, Default, PartialEq)] pub struct LinkView { @@ -56,8 +59,38 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { [u[0] as f64, u[1] as f64] } -/// Link tool input on one page. Returns `true` when the gesture belongs to the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut DocView) -> bool { +/// The link under `p` on `page`: the one hit test of links, for the hand, the tip and the press. +/// Of stacked links, the topmost (the last in `/Annots`, drawn on top). Only the Select tool +/// follows links and the Link tool edits them (`Layer::reached_by`). The Link tool finds every link +/// the document has (`links`, the engine's), with a margin around each so a thin one can be +/// picked, and a drag from one draws a new link. The other tools find the links the renderer can +/// follow, which have a target (`info.links`). The tip is the link's target. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem], tool: QuickTool, p: Pos2) -> Option { + let index = if tool == QuickTool::Link { + links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + } else { + info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + }; + let tip = info.links.iter().find(|l| l.page == page && l.index == index).map(|l| target_label(info, &l.target)); + let declines_drag = tool == QuickTool::Link; + Some(Hit { tip, declines_drag, ..Hit::new(Owner::Link { index }, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// What a link's tip says it goes to. +fn target_label(info: &DocInfo, target: &LinkTarget) -> String { + match 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)]), + } +} + +/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` +/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to +/// the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; let lv = &mut view.links; @@ -76,26 +109,21 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); - match hit { - Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand), - None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair), - } - if resp.secondary_clicked() - && let Some(l) = hit - { - lv.selected = Some((page, l.index)); - lv.context = Some((page, l.index)); + // Over a link, its hit claims the hand. + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if let Some(index) = context { + lv.selected = Some((page, index)); + lv.context = Some((page, index)); return true; } if resp.clicked() || resp.double_clicked() { let now = ui.input(|i| i.time); // A second click on the same link soon after the first is a double-click. - let double = resp.double_clicked() || hit.is_some_and(|l| lv.last_click.is_some_and(|(t, p, k)| p == page && k == l.index && now - t < 0.5)); - lv.last_click = hit.map(|l| (now, page, l.index)); - lv.selected = hit.map(|l| (page, l.index)); - if double && let Some(l) = hit { - lv.open_existing = Some((page, l.index)); + let double = resp.double_clicked() || owner.is_some_and(|k| lv.last_click.is_some_and(|(t, p, l)| p == page && l == k && now - t < 0.5)); + lv.last_click = owner.map(|k| (now, page, k)); + lv.selected = owner.map(|k| (page, k)); + if double && let Some(k) = owner { + lv.open_existing = Some((page, k)); lv.last_click = None; } return true; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 889caf4c1c..1ee9676ad2 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -11,7 +11,7 @@ use egui_kittest::kittest::Queryable; use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; use pdfcraft_ui_egui::fill_sign::FillTool; -use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, UnlockPrompt}; use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; @@ -957,3 +957,135 @@ fn escape_cancels_a_crop_rectangle() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "no page box was set"); } + +// --------------------------------------------------------------------------------------------- +// One link test: what the Select tool follows, the Link tool edits and a right-click opens is +// the same link. + +/// A 300×400 page with two web links overlapping at x 150..200, y 300..350: `first` first in +/// `/Annots`, then `second` drawn over it. +fn stacked_links_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 200 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [150 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/second) >> >>", + ]) +} + +/// A 300×400 page with a link that does nothing (no action) at x 10..40, y 300..350, then a web +/// link at x 50..250 beside it: second in `/Annots`, and the first the renderer can follow. +fn dead_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 300 40 350] /Border [0 0 0] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// [`link_pdf`], with a rectangle comment drawn over the middle of its link. +fn note_over_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Square /Rect [100 310 200 340] /C [1 0 0] /F 4 /T (Ana) /Contents (A note) >>", + ]) +} + +fn link_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Link; + h.run_steps(4); +} + +/// A right-click at `p`: press and release in frames of their own. +fn right_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); +} + +fn pending_url(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().pending_link.as_ref().map(|l| l.url.clone()) +} + +/// Of two stacked links the Link tool selects the one drawn on top, the one the Select tool +/// follows. It used to select the one underneath. +#[test] +fn the_link_tool_selects_the_topmost_of_stacked_links() { + let mut h = open(stacked_links_pdf(), "150", false); + let both = at(&h, 175.0, 325.0); + mouse_click(&mut h, both); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/second"), "the Select tool follows the link on top"); + h.state_mut().pending_link = None; + link_tool(&mut h); + mouse_click(&mut h, both); + assert_eq!(h.state().views[0].links.selected, Some((0, 1)), "the Link tool selects it too"); +} + +/// The Link tool edits every link, one that does nothing included. The Select tool, which +/// follows links, shows no hand over it, and follows the link beside it by its own `/Annots` +/// place, not by its place among the links the renderer can follow. +#[test] +fn the_link_tool_selects_a_link_with_no_action() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + assert_ne!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand, "the Select tool has nothing to follow there"); + let web = at(&h, 150.0, 325.0); + mouse_click(&mut h, web); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the web link is followed"); + h.state_mut().pending_link = None; + link_tool(&mut h); + assert_eq!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand); + mouse_click(&mut h, dead); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the Link tool selects the link that does nothing"); +} + +/// With the Link tool a drag that starts on a link draws a new link: the press is the link's, its +/// drag is not. +#[test] +fn a_drag_with_the_link_tool_from_a_link_draws_a_new_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_drag(&mut h, on_link, egui::vec2(80.0, 120.0)); + let s = h.state(); + assert_eq!(s.dialog, Some(Dialog::LinkProps)); + assert_eq!(s.link_draft.as_ref().map(|d| d.index), Some(None), "Link Properties opens for a new link"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: just beside the +/// link it opens the page's menu. The Link tool's margin around links used to reach there. +#[test] +fn a_right_click_beside_a_link_with_the_select_tool_opens_no_link_menu() { + let mut h = open(link_pdf(), "150", false); + // The link ends at x 250. + let beside = at(&h, 250.0, 325.0) + egui::vec2(1.5, 0.0); + right_click(&mut h, beside); + assert_eq!(h.state().views[0].links.context, None, "no link under the right-click"); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let on_link = at(&h, 245.0, 325.0); + right_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.context, Some((0, 0)), "on the link, its menu"); +} + +/// A right-click goes to what a press there would go to: on a note over a link, the note. It used +/// to open the link's menu. +#[test] +fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + right_click(&mut h, on_note); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link"); + assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's"); +} From bab615fed13e2204cbb6baf3119f5f8d0c2ffc37 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:15:32 +0100 Subject: [PATCH 05/49] refactor(ui): route right-clicks by what lies on top under the pointer Each right-click handler tested the pointer itself, and the chain's order decided which ran: comments only when no earlier module took the frame, Prepare by its own widget scan. A right-click now goes to what lies on top under the pointer (`PageHits::context_at`), by the layer ranking a press uses, with two differences. Comments count with every tool, as a comment's menu opens whatever tool is active (`comments::context_hit`). A highlight over a link is the highlight's: a left click there follows the link, a right-click opens the highlight's menu. On every right-click the page's menu is anchored at the pointer, and the comment it is on is selected, or none (`comments::context`); a Prepare field is selected (`prepare::context`); links take theirs from the same answer. Edit text's own paragraphs and pictures, and a Prepare field tool, keep their right-clicks as before. Behaviour change (the user's ruling, 2026-10-31): a right-click on a highlight over a link opens the highlight's menu, not the link's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 41 +++++++++++++++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 6 ++- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 25 ++++++++--- crates/ui-egui/src/tools/prepare.rs | 36 ++++++++-------- crates/ui-egui/tests/it/pointer.rs | 40 ++++++++++++++++++ 6 files changed, 120 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5ca60f8684..5d6dcb8054 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -98,8 +98,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } - // A right-click goes to what a press there would go to: the top hit under the pointer. - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -303,6 +302,27 @@ fn link_of(owner: Option) -> Option { } } +/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the +/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is +/// selected; links take theirs from what this returns. +fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { + let context = hits.context_at(view, p); + // Edit text's own paragraphs and pictures keep the right-click as they take it today, and a + // Prepare field tool the whole page. + let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some(); + if !editing_here && hits.field_tool.is_none() { + let comment = match context { + Some(Owner::Comment { index, .. }) => Some(index), + _ => None, + }; + comments::context(hits.pcx, p, comment, view); + } + if let Some(Owner::PrepareField { field, widget }) = context { + crate::prepare::context(hits.form, field, widget, view); + } + context +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView, @@ -348,12 +368,25 @@ struct PageHits<'a> { } impl PageHits<'_> { + /// The hits at `p`, bottom up ([`Self::hits`]). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + self.hits(view, p, false) + } + + /// What a right-click at `p` is on: what lies on top there, as for a press, but with every + /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight + /// over a link is the highlight's ([`comments::context_hit`]). + fn context_at(&self, view: &DocView, p: Pos2) -> Option { + top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + } + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the /// open paragraph's frame, the selected picture's grips over them all; then form fields /// (Prepare's, being moved, over the comments; filled ones under added content and the /// comments that move). - fn at(&self, view: &DocView, p: Pos2) -> Vec { + /// For a right-click (`context`), comments by [`comments::context_hit`]. + fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { page, xf, @@ -415,7 +448,7 @@ impl PageHits<'_> { let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); - let comment = comments::hit(pcx, view, p, link.is_some()); + let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4f9398be85..f9fc72bbcd 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -417,7 +417,11 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. const HIT_TESTERS: [(&str, &str, &str); 4] = [ - ("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ( + "canvas/page_input.rs", + "hits", + "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", + ), ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index cb8bafe642..d0c46d0453 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{context, context_hit, describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 527df65d01..a12d971f5c 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -27,12 +27,6 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, if cv.cancelled_drag { return true; } - if resp.secondary_clicked() - && let Some(p) = pointer.filter(|p| page_rect.contains(*p)) - { - 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 { QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin), @@ -106,6 +100,25 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) } +/// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows +/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it +/// follows the link, [`hit`]). `None` off the page or with comments hidden. +pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { + if cx.hidden || !cx.xf.rect.contains(p) { + return None; + } + let a = cx.hit(p)?; + let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) +} + +/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the +/// comment it is on ([`context_hit`], when it wins there) is selected, or none. +pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { + view.comments.context_at = Some((cx.page, cx.to_user(p))); + view.comments.selected = index.map(|i| (cx.page, i)); +} + /// Whether the comments take the press on this page (or, with no button down, would take one /// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press /// held by a comment that keeps its drag. diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index cc4b81441e..78568f46db 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -398,24 +398,6 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti return out; } - // Right-click a field: select it (the context menu acts on it). - if resp.secondary_clicked() - && let Some((f, wi)) = form.iter().find_map(|f| { - f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) - }) - { - let key = (f.name.clone(), wi); - if let Some(i) = prep.also.iter().position(|k| *k == key) { - // Part of the selection: it becomes the anchor the others line up with. - prep.also.remove(i); - if let Some(old) = prep.selected.replace(key) { - prep.also.push(old); - } - } else if prep.selected.as_ref() != Some(&key) { - prep.selected = Some(key); - prep.also.clear(); - } - } // The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a // drag draws a selection rectangle. A press held by something drawn over the fields (a // comment) is not Prepare's. @@ -469,6 +451,24 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out } +/// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is +/// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the +/// others line up with. +pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) { + let Some(f) = form.get(field) else { return }; + let key = (f.name.clone(), widget); + let prep = &mut view.prepare; + if let Some(i) = prep.also.iter().position(|k| *k == key) { + prep.also.remove(i); + if let Some(old) = prep.selected.replace(key) { + prep.also.push(old); + } + } else if prep.selected.as_ref() != Some(&key) { + prep.selected = Some(key); + prep.also.clear(); + } +} + /// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields /// selected). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1ee9676ad2..c868dc8079 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1089,3 +1089,43 @@ fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link"); assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's"); } + +/// [`link_pdf`], with a highlight drawn over the middle of its link. +fn highlight_over_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>", + ]) +} + +/// A click on a highlight over a link follows the link, but a right-click there is the +/// highlight's: it is selected and its menu opens. The link's menu used to open, with the +/// highlight selected underneath. +#[test] +fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() { + let mut h = open(highlight_over_link_pdf(), "150", false); + let on_both = at(&h, 150.0, 325.0); + right_click(&mut h, on_both); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link"); + assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + mouse_click(&mut h, on_both); + assert!(h.state().pending_link.is_some(), "a click there still follows the link"); +} + +/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's: +/// with the Rectangle tool, a right-click on a comment selects it. +#[test] +fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let on_comment = at(&h, 100.0, 305.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); +} From 1780f33350b1b7dc521ecc25bea3fba4e1d02509 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:23:07 +0100 Subject: [PATCH 06/49] refactor(ui): tell each page whether the press is its own Each module asked the router `owner(page).is_none()` to learn whether a press was the active tool's. That reads a press held on another page, or begun between the pages, as the tool's too. The router now remembers the page a press began on (`begin` on every new press, then `press` on the page it lands on), and `press_for(page)` answers what a page has to do with it: - `Mine(owner)`: the press began on this page and the owner's hit won it; - `Free`: no button is down, or the press began on this page and no hit won it, so it is the active tool's; - `Elsewhere`: the press began on another page, or between the pages. The page view asks once per page, and the stamps and the Link tool use the answer. The other modules move to it in the next commits. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/page_input.rs | 10 +-- .../ui-egui/src/canvas/page_pointer/tests.rs | 2 +- crates/ui-egui/src/canvas/page_router.rs | 68 ++++++++++++++++++- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 289edf811d..428f71c186 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1604,9 +1604,9 @@ fn draw_pages( cancel_on_escape(ui.ctx(), view, &ptr); view.router.settle(ptr.any_down, !ptr.edges.is_empty()); // A new press is no longer the last one's, wherever it lands (the page it is on gives it - // its owner; in the gap between pages it has none). + // its owner; in the gap between pages it is no page's). if ptr.pressed().is_some() { - view.router.cancel(); + view.router.begin(); // The page widget is not focusable. Explicitly return keyboard ownership from Pages // when the user presses the document, without stealing focus through a modal. if let Some(id) = view.pages_focus_id { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5d6dcb8054..00edfe54cc 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -7,7 +7,7 @@ use pdfcraft_render::{DocInfo, PageText}; use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; -use crate::page_router::{Owner, top}; +use crate::page_router::{Owner, PagePress, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -98,6 +98,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } + // What this page has to do with the press: its owner's, the active tool's, or another page's. + let press = view.router.press_for(i); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); @@ -111,7 +113,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { + if resp.clicked() && press == PagePress::Free { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); let (w, h) = kind.size(); @@ -136,7 +138,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { + if resp.clicked() && press == PagePress::Free { // 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); @@ -273,7 +275,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut crate::content_ui::sync_image_selection(view, i, &images, added); } } - let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + let (link_owner, link_context) = (link_of(press.owner()), link_of(context)); let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); if tool == QuickTool::Select && can_modify diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f9fc72bbcd..2e79b7481e 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -384,7 +384,7 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.cancel("]; +const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.settle(", "router.cancel("]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index a9f7500189..f1aa199c67 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -6,7 +6,8 @@ //! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer //! the active tool reaches. Until the button is up only the owner acts on the press, its drag and //! its release, so what the cursor promised is what the press does, and nothing takes a gesture -//! back halfway. +//! back halfway. Each page is told what it has to do with the press ([`PagePress`]): its owner's, +//! the active tool's, or none of its business. use egui::CursorIcon; @@ -106,6 +107,28 @@ pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Optio }) } +/// What one page has to do with the press the pages hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PagePress { + /// The press began on this page, and the owner's hit won it: only its module acts on it. + Mine(Owner), + /// No button is down, or the press began on this page and nobody's hit won it: the active + /// tool's. + Free, + /// The press began on another page, or between the pages. + Elsewhere, +} + +impl PagePress { + /// Who holds the press on this page, if anyone. + pub(crate) fn owner(self) -> Option { + match self { + PagePress::Mine(owner) => Some(owner), + PagePress::Free | PagePress::Elsewhere => None, + } + } +} + /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Capture { @@ -118,13 +141,33 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, + /// A press is held, and the page it began on (`None`: between the pages). + began: Option>, } impl PageRouter { + /// A new press on the pages: the last one's owner goes, and until a page claims it the press is + /// between the pages. + pub(crate) fn begin(&mut self) { + self.capture = None; + self.began = Some(None); + } + /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + self.began = Some(Some(page)); + } + + /// What `page` has to do with the press the pages hold. + pub(crate) fn press_for(&self, page: usize) -> PagePress { + match (self.began, &self.capture) { + (None, _) => PagePress::Free, + (Some(Some(p)), Some(c)) if p == page => PagePress::Mine(c.owner), + (Some(Some(p)), None) if p == page => PagePress::Free, + _ => PagePress::Elsewhere, + } } /// Who holds the press on `page`. @@ -142,12 +185,14 @@ impl PageRouter { pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { if !any_down && !edges { self.capture = None; + self.began = None; } } /// Drops the press (Escape, a dialog, automation's cancel). pub(crate) fn cancel(&mut self) { self.capture = None; + self.began = None; } } @@ -185,6 +230,27 @@ mod tests { assert!(!r.keeps_drag()); } + /// A page is told whether the press is its owner's, the active tool's, or another page's. + #[test] + fn each_page_is_told_whether_the_press_is_its_own() { + let mut r = PageRouter::default(); + assert_eq!(r.press_for(3), PagePress::Free, "no button down: every page is the tool's"); + r.begin(); + assert_eq!(r.press_for(0), PagePress::Elsewhere, "a press between the pages is no page's"); + r.press(2, [], QuickTool::Select); + assert_eq!((r.press_for(2), r.press_for(1)), (PagePress::Free, PagePress::Elsewhere), "nobody's, on page 2"); + r.begin(); + r.press(1, [hit(Layer::Comment, 4)], QuickTool::Select); + assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Mine(hit(Layer::Comment, 4).owner), PagePress::Elsewhere)); + r.settle(false, true); + assert_eq!(r.press_for(2), PagePress::Elsewhere, "until its release has been seen"); + r.settle(false, false); + assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free)); + r.begin(); + r.cancel(); + assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press is nobody's"); + } + #[test] fn a_highlight_lets_its_drag_go_to_the_text() { let mut r = PageRouter::default(); From 84fe5e54b31ad62ea86feb360aaeaecfbda23b37 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:28:48 +0100 Subject: [PATCH 07/49] refactor(ui): tell comments and Fill & Sign whether the press is theirs Comments ran last in the page view's chain of `||`, so only when no earlier module had taken the frame, and asked the router themselves (`grabs`, `fill_grabs`, `draw_grabs`), with a hit test of their own for hover. A link, a field or added text taking the frame first hid a comment's gesture end, its Escape and the click that unselects it. Comments now run on every frame and are told the page's `PagePress`: - a press on a comment (its body, a handle or a point) goes to `select_input` with the part, with the Select tool, a shape tool (the selected shape's handles) or Fill & Sign (its marks); - the drawing tools act only on a press nobody holds (`Gestures::free`); - a click on anything but a comment leaves comments unselected. `grabs`, `fill_grabs`, `draw_grabs` and `cancelled_drag` (with the raw button read in `keys` it needed) are gone; `grabs_points` is `reaches_points`. Fill & Sign leaves the press to a mark when the press is someone's, or, with no button down, when a mark is the top hit under the pointer. The page's hover is that top hit (`PageInput.hover`), and the comments' hover outline follows it, so it no longer shows under a field or added text that would take a press. Behaviour change (the user's ruling, 2026-10-31): a click on a form field, or on added text, leaves the selected comment unselected, as a click on a link or the page already did. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/page_input.rs | 37 ++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 7 +- crates/ui-egui/src/canvas/signature_drag.rs | 3 +- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/geometry.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 115 ++++++++---------- crates/ui-egui/src/tools/comments/paint.rs | 28 +++-- crates/ui-egui/src/tools/comments/state.rs | 2 - crates/ui-egui/src/tools/comments/tests.rs | 36 ++++++ crates/ui-egui/tests/it/pointer.rs | 26 ++++ 11 files changed, 164 insertions(+), 99 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 428f71c186..861833d25a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1496,7 +1496,8 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i let slot = PageSlot { i, r, xf }; // A cancelled press takes nothing until the button is up. - let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; + let input = + if view.press_cancelled { PageInput { consumed: true, area_tool: false, hover: None } } else { 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(); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 00edfe54cc..05a302926d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -16,6 +16,8 @@ pub(super) struct PageInput { pub(super) consumed: bool, /// Redact or Highlight draws boxes off text. pub(super) area_tool: bool, + /// What a press under the pointer would take (the top hit there), for hover painting. + pub(super) hover: Option, } /// Common geometry and pointer state for a tool acting on one page. @@ -153,10 +155,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut frame.out.stamp_placed = true; } } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + // A mark already placed takes its own press (comments, below), and with no button down the + // tool's preview hides over one, where a press would pick it up. + let over_mark = matches!(tool, QuickTool::Fill(_)) + && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); if let QuickTool::Fill(ft) = tool && allowed - && !comments::fill_grabs(&pcx, view) + && !over_mark { match crate::fill_sign::page_input( cx, @@ -283,17 +288,23 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.links.context = Some((i, index)); } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); - // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the - // last claim on a layer stands). - if frame.env.unobstructed - && let Some(p) = pointer - { - for h in hits.at(view, p) { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip); - } + // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that + // leaves them unselected. + let on_comment = comments::page_input(ui, resp, &pcx, press, view); + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; + let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); + PageInput { consumed, area_tool, hover } +} + +/// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs +/// (the last claim on a layer stands). Returns the top of it, what a press there would take, for +/// hover painting. +fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { + let under = hits.at(view, p); + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); } - PageInput { consumed, area_tool } + top(under, hits.tool) } /// The link `owner` is, by its `/Annots` index. @@ -701,7 +712,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P if input.area_tool { crate::redact_ui::paint(painter, i, view, pointer); } - comments::paint_page(ui, painter, &pcx, view); + comments::paint_page(ui, painter, &pcx, view, input.hover.as_ref()); if editing_content { crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2e79b7481e..865f5051b0 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -217,14 +217,13 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), ("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), - ("tools/comments/input.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), ("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), @@ -416,7 +415,7 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. -const HIT_TESTERS: [(&str, &str, &str); 4] = [ +const HIT_TESTERS: [(&str, &str, &str); 3] = [ ( "canvas/page_input.rs", "hits", @@ -424,7 +423,6 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [ ), ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), - ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), ]; /// What ignores the press the pages hold until the button is up. @@ -493,7 +491,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { ("tools/edit_text_ui.rs", "page_input"), ("tools/edit_text_ui/image.rs", "image_input"), ("tools/object_ui.rs", "input"), - ("tools/comments/input.rs", "select_input"), ] { let source = std::fs::read_to_string(src.join(file)).expect("page input module"); let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); diff --git a/crates/ui-egui/src/canvas/signature_drag.rs b/crates/ui-egui/src/canvas/signature_drag.rs index c706b8a113..9f7a071ac7 100644 --- a/crates/ui-egui/src/canvas/signature_drag.rs +++ b/crates/ui-egui/src/canvas/signature_drag.rs @@ -402,8 +402,7 @@ mod tests { let doc = session.get(id).expect("open"); let index = doc.info.annotations.iter().find(|a| a.subtype == "Sound").expect("listed").index; let ctx = egui::Context::default(); - let mut cv = CommentView::default(); - cv.gesture = Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }); + let mut cv = CommentView { gesture: Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }), ..CommentView::default() }; let mut drag = SignatureDrag::default(); let key = Some((doc.edit_generation(), 0, index)); drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index d0c46d0453..83ee620ae1 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{context, context_hit, describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 051cf09dba..ee67b92bb0 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -20,10 +20,10 @@ pub(super) fn reshapable(a: &Annotation) -> bool { && !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension")) } -/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools. +/// Whether `tool` reaches a line's end and corner handles: the Select and Fill & Sign tools. /// The drawing tools draw from where they are pressed, so a new line starts at the end of the /// one just drawn rather than dragging it. -pub(super) fn grabs_points(tool: QuickTool) -> bool { +pub(super) fn reaches_points(tool: QuickTool) -> bool { !matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points()) } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a12d971f5c..7ada11bc7f 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,43 +1,47 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, grabs_points, handle_pos, is_markup, point_handle, reshapable, reshaped, resizable, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable, resized, snapped_caret, }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; -use crate::page_router::{CommentPart, Hit, Owner}; +use crate::page_router::{CommentPart, Hit, Owner, PagePress}; -/// Comment input on one page, before text selection runs. Returns `true` when the pointer -/// gesture belongs to commenting (text selection must ignore it). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool { +/// Comment input on one page, before text selection runs. `press` is what this page has to do with +/// the press the pages hold: a press on a comment ([`hit`]) is that comment's, with any tool, and +/// one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to +/// commenting (text selection must ignore it). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; - let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); + let free = press == PagePress::Free; + let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); - let fill_grab = fill_grabs(cx, view); - let draw_grab = draw_grabs(cx, view); + let owned = match press { + PagePress::Mine(Owner::Comment { index, part }) => Some((index, part)), + _ => None, + }; let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None }; let cv = &mut view.comments; if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { cv.gesture = None; - cv.cancelled_drag = true; return true; } - if cv.cancelled_drag { - return true; - } - let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; + // A comment being moved, resized or reshaped on this page keeps the frame until it ends. + let moving = + matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page); + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { - QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin), + // The selected shape's handles, and Fill & Sign's marks, take their own presses. + QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, owned, pointer, origin), 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), - QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin), + QuickTool::Select => select_input(ui, resp, cx, view, owned, pointer, origin), _ => false, } } @@ -68,7 +72,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op }; let bodies = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)); if let Some(a) = view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed) { - if grabs_points(cx.tool) + if reaches_points(cx.tool) && reshapable(a) && let Some(point) = point_handle(cx, a, p) { @@ -119,32 +123,6 @@ pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut view.comments.selected = index.map(|i| (cx.page, i)); } -/// Whether the comments take the press on this page (or, with no button down, would take one -/// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press -/// held by a comment that keeps its drag. -fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) - { - return true; - } - if cx.ptr.any_down { - return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); - } - // Only the Select tool lets a link through a highlight, and it doesn't come here. - cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) -} - -/// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select -/// tool ([`hit`]), so the tool does not place a mark there. -pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view) -} - -/// With a shape tool, the selected comment's handles are picked up ([`hit`]). -fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a @@ -180,13 +158,19 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) } -/// The Select tool: select, move, resize, edit, delete, on the comment the press was on (its -/// owner, from [`hit`]). The cursor comes from [`hit`] too. -fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView, pointer: Option, origin: Option) -> bool { - let owner = match view.router.owner(cx.page) { - Some(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), - _ => None, - }; +/// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the +/// part of it (`owned`, from [`hit`]). The cursor comes from [`hit`] too. A click on anything else +/// (a link, a field, added text, the page) leaves comments unselected. +fn select_input( + ui: &egui::Ui, + resp: &egui::Response, + cx: &PageCx<'_>, + view: &mut DocView, + owned: Option<(usize, CommentPart)>, + pointer: Option, + origin: Option, +) -> bool { + let owner = owned.and_then(|(index, part)| cx.get(index).map(|a| (a, part))); let cv = &mut view.comments; let mut consumed = false; if resp.drag_started() @@ -262,8 +246,8 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { - // A click elsewhere (a link beneath text highlighting included: it follows the link) - // leaves comments unselected. + // A click elsewhere (a link beneath text highlighting included: it follows the link; a + // form field; added text) leaves comments unselected. match owner { Some((a, _)) => { cv.selected = Some((cx.page, a.index)); @@ -326,13 +310,21 @@ struct Gestures<'a> { pointer: Option, origin: Option, page_rect: Rect, + /// The press began on this page, and nobody holds it: the tool's. pressed_here: bool, over_page: bool, + /// Nobody holds the press, and it began on this page, or no button is down. + free: bool, } impl Gestures<'_> { + /// The tool's own click: one nobody holds. + fn clicked(&self) -> bool { + self.free && self.resp.clicked() + } + 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 Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -380,7 +372,7 @@ impl Gestures<'_> { // 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 matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight) - && resp.clicked() + && self.clicked() && over_page && let Some(p) = pointer && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) @@ -412,7 +404,7 @@ impl Gestures<'_> { cv.undo_point(); return true; } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -492,7 +484,7 @@ impl Gestures<'_> { } fn attach(&self, view: &mut DocView) -> bool { - let Self { resp, cx, pointer, over_page, .. } = *self; + let Self { cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -500,7 +492,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -511,7 +503,7 @@ impl Gestures<'_> { } fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { - let Self { resp, cx, pointer, over_page, .. } = *self; + let Self { cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -519,7 +511,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -573,7 +565,7 @@ impl Gestures<'_> { } } } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -587,9 +579,6 @@ impl Gestures<'_> { /// 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()) { - view.comments.cancelled_drag = false; - } if ctx.egui_wants_keyboard_input() { return; } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 59b5ca773a..e784c332ae 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,12 +1,24 @@ -use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; -use super::state::{Gesture, PageCx, SETTLE_SECS}; +use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, Stroke, color32, vec2, }; +use crate::page_router::{CommentPart, Hit, Owner}; -/// 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) { +/// The comment outlined under the pointer: the one a press there would take (`hover`, the top hit), +/// with the Select tool or, over its own marks, Fill & Sign. Not the selected comment, nor while a +/// comment is moved, resized or drawn, nor one under a form field or added text that would take +/// the press. +pub(super) fn hover_outline<'c>(cx: &'c PageCx<'_>, cv: &CommentView, hover: Option<&Hit>) -> Option<&'c Annotation> { + let Some(Owner::Comment { index, part: CommentPart::Body }) = hover.map(|h| h.owner) else { return None }; + let tool_outlines = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)); + (cv.gesture.is_none() && tool_outlines && cv.selected != Some((cx.page, index))).then(|| cx.get(index)).flatten() +} + +/// Paint comment selection, hover and gesture previews on a page. `hover` is the top hit under the +/// pointer. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, hover: Option<&Hit>) { let cv = &view.comments; let pointer = cx.ptr.pos; // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the @@ -21,11 +33,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.rect_filled(rect, CornerRadius::ZERO, fill); } } - if cv.gesture.is_none() - && let Some(a) = pointer.and_then(|p| cx.hit(p)) - && (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign) - && cv.selected != Some((cx.page, a.index)) - { + if let Some(a) = hover_outline(cx, cv, hover) { for r in cx.screen_rects(a) { painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } @@ -45,7 +53,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> return paint_gesture(painter, cx, view); } let shift = ui.input(|i| i.modifiers.shift); - if cx.allowed && grabs_points(cx.tool) && reshapable(a) { + if cx.allowed && reaches_points(cx.tool) && reshapable(a) { return paint_points(painter, cx, view, a, pointer, shift); } let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0); diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 6cac912210..07452f5b49 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -133,8 +133,6 @@ pub struct CommentView { /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). pub(crate) settling: Vec, - /// Escape ends egui's drag too; ignore its synthetic release until the mouse is up. - pub(super) cancelled_drag: bool, pub composer: Option, /// Reply being typed under the selected card. pub reply: String, diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 6110e8a6fc..0f9edd6c22 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -499,3 +499,39 @@ fn text_boxes_fit_their_text() { assert_eq!(long[2], 300.0); assert!(long[3] - long[1] > 50.0); } + +/// The hover outline goes round the comment a press under the pointer would take (the top hit +/// there): not a highlight under a form field, which takes the press. +#[test] +fn the_hover_outline_goes_round_what_a_press_would_take() { + use super::paint::hover_outline; + use crate::page_cursor::Layer; + use crate::page_router::{CommentPart, Hit, Owner}; + let page = PageInfo { width: 200.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 200.0], rotation: 0 }; + let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), rot: 0, pw: 200.0, ph: 200.0 }; + let highlight = Annotation { rect: [50.0, 50.0, 150.0, 150.0], ..annotation("Highlight", None, false) }; + let info = DocInfo { pages: vec![page], annotations: vec![highlight], ..Default::default() }; + let prefs = CommentPrefs::default(); + // The pointer is over the highlight. + let ptr = PagePointer { pos: Some(pos2(100.0, 100.0)), ..PagePointer::default() }; + let cx = PageCx { + page: 0, + xf: &xf, + ptr: &ptr, + info: &info, + tool: QuickTool::Select, + prefs: &prefs, + allowed: true, + hidden: false, + redact_mark: ([0.0; 3], 1.0), + }; + let on = |owner, layer| Hit::new(owner, layer, egui::CursorIcon::PointingHand); + let comment = on(Owner::Comment { index: 0, part: CommentPart::Body }, Layer::Comment); + let field = on(Owner::Field { field: 0, widget: 0 }, Layer::Field); + let cv = CommentView::default(); + assert!(hover_outline(&cx, &cv, Some(&comment)).is_some(), "the highlight a press there selects"); + assert!(hover_outline(&cx, &cv, Some(&field)).is_none(), "not under a field that takes the press"); + assert!(hover_outline(&cx, &cv, None).is_none(), "nor where nothing would take it"); + let selected = CommentView { selected: Some((0, 0)), ..CommentView::default() }; + assert!(hover_outline(&cx, &selected, Some(&comment)).is_none(), "the selected comment has its own frame"); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index c868dc8079..b63d9937fb 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1129,3 +1129,29 @@ fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() { right_click(&mut h, on_comment); assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); } + +/// The check box `agree` of [`checkbox_pdf`], with a rectangle comment at x 150..250, y 100..150. +fn comment_and_checkbox_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Square /Rect [150 100 250 150] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// A click on anything but the selected comment leaves it unselected, a form field included, as +/// a click on a link or on the page does. A field's click used to keep it selected. +#[test] +fn a_click_on_a_form_field_deselects_the_selected_comment() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + let comment = at(&h, 200.0, 125.0); + mouse_click(&mut h, comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the comment is selected"); + let check_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, check_box); + assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected"); + assert_eq!(agree(&h).len(), 1, "and ticks the box"); +} From 951ef7e3627288484499d73a7dd9e1ff533283ce Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:34:49 +0100 Subject: [PATCH 08/49] refactor(ui): one gate for the presses nobody holds The tools that act wherever on the page each decided for themselves whether a press was theirs: Sign area, zoom and snapshot, crop, redact and the stamps asked the router, and Measure, Fill & Sign, the camera and the Link tool's rectangle acted on any press. Stamp placement lived inline in the page view. `tool_input` is now the one gate. Measure, the Stamp tool and custom stamps, Fill & Sign, Sign area, zoom and snapshot, the camera and crop are each told whether the press is the tool's (it began on their page and no hit won it), and act only then; their cursors and gestures run as before. Redact's boxes and the Link tool's rectangle are told the same where they are called. Stamp placement moves to `stamps_ui::place` and `place_custom`, beside custom stamps. Measure is told its clicks (`measure_ui::Clicks`), Sign area takes a `PageToolContext`. The camera's corners are hits (`camera_ui::hit`, `Owner::CameraCorner` on the handle layer), so a corner's drag is an owned press like any other: it starts on the corner's own press, ends on `drag_stopped() || !primary_down`, and `cancel_pointer_gestures` drops it. Until now only the order of two key handlers kept Escape mid-drag from leaving a corner following the pointer: Escape leaves the camera tool before the pages see it. The kittest for Escape mid-drag can't fail with that order; a unit test of the cancel comes later in this series. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/crop.rs | 8 +- crates/ui-egui/src/canvas/page_input.rs | 210 +++++++++--------- .../ui-egui/src/canvas/page_pointer/tests.rs | 6 +- crates/ui-egui/src/canvas/page_router.rs | 2 + crates/ui-egui/src/canvas/zoom_snap.rs | 11 +- crates/ui-egui/src/tools/camera_ui.rs | 57 +++-- crates/ui-egui/src/tools/fill_sign.rs | 5 +- crates/ui-egui/src/tools/link_ui.rs | 10 +- crates/ui-egui/src/tools/measure_ui.rs | 20 +- crates/ui-egui/src/tools/redact_ui.rs | 10 +- crates/ui-egui/src/tools/sign_ui.rs | 21 +- crates/ui-egui/src/tools/stamps_ui.rs | 50 +++++ crates/ui-egui/tests/it/pointer.rs | 37 +++ 14 files changed, 281 insertions(+), 169 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 861833d25a..30d2799f68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -863,6 +863,9 @@ impl DocView { self.sign.drag = None; self.redact_drag = None; self.crop_drag = None; + if let Some(q) = &mut self.camera { + q.grab = None; + } self.block_drag = None; self.org_drag = None; self.panel_drag = None; diff --git a/crates/ui-egui/src/canvas/crop.rs b/crates/ui-egui/src/canvas/crop.rs index bdd1e9e135..7858b00dd6 100644 --- a/crates/ui-egui/src/canvas/crop.rs +++ b/crates/ui-egui/src/canvas/crop.rs @@ -27,10 +27,12 @@ pub(crate) struct CropPageInput<'a> { pub(crate) info: &'a DocInfo, pub(crate) view: &'a mut DocView, pub(crate) allowed: bool, + /// Nobody holds the press, and it began on this page: the tool's. + pub(crate) free: bool, } pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { - let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input; + let CropPageInput { resp, xf, ptr, page, info, view, allowed, free } = input; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); @@ -38,9 +40,9 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { if !allowed { return false; } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.crop_drag = Some((page, o)); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 05a302926d..40552fc1ec 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -39,30 +39,20 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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, - fill_text_color, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -103,106 +93,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); - 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) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && press == PagePress::Free { - // 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; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && press == PagePress::Free { - // 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; - } - } // A mark already placed takes its own press (comments, below), and with no button down the - // tool's preview hides over one, where a press would pick it up. + // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); - if let QuickTool::Fill(ft) = tool - && allowed - && !over_mark - { - match crate::fill_sign::page_input( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - 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(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } + tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); frame.out.field_props |= o.properties; @@ -220,7 +115,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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(cx, over_text, view) + crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); // Add text acts on the page's own paragraphs over them, and on the one its press was on until @@ -281,7 +176,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } } let (link_owner, link_context) = (link_of(press.owner()), link_of(context)); - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, press == PagePress::Free, link_owner, link_context, view); if tool == QuickTool::Select && can_modify && let Some(index) = link_context @@ -315,6 +210,100 @@ fn link_of(owner: Option) -> Option { } } +/// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the +/// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only +/// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor +/// and gestures. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// pointer. +fn tool_input( + ui: &egui::Ui, + view: &mut DocView, + frame: &mut PageFrame<'_>, + slot: &PageSlot, + pcx: &comments::PageCx<'_>, + press: PagePress, + over_mark: bool, +) { + let PageEnv { + allowed, + author, + by_line, + can_crop, + can_modify, + custom_stamp, + date_text, + doc, + info, + initials, + signature, + tool, + units, + fill_text_size, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let free = press == PagePress::Free; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units); + } + if let QuickTool::Stamp(kind) = tool + && allowed + && crate::stamps_ui::place(cx, kind, by_line, author, free, view) + { + frame.out.stamp_placed = true; + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && crate::stamps_ui::place_custom(cx, cs, author, free, view) + { + frame.out.stamp_placed = true; + } + if let QuickTool::Fill(ft) = tool + && allowed + && !over_mark + { + let input = crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }; + match crate::fill_sign::page_input(cx, input, free, view) { + 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(cx, free, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, free, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify, press); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop, free }) + { + view.current = i; + frame.out.open_boxes = true; + } +} + /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. @@ -460,9 +449,10 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + let corner = (tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; - [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() + [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 865f5051b0..c2d214d0d7 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,7 +150,7 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 36] = [ +const PAGE_MODULES: [&str; 37] = [ "canvas.rs", "canvas/page_input.rs", "canvas/area.rs", @@ -187,6 +187,7 @@ const PAGE_MODULES: [&str; 36] = [ "canvas/image_pick.rs", "canvas/signature_drag.rs", "tools/calc_order_ui.rs", + "tools/stamps_ui.rs", ]; /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. @@ -434,11 +435,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 10] = [ +const PAGE_HIT_CALLS: [&str; 11] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index f1aa199c67..23eae443e9 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -36,6 +36,8 @@ pub(crate) enum Owner { /// Added text or a picture placed on the page, by its index among the page's, and a corner of /// the selected one (left, top) or the item itself. Added { index: usize, corner: Option<(bool, bool)> }, + /// Enhance ▸ Camera image: a corner of the quadrilateral (0 left top, then clockwise). + CameraCorner { corner: usize }, } /// The part of a picture a hit is on. diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 159adf2b7e..a422907b89 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -13,15 +13,15 @@ use crate::{PdfCraftApp, QuickTool}; /// [x0, y0, x1, y1]); a plain click has an empty rectangle. pub type Marquee = (usize, Rect, [f32; 4]); -/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { +/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); @@ -40,7 +40,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)])); } } - if resp.clicked() + if free + && resp.clicked() && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) { let v = xf.screen_to_view(p); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index abaad1ed5c..a7e7d6c934 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -7,6 +7,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -59,8 +60,28 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { xf.norm_to_screen(c[0] / xf.pw.max(1e-3), c[1] / xf.ph.max(1e-3)) } -/// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { +/// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test +/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, or on a page +/// without the quadrilateral. +pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { + if view.camera_done.is_some() { + return None; + } + let q = view.camera.filter(|q| q.page == page)?; + let corner = q + .corners + .iter() + .enumerate() + .map(|(i, &c)| (i, screen(xf, c).distance(p))) + .filter(|(_, d)| *d <= GRAB) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i)?; + Some(Hit::new(Owner::CameraCorner { corner }, Layer::Handle, egui::CursorIcon::Grab)) +} + +/// Corner dragging on one page: a corner's own press ([`hit`]) drags it, and a press nobody holds +/// on a page without the quadrilateral moves it there. +pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool, press: PagePress) { let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { @@ -76,23 +97,21 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe if view.camera.is_none() && page == view.current { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } - if resp.drag_started() || resp.clicked() { - let pressed = origin.filter(|o| xf.rect.contains(*o)); - if pressed.is_some() && view.camera.is_none_or(|q| q.page != page) { - view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); - } - if resp.drag_started() - && let (Some(o), Some(q)) = (pressed, view.camera.as_mut()) - { - q.grab = (0..4) - .map(|i| (i, screen(xf, q.corners[i]).distance(o))) - .filter(|(_, d)| *d <= GRAB) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(i, _)| i); - } + if (resp.drag_started() || resp.clicked()) + && press == PagePress::Free + && origin.is_some_and(|o| xf.rect.contains(o)) + && view.camera.is_none_or(|q| q.page != page) + { + view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); + } + if resp.drag_started() + && let PagePress::Mine(Owner::CameraCorner { corner }) = press + && let Some(q) = view.camera.as_mut().filter(|q| q.page == page) + { + q.grab = Some(corner); } let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return }; - let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB)); + // Over a corner, its hit claims the grab cursor. if let Some(i) = q.grab { view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if let Some(p) = pointer { @@ -101,11 +120,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe *c = [x.clamp(0.0, xf.pw), y.clamp(0.0, xf.ph)]; } } - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { q.grab = None; } - } else if near { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..416be8867e 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -455,7 +455,7 @@ pub(crate) struct PageInput<'a> { } /// Preview and clicks with a Fill & Sign tool on one page. -pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &mut DocView) -> Option { +pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; @@ -475,7 +475,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m } else { view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); } - if !resp.clicked() { + // The tool's own click: one nobody holds. + if !(free && resp.clicked()) { return None; } // Typed text starts a little above the click, as displayed. diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 699383cbfe..d380d1b71b 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -87,10 +87,10 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { } } -/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` -/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to -/// the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { +/// Link tool input on one page: `free` when nobody holds the press and it began on this page, +/// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is +/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; let lv = &mut view.links; @@ -128,7 +128,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: } return true; } - if resp.drag_started() { + if resp.drag_started() && (free || owner.is_some()) { let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..a81f01b0b2 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -202,9 +202,23 @@ fn snap_to_content( None } +/// The Measure tools' own clicks this frame: those of a press nobody holds. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Clicks { + pub single: bool, + pub double: bool, +} + +impl Clicks { + /// `resp`'s clicks, when the press is the tool's (`free`). + pub(crate) fn of(resp: &egui::Response, free: bool) -> Self { + Clicks { single: free && resp.clicked(), double: free && resp.double_clicked() } + } +} + pub(crate) fn page_input( ui: &egui::Ui, - resp: &egui::Response, + clicks: Clicks, doc: &Document, view: &mut DocView, cx: &crate::comments::PageCx<'_>, @@ -254,7 +268,7 @@ pub(crate) fn page_input( point = measure::snap::constrain(*last, point); } hover = Some(point); - if resp.clicked() { + if clicks.single { if state.page != Some(page) { state.cancel(); state.page = Some(page); @@ -284,7 +298,7 @@ pub(crate) fn page_input( } } if state.page == Some(page) && !state.points.is_empty() { - if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { + if (clicks.double || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { finish(state, &mut view.pending_edit, doc, page, tool, author, units); } if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) { diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index e55dd8c5c3..0cca746bf8 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -233,9 +233,9 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Redact tool input on a page. Presses on text are left to text selection (the selection is -/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when -/// the gesture is a box. -pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, view: &mut DocView) -> bool { +/// marked when it ends, see [`after_text`]); presses nobody holds (`free`) elsewhere draw a box. +/// Returns `true` when the gesture is a box. +pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, free: bool, view: &mut DocView) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag @@ -256,8 +256,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo if !over_text(p) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. - if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() { + // The tool's own press, of the primary button: one nobody holds. + if resp.drag_started_by(egui::PointerButton::Primary) && free { let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index b00d07279a..581fd23694 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -8,10 +8,9 @@ use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus}; -use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; +use crate::canvas::{DocView, PageToolContext, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; use crate::page_cursor::Layer; -use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -162,23 +161,17 @@ pub struct SignView { pub field: Option, } -/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). -pub(crate) fn page_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &pdfcraft_render::DocInfo, - view: &mut DocView, -) { +/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)), from a +/// press nobody holds (`free`). +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, view: &mut DocView) { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.sign.drag = Some((page, o)); diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..484c7eb938 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use egui::{Align, Layout}; use serde::{Deserialize, Serialize}; +use crate::canvas::{DocView, PageToolContext}; +use crate::page_cursor::Layer; use crate::theme::{self, Tokens}; use crate::{Dialog, PdfCraftApp, QuickTool, widgets}; @@ -41,6 +43,54 @@ mod b64 { } } +/// The Stamp tool on one page: a click that nobody holds (`free`) places stamp `kind` centred on +/// it, upright as the page is shown. `true` once one is placed. +pub(crate) fn place(cx: PageToolContext<'_>, kind: pdfcraft_engine::StampKind, by_line: &str, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let a = page_info.view_to_user((f64::from(vx) - w / 2.0) as f32, (f64::from(vy) - h / 2.0) as f32); + let b = page_info.view_to_user((f64::from(vx) + w / 2.0) as f32, (f64::from(vy) + h / 2.0) as f32); + let rect = [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.to_owned()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.to_owned(), + })); + true +} + +/// A custom stamp on one page: a click that nobody holds (`free`) places `stamp` centred on it, at +/// its natural size (the engine sizes it). `true` once one is placed. +pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let u = page_info.view_to_user(vx, vy); + let (x, y) = (f64::from(u[0]), f64::from(u[1])); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page, + rect: [x, y, x, y], + name: stamp.name.clone(), + file: pdfcraft_engine::MarkFile { name: stamp.file.clone(), bytes: stamp.data.clone(), page: stamp.page }, + author: author.to_owned(), + }); + true +} + /// The Create Custom Stamp dialog: the chosen file, its category and name. #[derive(Clone, Debug, Default, PartialEq)] pub struct StampDraft { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index b63d9937fb..9ecc955604 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1155,3 +1155,40 @@ fn a_click_on_a_form_field_deselects_the_selected_comment() { assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected"); assert_eq!(agree(&h).len(), 1, "and ticks the box"); } + +/// The camera tool's quadrilateral, corner by corner, in page view points. +fn camera_corners(h: &Harness<'_, PdfCraftApp>) -> Option<[[f32; 2]; 4]> { + h.state().views[0].camera.map(|q| q.corners) +} + +/// A corner of the camera tool's quadrilateral is a press of its own: Escape in the middle of its +/// drag cancels it, and once the button is up no corner follows the pointer. Today Escape also +/// leaves the camera tool before the pages see it; this holds whatever the order of the keys. +#[test] +fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { + let mut h = open(text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + // The left top corner starts 5% in from the page's edges: view (15, 20) on the 300×400 page. + let corner = at(&h, 15.0, 380.0); + let start = camera_corners(&h); + h.hover_at(corner); + h.run_steps(1); + h.drag_at(corner); + h.run_steps(1); + h.hover_at(corner + egui::vec2(30.0, 30.0)); + h.run_steps(2); + assert_ne!(camera_corners(&h), start, "the corner follows its drag"); + h.key_press(egui::Key::Escape); + h.run_steps(1); + let end = corner + egui::vec2(60.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(2); + let after = camera_corners(&h); + h.hover_at(corner + egui::vec2(120.0, 90.0)); + h.run_steps(3); + assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled"); + assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held"); +} From ed8a10e83e8c933c945aa2437a9a8e31ed41411e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:41:07 +0100 Subject: [PATCH 09/49] refactor(ui): tell forms and Prepare whether the press is theirs Forms and Prepare read the router themselves, and forms tested the pointer twice more: for a field's tooltip, and for whether a click came up over the widget it was pressed on. Following a link tested the pointer again too. Both are now told the page's `PagePress`. Prepare's Select tool draws a selection rectangle, or unselects, only on a press nobody holds, and a field tool draws only on such a press. Whether a click came up over what its press began on is answered once, from the hits under the pointer (`released_over`), for fields and for following links. The field tooltip (`forms_ui::tooltip`) and the field hover outline come from the page's hover, the top hit under the pointer. `PageHits::hits` is now the only function that calls a hit test, and the press-handler check no longer lists forms or Prepare. Behaviour changes: a field's tooltip shows only where a press would take the field, not under a comment drawn over it (the user's ruling, 2026-10-31); a field's hover outline follows the same rule, so it shows only with the Select tool, which fills fields. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 7 ++- crates/ui-egui/src/canvas/page_input.rs | 20 +++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 26 ++-------- crates/ui-egui/src/tools/forms_ui.rs | 47 ++++++++++++------- crates/ui-egui/src/tools/prepare.rs | 35 +++++++++----- crates/ui-egui/tests/it/pointer.rs | 34 ++++++++++++++ 6 files changed, 111 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 30d2799f68..6cb3211c7c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1499,8 +1499,11 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i let slot = PageSlot { i, r, xf }; // A cancelled press takes nothing until the button is up. - let input = - if view.press_cancelled { PageInput { consumed: true, area_tool: false, hover: None } } else { page_tool_input(ui, view, frame, &slot) }; + let input = if view.press_cancelled { + PageInput { consumed: true, area_tool: false, hover: None, released_over: false } + } else { + 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(); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 40552fc1ec..38c20196d9 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -18,6 +18,9 @@ pub(super) struct PageInput { pub(super) area_tool: bool, /// What a press under the pointer would take (the top hit there), for hover painting. pub(super) hover: Option, + /// The press's click came up over what the press began on: a link's or a field's click counts + /// only then, as a button's does. + pub(super) released_over: bool, } /// Common geometry and pointer state for a tool acting on one page. @@ -92,6 +95,10 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); + let released_over = match press { + PagePress::Mine(owner) if resp.clicked() => pointer.is_some_and(|p| hits.at(view, p).iter().any(|h| h.owner == owner)), + _ => false, + }; let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. @@ -99,13 +106,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { - let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, 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() } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) }; // 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; @@ -188,7 +195,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let on_comment = comments::page_input(ui, resp, &pcx, press, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); - PageInput { consumed, area_tool, hover } + PageInput { consumed, area_tool, hover, released_over } } /// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs @@ -727,7 +734,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P } } } else { - crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, input.hover.as_ref()); + crate::forms_ui::tooltip(resp, form, input.hover.as_ref()); } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a @@ -743,8 +751,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && let Some(Owner::Link { index }) = view.router.owner(i) + && input.released_over && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index c2d214d0d7..48bb408bad 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -416,15 +416,11 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. -const HIT_TESTERS: [(&str, &str, &str); 3] = [ - ( - "canvas/page_input.rs", - "hits", - "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", - ), - ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), - ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), -]; +const HIT_TESTERS: [(&str, &str, &str); 1] = [( + "canvas/page_input.rs", + "hits", + "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", +)]; /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -487,8 +483,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { )); let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for (file, function) in [ - ("tools/forms_ui.rs", "page_input"), - ("tools/prepare.rs", "page_input"), ("tools/content_ui.rs", "page_input"), ("tools/edit_text_ui.rs", "page_input"), ("tools/edit_text_ui/image.rs", "image_input"), @@ -496,16 +490,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { ] { let source = std::fs::read_to_string(src.join(file)).expect("page input module"); let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); - // Forms reuse their hit test for hover text and to require release over the captured - // widget. Neither chooses the press owner. Pin both calls to the current pointer `p`, - // not the press origin, and reject an additional call or a changed argument. - let body = if file == "tools/forms_ui.rs" { - let call = "hit(xf, info, page, form, allowed, p)"; - assert_eq!(body.matches(call).count(), 2, "only the existing hover and release checks are exempt"); - body.replace(call, "hover_or_release_check") - } else { - body - }; if !routed_press_handler(&body) { problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); } diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 7232b1169b..1d31b81fbc 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -15,7 +15,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -182,19 +182,30 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon)) } -/// Presses and clicks on form widgets of one page, on the field the press was on ([`hit`]). -/// `true` while a field holds the press. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - // The field's tooltip (`/TU`) under the pointer, as Acrobat shows it on hover (#3854). ISO - // 32000-2 Table 226 makes it the name the user interface identifies the field by. - if let Some(p) = ptr.within(xf.rect) - && let Some(Owner::Field { field, .. }) = hit(xf, info, page, form, allowed, p).map(|h| h.owner) +/// The tooltip (`/TU`) of the field under the pointer, as Acrobat shows it on hover (#3854): only +/// where the field is what a press would take (`hover`, the top hit there). ISO 32000-2 Table 226 +/// makes it the name the user interface identifies the field by. +pub(crate) fn tooltip(resp: &egui::Response, form: &[FormField], hover: Option<&Hit>) { + if let Some(Owner::Field { field, .. }) = hover.map(|h| h.owner) && let Some(tip) = form.get(field).and_then(|f| f.tooltip.as_deref()).filter(|t| !t.trim().is_empty()) { resp.clone().on_hover_text_at_pointer(tip); } - let Some(Owner::Field { field, widget: wi }) = view.router.owner(page) else { return false }; +} + +/// Presses and clicks on form widgets of one page, on the field the press is on (`press`, from +/// [`hit`]). `released_over`: the press's click came up over the widget it began on. `true` while a +/// field holds the press. +pub(crate) fn page_input( + cx: PageToolContext<'_>, + form: &[FormField], + allowed: bool, + press: PagePress, + released_over: bool, + view: &mut DocView, +) -> bool { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; + let PagePress::Mine(Owner::Field { field, widget: wi }) = press else { return false }; let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false }; let usable = usable(f, w, allowed); // A text field opens on the press rather than the click, so a drag that starts on it @@ -210,8 +221,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b } // A click counts released over the widget its press was on, as a button's does: one that slips // off it first does nothing (a check box's overlay closes on it too). - if !resp.clicked() || !ptr.pos.and_then(|p| hit(xf, info, page, form, allowed, p)).is_some_and(|h| h.owner == Owner::Field { field, widget: wi }) - { + if !resp.clicked() || !released_over { return true; } if !allowed { @@ -489,7 +499,8 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st picked } -/// Paint focus and hover frames on a page, the pages' `pointer` over it. +/// Paint focus and hover frames on a page: the hover frame round the widget a press under the +/// pointer would take (`hover`, the top hit there). pub(crate) fn paint_page( painter: &egui::Painter, xf: &PageXform, @@ -497,9 +508,13 @@ pub(crate) fn paint_page( info: &DocInfo, form: &[FormField], view: &DocView, - pointer: Option, + hover: Option<&Hit>, ) { - for f in form { + let hovered = match hover.map(|h| h.owner) { + Some(Owner::Field { field, widget }) => Some((field, widget)), + _ => None, + }; + for (fi, f) in form.iter().enumerate() { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) { let r = widget_rect(xf, info, page, w.rect); let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi); @@ -508,7 +523,7 @@ pub(crate) fn paint_page( } if view.forms.unbaked.contains(&(f.name.clone(), page)) { paint_unbaked_widget(painter, xf, f, w, r); - } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { + } else if fillable(f) && hovered == Some((fi, wi)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } } diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 78568f46db..ffd716c056 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -13,7 +13,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::comments; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme; use crate::widgets; @@ -285,8 +285,17 @@ pub(crate) fn hit( Some(Hit::new(Owner::PrepareField { field, widget }, Layer::Placed, egui::CursorIcon::Move)) } -/// Pointer input on one page while preparing a form. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Option, allowed: bool, view: &mut DocView) -> Outcome { +/// Pointer input on one page while preparing a form: `press` says whether the press is a field's +/// or a corner's ([`hit`]), nobody's (a field tool draws, the Select tool draws a selection +/// rectangle), or another page's. +pub(crate) fn page_input( + cx: PageToolContext<'_>, + form: &[FormField], + tool: Option, + allowed: bool, + press: PagePress, + view: &mut DocView, +) -> Outcome { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; let pointer = ptr.pos; @@ -373,9 +382,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti if !allowed { return out; } + let free = press == PagePress::Free; if let Some(tool) = tool { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.drag_started() || resp.clicked() { + if free && (resp.drag_started() || resp.clicked()) { let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); out.consumed = true; @@ -398,18 +408,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti return out; } - // The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a - // drag draws a selection rectangle. A press held by something drawn over the fields (a + // The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody + // holds draws a selection rectangle. A press held by something drawn over the fields (a // comment) is not Prepare's. - let owner = view.router.owner(page); - let held = matches!(owner, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); - let hit = match owner { - Some(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), + let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); + let hit = match press { + PagePress::Mine(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), _ => None, }; - if resp.drag_started() && (held || owner.is_none()) { + if resp.drag_started() && (held || free) { let origin = ptr.origin.unwrap_or(p); - if let Some(Owner::PrepareCorner { left, top }) = owner { + if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { prep.grab = Some((page, Grab::Corner(left, top), origin)); out.consumed = true; } else if let Some((f, wi)) = hit { @@ -432,7 +441,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out.properties = true; return out; } - if resp.clicked() && (held || owner.is_none()) { + if resp.clicked() && (held || free) { let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command); match hit { // Shift/⌘-click adds or removes a field from the selection. diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 9ecc955604..6975a12a04 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1192,3 +1192,37 @@ fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled"); assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held"); } + +/// A 300×400 page with a text field `name` (tooltip "Your name") at x 50..250, y 200..220, and a +/// rectangle comment drawn over its right end, x 150..260, y 190..230. +fn commented_text_field_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [50 200 250 220] /P 3 0 R /F 4 /DA (/Helv 12 Tf 0 g) >>", + "<< /Type /Annot /Subtype /Square /Rect [150 190 260 230] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// Rests the pointer at `p` long enough for a tooltip. +fn rest_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + for _ in 0..40 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + } +} + +/// A field's tooltip (`/TU`) shows only where a press would take the field: not under a comment +/// drawn over it. It used to show over the whole field. +#[test] +fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() { + let mut h = open(commented_text_field_pdf(), "150", false); + let under_comment = at(&h, 200.0, 210.0); + rest_at(&mut h, under_comment); + assert!(h.query_by_label("Your name").is_none(), "no field tooltip where the comment takes the press"); + let on_field = at(&h, 100.0, 210.0); + rest_at(&mut h, on_field); + assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press"); +} From 734cb4c13a4aa2e2ba1ae201f9fcf905d227d3ee Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:26:10 +0100 Subject: [PATCH 10/49] test(ui): the page guards read past a test module in the middle of a file The page guards (pointer reads, cursor writes, and presses) left a file's tests out by cutting it at its first `#[cfg(test)] mod`. `edit_text_ui/image.rs` has a test module at line 231, so its lines 245-1116 were never read: `crop_input`, `image_input` and the picture hit tests among them. A hit test called there, a router write or a raw pointer read passed every guard. `outside_test_modules` now leaves out each test module up to its closing brace, and the guards read the rest. Nothing in the newly read code breaks a rule. A unit test pins the scanner, and a bare `crop_hit(` planted in `image_input` now fails the press guard (it passed before). The guard's messages and docs, CLAUDE.md's "Page presses" line, and the docs of `page_tool_input`, `PageHits::hits` and `PageRouter::cancel` now say what the code does: modules are told what a press is to their page (`PagePress`), and `PageHits::hits` is the one function that asks the hit tests. `comments::context_hit(` joins the hit tests a press handler must not call. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas/page_input.rs | 12 ++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 50 +++++++++++++++---- crates/ui-egui/src/canvas/page_router.rs | 6 ++- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b1d7f5023..47a80b86bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own: the page view tells it what the press is to its page (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 38c20196d9..a2f71d97c8 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -35,9 +35,9 @@ pub(crate) struct PageToolContext<'a> { pub info: &'a DocInfo, } -/// 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. +/// The tools' input on one page. The hits at the press point decide who holds the press, and each +/// module is told what the press is to this page (`PagePress`): its own, nobody's (the active tool's, +/// through `tool_input`), or another page's. Text selection runs after, on what nobody took. pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { let PageEnv { added, @@ -391,9 +391,9 @@ impl PageHits<'_> { /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the - /// open paragraph's frame, the selected picture's grips over them all; then form fields - /// (Prepare's, being moved, over the comments; filled ones under added content and the - /// comments that move). + /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; + /// then form fields (Prepare's, being moved, over the comments; filled ones under added content + /// and the comments that move). In crop mode, only the picture being cropped. /// For a right-click (`context`), comments by [`comments::context_hit`]. fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 48bb408bad..69abae12cf 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -264,6 +264,25 @@ fn uses(line: &str, name: &str) -> bool { line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } +/// The lines of `source` outside its test modules, numbered from 1. Tests drive egui's input and +/// read its output themselves. A test module (`#[cfg(test)]` then `mod name {`) ends at the first +/// line that is a lone `}`, as rustfmt lays a top-level item out, so the code after a test module +/// in the middle of a file is still read; `#[cfg(test)]` then `mod name;` is one line. +fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { + let mut kept = Vec::new(); + let mut lines = source.lines().enumerate().map(|(n, line)| (n + 1, line)).peekable(); + while let Some((n, line)) = lines.next() { + if line == "#[cfg(test)]" && lines.peek().is_some_and(|(_, next)| next.starts_with("mod ")) { + if lines.next().is_some_and(|(_, module)| module.ends_with('{')) { + lines.by_ref().find(|(_, line)| *line == "}"); + } + continue; + } + kept.push((n, line)); + } + kept +} + /// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, /// the top-level function or `impl` method it is in, line, name). fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { @@ -271,11 +290,9 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // Tests drive egui's input and read its output themselves. - let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); // The top-level function, or method of an `impl`, that each line is in. let (mut function, mut in_impl) = (None, false); - for (n, line) in code.lines().enumerate() { + for (n, line) in outside_test_modules(&source) { if line.starts_with("impl") { (function, in_impl) = (None, true); } else if line == "}" { @@ -287,7 +304,7 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, continue; } for name in find(line) { - found.push((file, function.map(str::to_owned), n + 1, name)); + found.push((file, function.map(str::to_owned), n, name)); } } } @@ -414,8 +431,9 @@ fn hit_test_calls(line: &str) -> Vec { .collect() } -/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the -/// press point; the others ask where the pointer is. +/// The functions that call a hit test: (file, function, why). `PageHits::hits` is the one: the hits +/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover +/// painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( "canvas/page_input.rs", "hits", @@ -431,11 +449,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 11] = [ +const PAGE_HIT_CALLS: [&str; 12] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "comments::context_hit(", "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", @@ -461,7 +480,7 @@ fn routed_press_handler(code: &str) -> bool { /// so what the cursor promised is what the press does. Where each module tested the press /// point again, or asked another whether it wanted the press, the answers disagreed: a press on /// one check box released over the next ticked the second, and a drag could be taken over -/// halfway. Only `PageHits::at` asks the hit tests at the press point. Escape is the one way to +/// halfway. Only `PageHits::hits` asks the hit tests. Escape is the one way to /// drop a press and ignore it until the button is up. #[test] fn page_modules_act_on_a_press_only_through_the_router() { @@ -469,7 +488,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { &ROUTER_WRITES, &ROUTER_WRITERS, "decides, keeps or drops the press the pages hold. Only the hits at the press point decide its owner, once \ - (src/canvas/page_router.rs): a page module reads `view.router.owner(page)`. Drop a press with \ + (src/canvas/page_router.rs), and the page view tells each module what the press is to its page (`PagePress`). Drop a press with \ `DocView::cancel_pointer_gestures`. A new place that must? Add the function to ROUTER_WRITERS in \ src/canvas/page_pointer/tests.rs, with why.", "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", @@ -499,8 +518,8 @@ fn page_modules_act_on_a_press_only_through_the_router() { problems.extend(misuses_in( page_module_matches(hit_test_calls), &HIT_TESTERS, - "calls a hit test. The hits at the press point decide its owner once, in `PageHits::at`: act on the press when \ - `view.router.owner(page)` says it is yours, rather than testing its point again. Testing where the pointer \ + "calls a hit test. The hits at the press point decide its owner once, in `PageHits::hits`: act on the press when \ + the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. Testing where the pointer \ is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", @@ -516,6 +535,15 @@ fn page_modules_act_on_a_press_only_through_the_router() { assert!(problems.is_empty(), "{}", problems.join("\n")); } +/// A test module in the middle of a file hides only itself from the page guards: the code after +/// it is read too. The guards used to stop at the first test module. +#[test] +fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() { + let source = "fn before() {}\n#[cfg(test)]\nmod tests {\n fn inside() {}\n}\n\nfn after() {}\n#[cfg(test)]\nmod more;\nfn last() {}\n"; + let kept: Vec<(usize, &str)> = outside_test_modules(source); + assert_eq!(kept, [(1, "fn before() {}"), (6, ""), (7, "fn after() {}"), (10, "fn last() {}")]); +} + #[test] fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { assert!(routed_press_handler("let owner = view.router.owner(page);")); diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 23eae443e9..447289ccd0 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -191,7 +191,8 @@ impl PageRouter { } } - /// Drops the press (Escape, a dialog, automation's cancel). + /// Drops the press (Escape, a dialog, automation's cancel): every page is the active tool's again + /// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up. pub(crate) fn cancel(&mut self) { self.capture = None; self.began = None; @@ -250,7 +251,8 @@ mod tests { assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free)); r.begin(); r.cancel(); - assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press is nobody's"); + // Page input skips a press Escape cancelled until the button is up (`press_cancelled`). + assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press leaves every page to the tool"); } #[test] From b1a0657c426076ca7576dfbf15853463c02f846d Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:29:46 +0100 Subject: [PATCH 11/49] fix(ui): right-clicks with Prepare, the Link tool and Add text as before Routing right-clicks by what lies on top under the pointer changed three cases beyond the rulings: - **Prepare.** A right-click while a field was dragged selected the field under the pointer, and the release then moved that one. Prepare used to ignore right-clicks during a drag; it does again (`prepare::context` returns while a grab is held). - **The Link tool.** A note over a link took the right-click, though the Link tool's press there is the link's. The Link tool's right-click on a link is the link's again, whatever lies over it. - **Add text.** Comments took right-clicks away from paragraphs and added items. Add text used to take every right-click on a page it can change; it does again. Tests: those three, and two that pin ruling 6's right-click half with the Select tool: no link menu on a link with no action, and none on a form field over a link. Each was seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 20 +++++++-- crates/ui-egui/src/tools/prepare.rs | 6 ++- crates/ui-egui/tests/it/pointer.rs | 58 +++++++++++++++++++++++++ crates/ui-egui/tests/it/prepare.rs | 37 ++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index a2f71d97c8..7f5f9dd154 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -316,9 +316,13 @@ fn tool_input( /// selected; links take theirs from what this returns. fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); - // Edit text's own paragraphs and pictures keep the right-click as they take it today, and a - // Prepare field tool the whole page. - let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some(); + // Add text takes every right-click on a page it can change, Edit text those on its own + // paragraphs and pictures, and a Prepare field tool the whole page, as before. + let editing_here = match hits.tool { + QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(), + QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(), + _ => false, + }; if !editing_here && hits.field_tool.is_none() { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), @@ -385,8 +389,16 @@ impl PageHits<'_> { /// What a right-click at `p` is on: what lies on top there, as for a press, but with every /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). + /// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as + /// its press there is. fn context_at(&self, view: &DocView, p: Pos2) -> Option { - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + let hits = self.hits(view, p, true); + if self.tool == QuickTool::Link + && let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. })) + { + return Some(link.owner); + } + top(hits, QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index ffd716c056..9c659352f9 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -462,11 +462,15 @@ pub(crate) fn page_input( /// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is /// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the -/// others line up with. +/// others line up with. Not while a field is dragged or a rectangle drawn: the release acts on +/// the selection the drag began with. pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) { let Some(f) = form.get(field) else { return }; let key = (f.name.clone(), widget); let prep = &mut view.prepare; + if prep.grab.is_some() || prep.ignore_release { + return; + } if let Some(i) = prep.also.iter().position(|k| *k == key) { prep.also.remove(i); if let Some(old) = prep.selected.replace(key) { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 6975a12a04..306f98accc 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1226,3 +1226,61 @@ fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() { rest_at(&mut h, on_field); assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press"); } + +/// With the Link tool a right-click on a link is the link's, as its press there is, though a note +/// lies over it: the Link tool edits links and reaches no comment. +#[test] +fn a_right_click_with_the_link_tool_on_a_note_over_a_link_selects_the_link() { + let mut h = open(note_over_link_pdf(), "150", false); + link_tool(&mut h); + let on_both = at(&h, 150.0, 325.0); + right_click(&mut h, on_both); + let v = &h.state().views[0]; + assert_eq!((v.links.selected, v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 0)), None), "the link, not the note"); +} + +/// With Add text a right-click leaves comments alone, as before: Add text takes the page's +/// right-clicks. +#[test] +fn a_right_click_with_add_text_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::AddText; + h.run_steps(4); + // Above the line of text the comment covers, where no paragraph is. + let on_comment = at(&h, 100.0, 317.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, None, "the comment under the pointer is not selected"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: a link that +/// does nothing has no menu. The Link tool's own test used to give it one. +#[test] +fn a_right_click_with_the_select_tool_on_a_link_with_no_action_opens_no_link_menu() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + right_click(&mut h, dead); + assert_eq!(h.state().views[0].links.context, None); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); +} + +/// [`link_pdf`], with a check box over the middle of its link. +fn field_over_link_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 6 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [140 315 160 335] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + ]) +} + +/// A right-click on a form field over a link is the field's, as a press there is: no link menu. +#[test] +fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() { + let mut h = open(field_over_link_pdf(), "150", false); + let on_field = at(&h, 150.0, 325.0); + right_click(&mut h, on_field); + assert_eq!(h.state().views[0].links.context, None, "the field is on top"); + assert!(h.query_by_label("Remove all links").is_none()); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 82e33b29de..281e3d9e18 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1562,3 +1562,40 @@ fn button_export_properties_apply_cancel_and_undo() { assert_eq!(f.export_of(0), Some(original.as_str())); } } + +/// The screen rectangle of widget 0 of field `name` on the first view. +fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") +} + +/// A right-click while a field is dragged leaves the dragged field selected, so the drag moves it +/// and not the field right-clicked. The right-click used to select the other field, and the release +/// moved that one. +#[test] +fn a_right_click_during_a_field_drag_moves_the_dragged_field() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + let (name, city) = (field_rect(&h, "name"), field_rect(&h, "city")); + let a = name.center(); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(a + egui::vec2(0.0, 30.0)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: city.center(), button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(1); + let end = a + egui::vec2(0.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(3); + assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was"); + assert_ne!(field_rect(&h, "name"), name, "the dragged field moved"); +} From b12d091496348088bde8bd592dd3d9e3fb3cc91f Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:35:25 +0100 Subject: [PATCH 12/49] fix(ui): hover painting beside the open palette, and one hit test under the pointer The page's hover (the top hit under the pointer) was only worked out when nothing was drawn over the pages, so with the palette open a field beside it lost its tooltip and its hover outline, and comments their outline. The palette keeps the page's cursor and tips away, which is what that gate is for; hover now ignores it, as the outlines and the field tooltip did before. The hits under the pointer were also asked up to four times a page each frame, each pass running every module's hit test: for a click's release, for Fill & Sign's preview, for a right-click, and for the cursor. A click's release, Fill & Sign's preview and a right-click's tool checks now share one pass, taken before the tools act and only on frames that need it. A right-click's own pass (comments by `context_hit`) stays, and the cursor and hover keep theirs, after the tools act. A unit test pins that cancelling the pages' gestures lets go of a camera corner. The kittest for Escape mid corner drag passes on `main` too, because Escape leaves the camera tool first. Tests: `a_fields_tooltip_shows_beside_the_open_palette`, `cancelling_pointer_gestures_lets_go_of_a_camera_corner`; each seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 35 +++++++++++++++---------- crates/ui-egui/src/canvas/tests.rs | 10 +++++++ crates/ui-egui/tests/it/pointer.rs | 14 ++++++++++ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 7f5f9dd154..f1d458257e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -95,15 +95,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); + // What lies under the pointer before the tools act, asked once: for a click's release, a + // right-click, and Fill & Sign's preview. + let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; + let under = pointer.filter(|_| wants_under).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { - PagePress::Mine(owner) if resp.clicked() => pointer.is_some_and(|p| hits.at(view, p).iter().any(|h| h.owner == owner)), + PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, }; - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. - let over_mark = matches!(tool, QuickTool::Fill(_)) - && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); + let over_mark = + matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -194,17 +198,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // leaves them unselected. let on_comment = comments::page_input(ui, resp, &pcx, press, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; - let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); + let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); PageInput { consumed, area_tool, hover, released_over } } -/// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs -/// (the last claim on a layer stands). Returns the top of it, what a press there would take, for -/// hover painting. -fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { +/// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, +/// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor +/// and tip too, after the tools claimed theirs (the last claim on a layer stands). +fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option { let under = hits.at(view, p); - for h in &under { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + if claim { + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + } } top(under, hits.tool) } @@ -314,13 +320,14 @@ fn tool_input( /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. -fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { +/// `under`: the hits at `p` before the tools act. +fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); // Add text takes every right-click on a page it can change, Edit text those on its own // paragraphs and pictures, and a Prepare field tool the whole page, as before. let editing_here = match hits.tool { - QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(), - QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(), + QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), + QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), _ => false, }; if !editing_here && hits.field_tool.is_none() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4761584735..02aeeacd09 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2037,3 +2037,13 @@ fn image_handles_account_for_document_and_view_rotation() { } } } + +/// Cancelling the pages' pointer gestures (Escape, a dialog, automation's cancel) lets go of a +/// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer. +#[test] +fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { + let mut v = view(1, PageLayout::Single, true); + v.camera = Some(crate::camera_ui::CameraQuad { grab: Some(2), ..crate::camera_ui::CameraQuad::inset(0, 300.0, 400.0) }); + v.cancel_pointer_gestures(); + assert_eq!(v.camera.map(|q| q.grab), Some(None)); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 306f98accc..0e5a7e3af1 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1284,3 +1284,17 @@ fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() { assert_eq!(h.state().views[0].links.context, None, "the field is on top"); assert!(h.query_by_label("Remove all links").is_none()); } + +/// While the palette is open, a field beside it still shows its tooltip, as before: the palette +/// keeps the page's own tips away, not egui's tooltip of a field. +#[test] +fn a_fields_tooltip_shows_beside_the_open_palette() { + let mut h = open(commented_text_field_pdf(), "150", false); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); + let on_field = at(&h, 60.0, 210.0); + assert!(!palette.expand(20.0).contains(on_field), "the field is beside the palette {palette:?}"); + rest_at(&mut h, on_field); + assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows"); +} From ce90c81ffb065e2d4f48bcb0926732c92773e2e1 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:06:29 +0100 Subject: [PATCH 13/49] fix(ui): a drag seen past its page's edge still draws on that page egui reports a drag once the pointer has moved a few points from the press. A press near a page's bottom edge, flicked onto the next page in one frame, is seen as a drag only with the pointer on the next page. Redact (and the area Highlight), the Link tool and Prepare returned before their drag start unless the pointer was on their own page, and the next page is `Elsewhere`, so the press drew nothing anywhere. (On main the next page took it, with the box's start on the page before.) They now start the gesture from where the press began on their own page, as zoom, snapshot, crop and Sign area already did, so the box, link rectangle, marquee, field drawn or field moved belongs to the page the press began on. Tests: one per tool, each seen failing with the fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/link_ui.rs | 19 ++++--- crates/ui-egui/src/tools/prepare.rs | 73 +++++++++++++-------------- crates/ui-egui/src/tools/redact_ui.rs | 20 ++++---- crates/ui-egui/tests/it/pointer.rs | 58 +++++++++++++++++++++ 4 files changed, 117 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index d380d1b71b..9cd62d6969 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -108,7 +108,19 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, free: bool, owner: Option form.get(field).map(|f| (f, widget)), _ => None, }; - if resp.drag_started() && (held || free) { - let origin = ptr.origin.unwrap_or(p); - if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { + // A drag starts from where its press began on this page: egui may see it only once the pointer + // is past the page's edge. + if allowed + && resp.drag_started() + && (held || free) + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o)) + { + out.consumed = true; + if tool.is_some() { + prep.grab = Some((page, Grab::Draw(origin), origin)); + } else if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { prep.grab = Some((page, Grab::Corner(left, top), origin)); - out.consumed = true; } else if let Some((f, wi)) = hit { prep.selected = Some((f.name.clone(), wi)); prep.grab = Some((page, Grab::Move, origin)); - out.consumed = true; } else { prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect()); if !ui.input(|i| i.modifiers.shift || i.modifiers.command) { @@ -432,7 +409,29 @@ pub(crate) fn page_input( prep.also.clear(); } prep.grab = Some((page, Grab::Select(origin), origin)); + } + return out; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; + if !allowed { + return out; + } + if let Some(tool) = tool { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // A click without a drag places the field at once. + if free && resp.clicked() { out.consumed = true; + if tool == FieldTool::TextTable { + out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO)))); + out.placed = true; + return out; + } + let at = to_user(xf, info, page, p); + let (w, h) = tool.default_size(); + let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); + prep.select_added = Some(form.len()); + view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None }); + out.placed = true; } return out; } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 0cca746bf8..345e7e5d77 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -252,17 +252,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo } return true; } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - if !over_text(p) { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own press, of the primary button: one nobody holds, which began on this page off + // its text. egui may see the drag only once the pointer is past the page's edge. + if resp.drag_started_by(egui::PointerButton::Primary) + && free + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o) && !over_text(*o)) + { + view.redact_drag = Some((page, origin)); + return true; } - // The tool's own press, of the primary button: one nobody holds. - if resp.drag_started_by(egui::PointerButton::Primary) && free { - let origin = ptr.origin.unwrap_or(p); - if !over_text(origin) { - view.redact_drag = Some((page, origin)); - return true; - } + if pointer.is_some_and(|p| xf.rect.contains(p) && !over_text(p)) { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } false } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 0e5a7e3af1..8a020cc4ea 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1298,3 +1298,61 @@ fn a_fields_tooltip_shows_beside_the_open_palette() { rest_at(&mut h, on_field); assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows"); } + +/// Two blank 300×400 pages. +fn two_pages_pdf() -> Vec { + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + ]) +} + +/// Sets a tool up (`tool`), presses above page 1's bottom edge and drags onto page 2 in one frame, +/// so egui sees the drag only once the pointer is on page 2, and returns the app after the release. +fn drag_seen_only_past_page_ones_edge(tool: impl FnOnce(&mut PdfCraftApp)) -> Harness<'static, PdfCraftApp> { + let mut h = open(two_pages_pdf(), "100", false); + tool(h.state_mut()); + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let press = egui::pos2(one.min.x + 50.0, one.max.y - 30.0); + let release = egui::pos2(one.min.x + 150.0, two.min.y + 20.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(release); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); + h +} + +// A drag seen only past its page's edge still draws on the page its press began on, as zoom, crop +// and Sign area do. With these three it used to draw nothing anywhere. + +#[test] +fn a_redaction_box_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Redact); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.info.annotations.iter().any(|a| a.subtype == "Redact" && a.page == 0), "a redaction mark on page 1"); +} + +#[test] +fn a_link_rectangle_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Link); + assert_eq!(h.state().link_draft.as_ref().map(|d| (d.page, d.index)), Some((0, None)), "a new link on page 1"); +} + +#[test] +fn a_field_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| assert!(app.execute("form.add.text"), "Prepare a form, with the text field tool")); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1"); +} From 6557d6edc5d8761dcc26394391f7f7e52f12a62e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:12:47 +0100 Subject: [PATCH 14/49] test(ui): the page guard holds told modules to what they are told The modules that are now told what a press is to their page (forms, Prepare, comments, the Link tool, the camera, the stamps and the other free-press tools) left the press-handler list, whose check was only that each read `router.owner(` and called no hit test of its own. Nothing kept them from reading the router again, though CLAUDE.md said the guard enforced it. - Rule (e) now: `router.press_for(`, `router.owner(` and `router.keeps_drag(` are read only by `ROUTER_READERS`: the page view, and the Edit text group and text layer until 4b tells them too. An unused entry fails the test, so 4b removes them one by one. - A hit test called as a method counts as a call: `cx.hit(origin)` or `self.foo_hit(p)` in a press handler passed. `PageCx::hit`, which finds a comment and makes no `Hit`, is renamed `comment_at`. - An entry of `PAGE_HIT_CALLS` that no page module calls any more fails the test. Each was seen: a `view.router.owner(page)` and a `cx.hit(origin)` planted in forms' `page_input` pass the guard before and fail it after, and a stale `PAGE_HIT_CALLS` entry fails it. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/canvas/page_pointer/tests.rs | 42 +++++++++++++++++-- crates/ui-egui/src/tools/comments/input.rs | 4 +- crates/ui-egui/src/tools/comments/paint.rs | 2 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 69abae12cf..dd37e44373 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -418,15 +418,14 @@ fn is_hit_test(name: &str) -> bool { name == "hit" || name.ends_with("_hit") } -/// The hit tests `line` calls, by path or bare. A method, after its `.`, is left out: -/// `PageCx::hit` finds the comment under a point, not a `Hit`. +/// The hit tests `line` calls: by path, bare or as a method. fn hit_test_calls(line: &str) -> Vec { line.match_indices("hit(") .filter_map(|(at, _)| { let end = at + "hit".len(); let start = line[..end].char_indices().rev().find(|(_, c)| !(c.is_alphanumeric() || *c == '_')).map_or(0, |(i, c)| i + c.len_utf8()); let (before, name) = (&line[..start], &line[start..end]); - (is_hit_test(name) && !before.ends_with('.') && !before.ends_with("fn ")).then(|| name.to_owned()) + (is_hit_test(name) && !before.ends_with("fn ")).then(|| name.to_owned()) }) .collect() } @@ -440,6 +439,27 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; +/// What reads who holds the press the pages hold. +const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; + +/// The functions that read who holds the press: (file, function, why). The page view asks once per +/// page and tells each module what the press is to its page (`PagePress`); the Edit text group and +/// the text layer still ask, until they are told too (PR 4b), and an unused entry fails the test. +const ROUTER_READERS: [(&str, &str, &str); 8] = [ + ( + "canvas/page_input.rs", + "page_tool_input", + "the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b", + ), + ("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"), + ("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"), + ("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"), + ("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"), + ("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"), + ("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"), + ("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"), +]; + /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -493,6 +513,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", ); + problems.extend(misuses( + &ROUTER_READS, + &ROUTER_READERS, + "reads who holds the press the pages hold. The page view asks the router once per page and tells each module \ + what the press is to its page: take a `PagePress` (src/canvas/page_router.rs) instead. A new place that must? \ + Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer reads who holds the press: remove it from ROUTER_READERS.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, @@ -524,6 +552,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); + // An entry no page module calls any more no longer guards anything. + let called = page_module_uses(&PAGE_HIT_CALLS); + problems.extend( + PAGE_HIT_CALLS + .iter() + .filter(|call| !called.iter().any(|(_, _, _, name)| name == *call)) + .map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")), + ); let builds = page_module_uses(&HIT_BUILDS); assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 7ada11bc7f..396aea8b13 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -90,7 +90,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Handle(x, y) }, Layer::Handle, icon)); } } - let a = cx.hit(p).filter(|a| bodies && pick(a))?; + let a = cx.comment_at(p).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; @@ -111,7 +111,7 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } - let a = cx.hit(p)?; + let a = cx.comment_at(p)?; let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index e784c332ae..8faae3d02e 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -24,7 +24,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the // marked area reads before anything is applied. if cv.gesture.is_none() - && let Some(a) = pointer.and_then(|p| cx.hit(p)) + && let Some(a) = pointer.and_then(|p| cx.comment_at(p)) && a.subtype == "Redact" { let ([r, g, b], opacity) = cx.redact_mark; diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 07452f5b49..9c876d950f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -384,7 +384,7 @@ impl PageCx<'_> { /// The topmost comment under `p`: the one drawn last, in `/Annots` order (inspection lists /// comments by position, not in paint order). - pub(super) fn hit(&self, p: Pos2) -> Option<&Annotation> { + pub(super) fn comment_at(&self, p: Pos2) -> Option<&Annotation> { self.comments().filter(|a| self.screen_rects(a).any(|r| r.expand(3.0).contains(p))).max_by_key(|a| a.index) } From ae62b66304a3e4b089fe7d3da7138cc5d44b885c Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:16:14 +0100 Subject: [PATCH 15/49] fix(ui): right-clicks with the Link tool and the camera as before; true docs Routing right-clicks changed a few more cases beyond the rulings, found in review: - **The Link tool.** A right-click on a link also unselected the selected comment and moved the page menu's anchor; the Link tool used to take it whole. On a document it can't change, its right-click on a note over a link opened the plain page menu instead of the note's. Now the Link tool's right-click is what its press there would take, on a document it can change, and on a link it leaves comments alone. - **The camera.** A corner, on the handle layer, took right-clicks from a comment under it, though it has no menu. Corners are no longer right-click targets. - **The Link tool's hand** showed in the 2 px margin past the page's edge, where a press is no page's. `link_ui::hit` now finds nothing off the page. Docs made true: what a comment's press is (`comments::page_input`), what reaches the free-press gate (`page_tool_input`), and when every page is `PagePress::Free`. Tests: those three, and two that pin right-clicks kept as before: Edit text's on a paragraph under a comment, and a Prepare field tool's. Each seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 34 +++++---- crates/ui-egui/src/canvas/page_router.rs | 3 +- crates/ui-egui/src/tools/comments/input.rs | 5 +- crates/ui-egui/src/tools/link_ui.rs | 4 + crates/ui-egui/tests/it/pointer.rs | 85 ++++++++++++++++++++++ 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f1d458257e..eda260451d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -35,9 +35,10 @@ pub(crate) struct PageToolContext<'a> { pub info: &'a DocInfo, } -/// The tools' input on one page. The hits at the press point decide who holds the press, and each -/// module is told what the press is to this page (`PagePress`): its own, nobody's (the active tool's, -/// through `tool_input`), or another page's. Text selection runs after, on what nobody took. +/// The tools' input on one page. The hits at the press point decide who holds the press, and the +/// modules are told what the press is to this page (`PagePress`): their own, nobody's (the active +/// tool's: `tool_input`, and the tools still called here), or another page's. The Edit text group +/// still asks the router, until it is told too. Text selection runs after, on what nobody took. pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { let PageEnv { added, @@ -324,13 +325,15 @@ fn tool_input( fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures, and a Prepare field tool the whole page, as before. - let editing_here = match hits.tool { + // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole + // page, as before: comments see none of them. + let tool_takes_it = match hits.tool { QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), - _ => false, + QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), + _ => hits.field_tool.is_some(), }; - if !editing_here && hits.field_tool.is_none() { + if !tool_takes_it { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, @@ -396,16 +399,16 @@ impl PageHits<'_> { /// What a right-click at `p` is on: what lies on top there, as for a press, but with every /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). - /// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as - /// its press there is. + /// The Link tool edits links and reaches no comment: on a document it can change, its + /// right-click is what its press there would take, when that is anything. fn context_at(&self, view: &DocView, p: Pos2) -> Option { - let hits = self.hits(view, p, true); if self.tool == QuickTool::Link - && let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. })) + && self.can_modify + && let Some(press) = top(self.hits(view, p, false), QuickTool::Link) { - return Some(link.owner); + return Some(press.owner); } - top(hits, QuickTool::Select).map(|h| h.owner) + top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text @@ -413,7 +416,8 @@ impl PageHits<'_> { /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; /// then form fields (Prepare's, being moved, over the comments; filled ones under added content /// and the comments that move). In crop mode, only the picture being cropped. - /// For a right-click (`context`), comments by [`comments::context_hit`]. + /// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it + /// has no menu). fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { page, @@ -475,7 +479,7 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - let corner = (tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); + let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 447289ccd0..0b53038625 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -115,7 +115,8 @@ pub(crate) enum PagePress { /// The press began on this page, and the owner's hit won it: only its module acts on it. Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. + /// tool's. Also every page's while a press that began off the pages is held, and after the + /// press was dropped ([`PageRouter::cancel`]) until the button is up. Free, /// The press began on another page, or between the pages. Elsewhere, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 396aea8b13..52319276da 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -9,8 +9,9 @@ use crate::page_cursor::{Layer, Subject}; use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// Comment input on one page, before text selection runs. `press` is what this page has to do with -/// the press the pages hold: a press on a comment ([`hit`]) is that comment's, with any tool, and -/// one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to +/// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select +/// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that +/// comment's, and one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to /// commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 9cd62d6969..659bad41e4 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -66,6 +66,10 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// picked, and a drag from one draws a new link. The other tools find the links the renderer can /// follow, which have a target (`info.links`). The tip is the link's target. pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem], tool: QuickTool, p: Pos2) -> Option { + // A press off the page is no page's, so nothing there is a link's, the margin included. + if !xf.rect.contains(p) { + return None; + } let index = if tool == QuickTool::Link { links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index } else { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 8a020cc4ea..04d98b9318 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1356,3 +1356,88 @@ fn a_field_dragged_off_its_page_is_drawn_on_it() { let doc = s.session.get(s.views[0].id).unwrap(); assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1"); } + +/// With the Link tool a right-click on a link is the link's and leaves comments as they are, as +/// before: the selected comment stays selected. +#[test] +fn a_right_click_with_the_link_tool_on_a_link_keeps_the_selected_comment() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + mouse_click(&mut h, on_note); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note is selected"); + link_tool(&mut h); + // On the link, away from the note. + let on_link = at(&h, 70.0, 325.0); + right_click(&mut h, on_link); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 1))), "the link's menu; the note stays selected"); +} + +/// A 300×400 page with a rectangle comment round the camera tool's first corner (view 15, 20). +fn comment_at_camera_corner_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [5 365 45 395] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// A camera corner has no menu: a right-click on a comment under it opens the comment's, as +/// before. +#[test] +fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() { + let mut h = open(comment_at_camera_corner_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + let corner = at(&h, 15.0, 380.0); + right_click(&mut h, corner); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); +} + +/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350. +fn edge_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 300 100 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// The Link tool's margin round a link stops at the page's edge: beside the page, where a press +/// does nothing, no hand promises one. +#[test] +fn the_link_tools_hand_stops_at_the_pages_edge() { + let mut h = open(edge_link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 10.0, 325.0); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "on the link"); + let beside = at(&h, 0.0, 325.0) - egui::vec2(1.0, 0.0); + assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page"); +} + +/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies +/// over it: the comment is not selected. +#[test] +fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let on_text = at(&h, 100.0, 303.0); + wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); + right_click(&mut h, on_text); + assert_eq!(h.state().views[0].comments.selected, None); +} + +/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not +/// selected. +#[test] +fn a_right_click_with_a_field_tool_selects_no_comment() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.add.text")); + h.run_steps(4); + let on_comment = at(&h, 200.0, 125.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, None); +} From 47ece03a5465dd22355c88c001b583d925b811fc Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:48:54 +0100 Subject: [PATCH 16/49] fix(ui): the Link tool's click told too; a moved comment keeps its selection Found in review: - **The Link tool's click** acted on any press: with Prepare open, a click on a field also unselected the selected link, and a click that began off the page did too. It now acts only on a press on a link, or one nobody holds. - **A right-click while a comment is moved** selected the comment under it mid-drag, though the release still moved the first. Comments now keep their selection during a move, resize or reshape, as Prepare does. - The hits under the pointer before the tools act are taken only on the pointer's page, and the Link tool's right-click reuses them. - The router has one source for the press's page (`Capture` no longer carries its own). - The guard lists which functions may ask which comment lies under a point (`comment_at`); the guard finds hit tests by name, and its docs say so. - Docs made true: what `PagePress::Mine` promises, what `tool_input` gates, and that a dialog does not cancel the pages' press (only Escape and automation's cancel do). Tests: the two fixes, and ruling 1 for added text (a click on it unselects the comment), each seen failing with its fix reverted; the drawing-tool right-click test now checks the comment's menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 19 ++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 ++++++- crates/ui-egui/src/canvas/page_router.rs | 12 ++-- crates/ui-egui/src/canvas/tests.rs | 2 +- crates/ui-egui/src/tools/link_ui.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 64 ++++++++++++++++++- 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index eda260451d..248b5a767f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -99,7 +99,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What lies under the pointer before the tools act, asked once: for a click's release, a // right-click, and Fill & Sign's preview. let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; - let under = pointer.filter(|_| wants_under).map(|p| hits.at(view, p)).unwrap_or_default(); + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -227,7 +227,8 @@ fn link_of(owner: Option) -> Option { /// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the /// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor -/// and gestures. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// and gestures. The camera also drags a corner whose press it holds, and crop's double-click +/// opens Set Page Boxes wherever it is. `over_mark`: a Fill & Sign mark takes the press, or would take one under the /// pointer. fn tool_input( ui: &egui::Ui, @@ -323,7 +324,7 @@ fn tool_input( /// selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { - let context = hits.context_at(view, p); + let context = hits.context_at(view, p, under); // Add text takes every right-click on a page it can change, Edit text those on its own // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole // page, as before: comments see none of them. @@ -333,7 +334,10 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - if !tool_takes_it { + // A comment being moved, resized or reshaped keeps its selection until its release. + let comment_dragged = + matches!(view.comments.gesture, Some(comments::Gesture::Move { .. } | comments::Gesture::Resize { .. } | comments::Gesture::Reshape { .. })); + if !tool_takes_it && !comment_dragged { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, @@ -400,11 +404,12 @@ impl PageHits<'_> { /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). /// The Link tool edits links and reaches no comment: on a document it can change, its - /// right-click is what its press there would take, when that is anything. - fn context_at(&self, view: &DocView, p: Pos2) -> Option { + /// right-click is what its press there would take (`under`, the hits at `p`), when that is + /// anything. + fn context_at(&self, view: &DocView, p: Pos2, under: &[crate::page_router::Hit]) -> Option { if self.tool == QuickTool::Link && self.can_modify - && let Some(press) = top(self.hits(view, p, false), QuickTool::Link) + && let Some(press) = top(under.iter().cloned(), QuickTool::Link) { return Some(press.owner); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index dd37e44373..5919fdae80 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -407,7 +407,7 @@ const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.sett const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), - ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"), + ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, automation's cancel"), ]; /// Ways to make a `Hit`. @@ -430,7 +430,9 @@ fn hit_test_calls(line: &str) -> Vec { .collect() } -/// The functions that call a hit test: (file, function, why). `PageHits::hits` is the one: the hits +/// The functions that call a hit test: (file, function, why). Hit tests are found by name (a +/// function named `hit` or `…_hit`, called by path, bare or as a method); a point test by another +/// name has a list of its own (`COMMENT_POINT_TESTERS`). `PageHits::hits` is the one: the hits /// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover /// painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( @@ -439,6 +441,16 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; +/// The comments' point test, which their hit tests are built on: which comment lies under a point. +const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; + +/// The functions that ask which comment lies under a point: (file, function, why). +const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ + ("tools/comments/input.rs", "hit", "the comments' hit test"), + ("tools/comments/input.rs", "context_hit", "the comments' hit test for a right-click"), + ("tools/comments/paint.rs", "paint_page", "a redaction mark shows its fill under the pointer with every tool, as comments' tips show"), +]; + /// What reads who holds the press the pages hold. const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; @@ -521,6 +533,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", "no longer reads who holds the press: remove it from ROUTER_READERS.", )); + problems.extend(misuses( + &COMMENT_POINT_TESTS, + &COMMENT_POINT_TESTERS, + "asks which comment lies under a point. The comments' hit tests answer that for a press and a right-click: \ + act on what the page view tells you, rather than testing the point again. A new place that must? Add the \ + function to COMMENT_POINT_TESTERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer asks which comment lies under a point: remove it from COMMENT_POINT_TESTERS.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 0b53038625..be83411072 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -112,7 +112,8 @@ pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Optio /// What one page has to do with the press the pages hold. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PagePress { - /// The press began on this page, and the owner's hit won it: only its module acts on it. + /// The press began on this page, and the owner's hit won it: its module acts on it. Others + /// only let go of their own state (a click on anything but the selected comment unselects it). Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active /// tool's. Also every page's while a press that began off the pages is held, and after the @@ -135,7 +136,6 @@ impl PagePress { /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Capture { - pub page: usize, pub owner: Owner, pub declines_drag: bool, } @@ -159,7 +159,7 @@ impl PageRouter { /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { - self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); self.began = Some(Some(page)); } @@ -167,7 +167,7 @@ impl PageRouter { pub(crate) fn press_for(&self, page: usize) -> PagePress { match (self.began, &self.capture) { (None, _) => PagePress::Free, - (Some(Some(p)), Some(c)) if p == page => PagePress::Mine(c.owner), + (Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner), (Some(Some(p)), None) if p == page => PagePress::Free, _ => PagePress::Elsewhere, } @@ -175,7 +175,7 @@ impl PageRouter { /// Who holds the press on `page`. pub(crate) fn owner(&self, page: usize) -> Option { - self.capture.as_ref().filter(|c| c.page == page).map(|c| c.owner) + self.press_for(page).owner() } /// Whether the press is held by someone who keeps its drag: then the page's text is not @@ -192,7 +192,7 @@ impl PageRouter { } } - /// Drops the press (Escape, a dialog, automation's cancel): every page is the active tool's again + /// Drops the press (Escape, automation's cancel): every page is the active tool's again /// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up. pub(crate) fn cancel(&mut self) { self.capture = None; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 02aeeacd09..c0a797fa67 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2038,7 +2038,7 @@ fn image_handles_account_for_document_and_view_rotation() { } } -/// Cancelling the pages' pointer gestures (Escape, a dialog, automation's cancel) lets go of a +/// Cancelling the pages' pointer gestures (Escape, automation's cancel) lets go of a /// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer. #[test] fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 659bad41e4..069564afff 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -132,7 +132,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option Vec { } /// The Link tool's margin round a link stops at the page's edge: beside the page, where a press -/// does nothing, no hand promises one. +/// does nothing, no hand promises one (as before the margin moved into the one link test). #[test] fn the_link_tools_hand_stops_at_the_pages_edge() { let mut h = open(edge_link_pdf(), "150", false); @@ -1441,3 +1442,64 @@ fn a_right_click_with_a_field_tool_selects_no_comment() { right_click(&mut h, on_comment); assert_eq!(h.state().views[0].comments.selected, None); } + +/// With the Link tool a click that began off the page (in the gutter beside it) is no page's: the +/// selected link stays selected. It used to unselect it. +#[test] +fn a_click_that_began_off_the_page_keeps_the_selected_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the link is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, on_link.y), egui::pos2(left + 3.0, on_link.y)); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "a click that began off the page leaves it selected"); +} + +/// A right-click while a comment is moved leaves the moved comment selected: the move ends on +/// it. The right-click used to select the comment under it mid-drag. +#[test] +fn a_right_click_during_a_comment_move_keeps_the_moved_comment_selected() { + let mut h = open(stacked_comments_pdf(), "150", false); + // "Bottom note" alone, then "Top note" alone. + let (bottom, top) = (at(&h, 60.0, 160.0), at(&h, 190.0, 290.0)); + h.hover_at(bottom); + h.run_steps(1); + h.drag_at(bottom); + h.run_steps(1); + h.hover_at(bottom + egui::vec2(0.0, 20.0)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: top, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + let end = bottom + egui::vec2(0.0, 40.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the moved comment stays selected"); +} + +/// A click on added text leaves the selected comment unselected, as a click on a form field does +/// (ruling 1). It used to keep it selected. +#[test] +fn a_click_on_added_text_deselects_the_selected_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + // The Edit panel open, with the Select tool: added text is picked up. + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(3); + let on_comment = at(&h, 150.0, 315.0); + mouse_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + let on_added = at(&h, 90.0, 202.0); + mouse_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected"); +} From aa415811d7462d17978a9f17f3b6190681cc8710 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 14:22:54 +0100 Subject: [PATCH 17/49] fix(ui): right-clicks leave a held press's selections alone; guard finds more page modules Found in review: - **Right-clicks during a press.** A comment being moved kept its selection only while its gesture state was live, so a gesture left over (a release the pages never saw) blocked every later right-click. Comments and Prepare now leave their selection alone while the primary button is held. egui ends the press's own interaction (and its drag) when another button goes down, so the button is what tells. A touch's long press, which egui reports as a right-click with the finger still down, still opens its menu. - **A click that began beside the page** unselected the selected comment, though the Link tool and Prepare now leave theirs alone for such a click. Comments do the same. - **The guard** took a file as a page module only if it named `PagePointer`; a file using `PageToolContext`, `PagePress` or the router's hits or owners is one too. Its message about tooltips and release checks now points at the page view's own hits, and its doc of the router's readers names link following. Tests: a right-click with a gesture left over, a click that began beside the page, and a long press on a comment. Each was seen failing with its fix reverted. A planted page-router name in a module the guard didn't list fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 28 ++++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 +++++++---- crates/ui-egui/src/tools/comments/input.rs | 19 +++++---- crates/ui-egui/tests/it/pointer.rs | 41 +++++++++++++++++++ 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 248b5a767f..4a9ed3a54e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -104,7 +104,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, }; - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p)); + // A right-click while the primary button is held (a comment moved, a box drawn) leaves the + // selections that press acts on alone; egui ends the press's own interaction when another button + // goes down, so the button is what tells. A touch's long press, which egui reports as a + // right-click with the finger still down, opens its menu. + let press_held = ptr.primary_down && !resp.long_touched(); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = @@ -322,29 +327,30 @@ fn tool_input( /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. -/// `under`: the hits at `p` before the tools act. -fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { +/// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the +/// selections its press acts on are left alone until its release. +fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { let context = hits.context_at(view, p, under); - // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole - // page, as before: comments see none of them. + // Add text takes every right-click on a page it can change, Edit text those on anything of its + // own (paragraphs, pictures, added content, the objects selected together and their grips), the + // Link tool those on a link, and a Prepare field tool the whole page, as before: comments see + // none of them. let tool_takes_it = match hits.tool { QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - // A comment being moved, resized or reshaped keeps its selection until its release. - let comment_dragged = - matches!(view.comments.gesture, Some(comments::Gesture::Move { .. } | comments::Gesture::Resize { .. } | comments::Gesture::Reshape { .. })); - if !tool_takes_it && !comment_dragged { + if !tool_takes_it && !press_held { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; comments::context(hits.pcx, p, comment, view); } - if let Some(Owner::PrepareField { field, widget }) = context { + if let Some(Owner::PrepareField { field, widget }) = context + && !press_held + { crate::prepare::context(hits.form, field, widget, view); } context diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 5919fdae80..1c41858651 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -190,6 +190,13 @@ const PAGE_MODULES: [&str; 37] = [ "tools/stamps_ui.rs", ]; +/// What a file that acts on the pages uses: the pointer, a tool's page context, what a press is to +/// a page, or the router's hits and owners. Such a file is a page module. +const PAGE_MODULE_MARKS: [&str; 4] = ["PagePointer", "PageToolContext", "PagePress", "page_router::"]; + +/// The router's own files, which define what page modules use. +const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; + /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. const RAW_READS: [&str; 21] = [ "hover_pos()", @@ -355,7 +362,7 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { NOT_PAGE_INPUT there, with why.", "reads no pointer state any more: remove it from NOT_PAGE_INPUT.", ); - // A module that takes the page pointer is a page module, so it is checked too. + // A module that acts on the pages is a page module, so it is checked too. let mut dirs = vec![src.clone()]; while let Some(dir) = dirs.pop() { for entry in std::fs::read_dir(dir).expect("source directory") { @@ -375,8 +382,9 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { let file = path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); let source = std::fs::read_to_string(&path).expect("source file"); - if file != "canvas/page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) { - problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES.")); + let marks = PAGE_MODULE_MARKS.iter().filter(|mark| source.contains(**mark)).collect::>(); + if !ROUTER_FILES.contains(&file.as_str()) && !marks.is_empty() && !PAGE_MODULES.contains(&file.as_str()) { + problems.push(format!("src/{file} uses {marks:?}, so it acts on the pages: add it to PAGE_MODULES.")); } } } @@ -455,8 +463,9 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; /// The functions that read who holds the press: (file, function, why). The page view asks once per -/// page and tells each module what the press is to its page (`PagePress`); the Edit text group and -/// the text layer still ask, until they are told too (PR 4b), and an unused entry fails the test. +/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the +/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry +/// fails the test. const ROUTER_READERS: [(&str, &str, &str); 8] = [ ( "canvas/page_input.rs", @@ -567,8 +576,9 @@ fn page_modules_act_on_a_press_only_through_the_router() { page_module_matches(hit_test_calls), &HIT_TESTERS, "calls a hit test. The hits at the press point decide its owner once, in `PageHits::hits`: act on the press when \ - the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. Testing where the pointer \ - is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ + the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. What lies under \ + the pointer comes from the page view's own hits: hover painting and tips (`PageInput.hover`), a click released \ + over what it was pressed on (`released_over`). A new place that must ask? Add the function to HIT_TESTERS in \ src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 52319276da..4c026604ea 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -35,14 +35,14 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { // The selected shape's handles, and Fill & Sign's marks, take their own presses. - QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, owned, pointer, origin), + QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin), 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, owned, pointer, origin), + QuickTool::Select => select_input(ui, resp, cx, view, press, pointer, origin), _ => false, } } @@ -160,18 +160,22 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option } /// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the -/// part of it (`owned`, from [`hit`]). The cursor comes from [`hit`] too. A click on anything else -/// (a link, a field, added text, the page) leaves comments unselected. +/// part of it (`press`, from [`hit`]). The cursor comes from [`hit`] too. A click on this page on +/// anything else (a link, a field, added text, the page) leaves comments unselected; one that +/// began off the pages is no page's. fn select_input( ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView, - owned: Option<(usize, CommentPart)>, + press: PagePress, pointer: Option, origin: Option, ) -> bool { - let owner = owned.and_then(|(index, part)| cx.get(index).map(|a| (a, part))); + let owner = match press { + PagePress::Mine(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), + _ => None, + }; let cv = &mut view.comments; let mut consumed = false; if resp.drag_started() @@ -255,7 +259,8 @@ fn select_input( cv.reveal = true; consumed = true; } - None => cv.selected = None, + None if press != PagePress::Elsewhere => cv.selected = None, + None => {} } } if resp.double_clicked() diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d318818304..6685085614 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1503,3 +1503,44 @@ fn a_click_on_added_text_deselects_the_selected_comment() { mouse_click(&mut h, on_added); assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected"); } + +/// A comment gesture left over with no button down (a release the pages never saw) does not keep +/// a right-click from selecting the comment under it. +#[test] +fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() { + let mut h = open(stacked_comments_pdf(), "150", false); + let bottom = at(&h, 60.0, 160.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom }); + let top = at(&h, 190.0, 290.0); + right_click(&mut h, top); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected"); +} + +/// A click that began off the page (in the gutter beside it) is no page's: the selected comment +/// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it. +#[test] +fn a_click_that_began_off_the_page_keeps_the_selected_comment() { + let mut h = open(stacked_comments_pdf(), "150", false); + let bottom = at(&h, 60.0, 160.0); + mouse_click(&mut h, bottom); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the note is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, bottom.y), egui::pos2(left + 3.0, bottom.y)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a click that began off the page leaves it selected"); +} + +/// A touch's long press on a comment selects it for its menu, as a right-click does: egui reports +/// it as a right-click with the finger still down, which holds no drag. +#[test] +fn a_long_press_on_a_comment_selects_it_for_its_menu() { + let mut h = open(stacked_comments_pdf(), "150", false); + let top = at(&h, 190.0, 290.0); + let touch = |phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [touch(egui::TouchPhase::Start, top), egui::Event::PointerMoved(top), button(top, true)]); + // Held still, past egui's long-press time. + h.run_steps(6); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the finger is selected"); + one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]); + h.run_steps(2); +} From f0caaf9259cf37fcb892b664e6aaa4890a52280e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 17:46:08 +0100 Subject: [PATCH 18/49] fix(ui): a press Prepare or added content took draws no comment; menus open where right-clicked Found in review: - **Comments acted on presses another tool had taken.** Comments run every frame now, and a commenting tool acted on any press nobody held, so with Prepare open a Rectangle drag drew a rectangle as well as Prepare's selection rectangle. Added content, with the Edit panel open, could likewise open a note composer under the Note tool. `main` skipped comments when another module took the frame. They are now told (`taken`), and their drawing and placing tools leave such a press alone. A click there still unselects. - **The page menu's anchor went stale.** A right-click while the primary button was held skipped the call that also anchors the page menu, so Delete page, Paste and Copy Image acted on the page right-clicked before. Every right-click on a page now anchors the menu there, whoever takes it, and only the selections wait for the release. That includes the right-clicks Add text, Edit text, the Link tool and a field tool take, which used to keep the previous anchor. - **Edit text's right-clicks.** Edit text took right-clicks on added content too. It now takes those on its paragraphs, pictures and the objects selected together, and comments see a right-click on added text, as before. - **Camera corners.** A camera corner at the page's edge showed its grab cursor past the edge, where a press is no page's. Its hit now stops at the page, as a link's does. - One source for a comment's layer, dead code in Add text's right-click, and true docs for `PagePress::Elsewhere` and the "Page presses" rule. Tests: a shape-tool drag with Prepare open, a right-click with the button held on another page, a right-click on added text with Edit text, the menu anchored by an Edit text right-click, and a camera corner at the page's edge. Each was seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas/page_input.rs | 40 ++++++++++-------- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 11 +++++ crates/ui-egui/src/tools/camera_ui.rs | 7 ++-- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 35 ++++++++++------ crates/ui-egui/tests/it/pointer.rs | 47 +++++++++++++++++++++- crates/ui-egui/tests/it/prepare.rs | 18 +++++++++ 9 files changed, 127 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47a80b86bc..5c7034b7a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own: the page view tells it what the press is to its page (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 4a9ed3a54e..647a39d970 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -201,9 +201,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut view.links.context = Some((i, index)); } // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that - // leaves them unselected. - let on_comment = comments::page_input(ui, resp, &pcx, press, view); - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; + // leaves them unselected. A press nobody holds that another tool took this frame (Prepare's + // selection rectangle, added content, which still decide for themselves) is not also a + // commenting tool's, as before. + let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field; + let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); + let consumed = taken || on_comment; let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); PageInput { consumed, area_tool, hover, released_over } } @@ -324,33 +327,36 @@ fn tool_input( } } -/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the -/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is -/// selected; links take theirs from what this returns. +/// A right-click at `p`: the page's menu opens there, and it goes to what lies on top there +/// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none +/// elsewhere; a Prepare field is selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the /// selections its press acts on are left alone until its release. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { let context = hits.context_at(view, p, under); - // Add text takes every right-click on a page it can change, Edit text those on anything of its - // own (paragraphs, pictures, added content, the objects selected together and their grips), the - // Link tool those on a link, and a Prepare field tool the whole page, as before: comments see - // none of them. + // Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click. + comments::anchor(hits.pcx, p, view); + if press_held { + return context; + } + // Add text takes every right-click on a page it can change, Edit text those on its own + // paragraphs and pictures (the objects selected together, and crop mode's picture, among them), + // the Link tool those on a link, and a Prepare field tool the whole page, as before: comments + // see none of them. let tool_takes_it = match hits.tool { - QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), - QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), + QuickTool::AddText => hits.can_modify, + QuickTool::EditText => under.iter().any(|h| matches!(h.owner, Owner::Paragraph { .. } | Owner::Picture { .. } | Owner::Objects { .. })), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - if !tool_takes_it && !press_held { + if !tool_takes_it { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; - comments::context(hits.pcx, p, comment, view); + comments::context(hits.pcx, comment, view); } - if let Some(Owner::PrepareField { field, widget }) = context - && !press_held - { + if let Some(Owner::PrepareField { field, widget }) = context { crate::prepare::context(hits.form, field, widget, view); } context diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index be83411072..c689f9fde5 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -119,7 +119,7 @@ pub(crate) enum PagePress { /// tool's. Also every page's while a press that began off the pages is held, and after the /// press was dropped ([`PageRouter::cancel`]) until the button is up. Free, - /// The press began on another page, or between the pages. + /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c0a797fa67..82b340e571 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2047,3 +2047,14 @@ fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { v.cancel_pointer_gestures(); assert_eq!(v.camera.map(|q| q.grab), Some(None)); } + +/// A camera corner at the page's edge is hit on the page only: a press off the page is no page's, +/// so its grab cursor does not show there. +#[test] +fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() { + let mut v = view(1, PageLayout::Single, true); + v.camera = Some(crate::camera_ui::CameraQuad { page: 0, corners: [[0.0, 0.0], [300.0, 0.0], [300.0, 400.0], [0.0, 400.0]], grab: None }); + let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), egui::vec2(300.0, 400.0)), rot: 0, pw: 300.0, ph: 400.0 }; + assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page"); + assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it"); +} diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index a7e7d6c934..c220f8aea3 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -61,10 +61,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test -/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, or on a page -/// without the quadrilateral. +/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, on a page +/// without the quadrilateral, or off the page (a press there is no page's), though a corner at its +/// edge reaches past it. pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { - if view.camera_done.is_some() { + if view.camera_done.is_some() || !xf.rect.contains(p) { return None; } let q = view.camera.filter(|q| q.page == page)?; diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 83ee620ae1..5ba9ca5401 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 4c026604ea..ccb12c7676 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -11,12 +11,13 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// Comment input on one page, before text selection runs. `press` is what this page has to do with /// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that -/// comment's, and one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to -/// commenting (text selection must ignore it). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, view: &mut DocView) -> bool { +/// comment's, and one nobody holds is a commenting tool's, unless another tool took it this frame +/// (`taken`: Prepare's selection rectangle, added content), though a click there still unselects. +/// Returns `true` when the pointer gesture belongs to commenting (text selection must ignore it). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; - let free = press == PagePress::Free; + let free = press == PagePress::Free && !taken; let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); let owned = match press { @@ -98,11 +99,15 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op } let markup = is_markup(&a.subtype); let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; - // What a drag moves whole (a note, a shape, a signature, a Fill & Sign mark) is placed on the - // page, over a form field; text markup lies on the text, under one. - let layer = if a.fill_sign || !markup { Layer::Placed } else { Layer::Comment }; // Text markup does not move: a click selects it, a drag across it selects the text. - Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) + Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) }) +} + +/// The layer a comment's body lies on: what a drag moves whole (a note, a shape, a signature, a +/// Fill & Sign mark) is placed on the page, over a form field; text markup lies on the text, under +/// one. +fn layer(a: &Annotation) -> Layer { + if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment } } /// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows @@ -113,14 +118,18 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { return None; } let a = cx.comment_at(p)?; - let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; - Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default)) } -/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the -/// comment it is on ([`context_hit`], when it wins there) is selected, or none. -pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { +/// A right-click at `p` on this page: the page's menu is anchored there (Delete page, Paste, Copy +/// Image, a note added here). +pub(crate) fn anchor(cx: &PageCx<'_>, p: Pos2, view: &mut DocView) { view.comments.context_at = Some((cx.page, cx.to_user(p))); +} + +/// A right-click on comment `index` of this page ([`context_hit`], when it wins there) selects it, +/// and one on anything else selects none. +pub(crate) fn context(cx: &PageCx<'_>, index: Option, view: &mut DocView) { view.comments.selected = index.map(|i| (cx.page, i)); } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 6685085614..d412496fcf 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1419,7 +1419,8 @@ fn the_link_tools_hand_stops_at_the_pages_edge() { } /// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies -/// over it: the comment is not selected. +/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on +/// the picture menu, Delete page); it used to stay where the last right-click comments saw was. #[test] fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() { let mut h = open(comment_on_text_pdf(), "150", false); @@ -1429,6 +1430,7 @@ fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_commen wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); right_click(&mut h, on_text); assert_eq!(h.state().views[0].comments.selected, None); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page"); } /// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not @@ -1544,3 +1546,46 @@ fn a_long_press_on_a_comment_selects_it_for_its_menu() { one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]); h.run_steps(2); } + +/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, +/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page +/// right-clicked before. +#[test] +fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { + let mut h = open(two_pages_pdf(), "100", false); + h.run_steps(2); + h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + let press = two.min + egui::vec2(50.0, 30.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(1); + let menu = press + egui::vec2(100.0, 60.0); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(1), "the menu is page 2's"); + h.drop_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(2); +} + +/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on +/// its own paragraphs and pictures): the selected comment is unselected. +#[test] +fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + h.state_mut().views[0].comments.selected = Some((0, 0)); + let on_added = at(&h, 90.0, 202.0); + right_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 281e3d9e18..51999431d9 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1599,3 +1599,21 @@ fn a_right_click_during_a_field_drag_moves_the_dragged_field() { assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was"); assert_ne!(field_rect(&h, "name"), name, "the dragged field moved"); } + +/// With Prepare open, a drag with a shape tool on the page's empty part draws Prepare's selection +/// rectangle and no comment, as before: Prepare takes the press nobody holds. It used to draw both. +#[test] +fn with_prepare_open_a_shape_tool_drag_draws_no_comment() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.state_mut().execute("comment.square")); + h.run_steps(3); + assert!(h.state().is_preparing(), "picking the tool leaves Prepare open"); + // Below the fields. + let (from, to) = (at(&h, 40.0, 260.0), at(&h, 200.0, 360.0)); + drag(&mut h, from, to); + let s = h.state(); + let squares = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Square").count(); + assert_eq!(squares, 0, "no rectangle under Prepare's selection rectangle"); +} From c1d34fc9eaaf9caaaa3eb1c165870e1a9991e5af Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 18:11:07 +0100 Subject: [PATCH 19/49] fix(ui): commenting tools leave a frame another tool took; Escape keeps the menu's anchor true Found in review: - **A double-click another tool took finished a polygon.** Round 5 kept commenting tools off a press another tool had taken, but a polygon, polyline or cloud finishes on a double-click or Enter whoever has the press. With Prepare open, a double-click on a field opened its properties and committed the polygon being clicked out. Added text opened for editing did the same with the Edit panel open. The commenting tools now skip such a frame entirely, as `main`'s consumed chain did. The comments' own presses and a click's unselecting still run. - **The menu's anchor after Escape.** While a press Escape cancelled is held, the page's tools are skipped, and a right-click left the page menu on the page right-clicked before. It now anchors where it is, as every other right-click does. - **The guard.** It now lists who asks the page view's hits at a point (`PageHits::at` and `context_at`), so a new place in `page_input.rs` can't test a point again. It takes a file that uses the comments' page context (`PageCx`) for a page module. Its doc of the point tests it doesn't list is now true. - **Docs and visibility.** True docs for `PagePress::Free`, where a press is off the pages, and the camera's hit. `link_ui::screen_rect` is private. Tests: a polygon left unfinished by a double-click on a Prepare field, and a right-click after Escape anchoring the menu. Each was seen failing without its fix. A planted ask of the page view's hits, and a planted unlisted file using `PageCx`, each fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 8 +++- .../ui-egui/src/canvas/page_pointer/tests.rs | 39 ++++++++++++++++--- crates/ui-egui/src/canvas/page_router.rs | 7 ++-- crates/ui-egui/src/tools/camera_ui.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 11 ++++-- crates/ui-egui/src/tools/link_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 30 +++++++++++--- crates/ui-egui/tests/it/prepare.rs | 27 +++++++++++++ 8 files changed, 105 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6cb3211c7c..6775702384 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1498,14 +1498,18 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - // A cancelled press takes nothing until the button is up. + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + // A cancelled press takes nothing until the button is up, though the page's menu still opens + // where a right-click is. let input = if view.press_cancelled { + if let Some(p) = pointer.filter(|p| frame.resp.secondary_clicked() && xf.rect.contains(*p)) { + comments::anchor(&pcx, p, view); + } PageInput { consumed: true, area_tool: false, hover: None, released_over: false } } else { 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()); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 1c41858651..2890a63cb1 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -190,9 +190,11 @@ const PAGE_MODULES: [&str; 37] = [ "tools/stamps_ui.rs", ]; -/// What a file that acts on the pages uses: the pointer, a tool's page context, what a press is to -/// a page, or the router's hits and owners. Such a file is a page module. -const PAGE_MODULE_MARKS: [&str; 4] = ["PagePointer", "PageToolContext", "PagePress", "page_router::"]; +/// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, +/// what a press is to a page, or the router's hits and owners. Such a file is a page module. One +/// that acts on the pages through the page view's response and a bare point alone uses none of +/// them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. +const PAGE_MODULE_MARKS: [&str; 5] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::"]; /// The router's own files, which define what page modules use. const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; @@ -439,8 +441,10 @@ fn hit_test_calls(line: &str) -> Vec { } /// The functions that call a hit test: (file, function, why). Hit tests are found by name (a -/// function named `hit` or `…_hit`, called by path, bare or as a method); a point test by another -/// name has a list of its own (`COMMENT_POINT_TESTERS`). `PageHits::hits` is the one: the hits +/// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test +/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's, +/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and +/// most are PR 4b's. `PageHits::hits` is the one: the hits /// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover /// painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( @@ -449,6 +453,23 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; +/// The page view's asks for every module's hits at a point (`PageHits::at`, `PageHits::context_at`). +const PAGE_HITS_ASKS: [&str; 2] = [".at(", ".context_at("]; + +/// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is +/// private to `page_input.rs`, so this keeps a new place there from testing a point again; a +/// second ask inside one of these is for review to catch. +const PAGE_HITS_ASKERS: [(&str, &str, &str); 3] = [ + ( + "canvas/page_input.rs", + "page_tool_input", + "the hits at the press point decide its owner, once per press; those under the pointer before the tools act, once a \ + frame, serve a click's release, a right-click and Fill & Sign's preview", + ), + ("canvas/page_input.rs", "claim_under", "what lies under the pointer after the tools act: its cursor, its tip and hover painting"), + ("canvas/page_input.rs", "right_click", "what a right-click is on"), +]; + /// The comments' point test, which their hit tests are built on: which comment lies under a point. const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; @@ -550,6 +571,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { function to COMMENT_POINT_TESTERS in src/canvas/page_pointer/tests.rs, with why.", "no longer asks which comment lies under a point: remove it from COMMENT_POINT_TESTERS.", )); + problems.extend(misuses( + &PAGE_HITS_ASKS, + &PAGE_HITS_ASKERS, + "asks the page view's hits at a point. The hits at the press point decide its owner once, and those under the \ + pointer are taken once before the tools act and once after: use those (`under`, `PageInput.hover`). A new \ + place that must ask? Add the function to PAGE_HITS_ASKERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer asks the page view's hits: remove it from PAGE_HITS_ASKERS.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index c689f9fde5..b59aee58be 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -116,8 +116,9 @@ pub(crate) enum PagePress { /// only let go of their own state (a click on anything but the selected comment unselects it). Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. Also every page's while a press that began off the pages is held, and after the - /// press was dropped ([`PageRouter::cancel`]) until the button is up. + /// tool's. Also every page's while a press the page view does not hold is down (one that began + /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is + /// up. Free, /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, @@ -144,7 +145,7 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, - /// A press is held, and the page it began on (`None`: between the pages). + /// A press is held, and the page it began on (`None`: off the pages, between them or beside one). began: Option>, } diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index c220f8aea3..ce88da25cf 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -62,8 +62,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { /// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test /// of the camera tool, for the cursor and the press. `None` once the page is enhanced, on a page -/// without the quadrilateral, or off the page (a press there is no page's), though a corner at its -/// edge reaches past it. +/// without the quadrilateral, or off the page (a press there is no page's), even where a corner at +/// its edge would reach past it. pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { if view.camera_done.is_some() || !xf.rect.contains(p) { return None; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ccb12c7676..8366e36453 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -11,13 +11,14 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// Comment input on one page, before text selection runs. `press` is what this page has to do with /// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that -/// comment's, and one nobody holds is a commenting tool's, unless another tool took it this frame -/// (`taken`: Prepare's selection rectangle, added content), though a click there still unselects. -/// Returns `true` when the pointer gesture belongs to commenting (text selection must ignore it). +/// comment's, and one nobody holds is a commenting tool's. When another tool took this frame +/// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave +/// it to that tool, as before, though a click there still unselects. Returns `true` when the +/// pointer gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; - let free = press == PagePress::Free && !taken; + let free = press == PagePress::Free; let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); let owned = match press { @@ -37,6 +38,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, match cx.tool { // The selected shape's handles, and Fill & Sign's marks, take their own presses. QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin), + // Neither a press nor a double-click or a key another tool took this frame. + QuickTool::Comment(_) if taken => false, 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), diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 069564afff..041edaf1c7 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -49,7 +49,7 @@ pub struct LinkDraft { pub style: LinkStyle, } -pub(crate) fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { +fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d412496fcf..1fc7c51dd3 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1547,11 +1547,9 @@ fn a_long_press_on_a_comment_selects_it_for_its_menu() { h.run_steps(2); } -/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, -/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page -/// right-clicked before. -#[test] -fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { +/// With the page menu anchored on page 1, presses on page 2 and holds the button, presses Escape +/// first with `escape`, right-clicks on page 2, and gives the page the menu is anchored on. +fn menu_page_after_a_right_click_with_the_button_held(escape: bool) -> Option { let mut h = open(two_pages_pdf(), "100", false); h.run_steps(2); h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); @@ -1563,14 +1561,34 @@ fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { h.run_steps(1); h.hover_at(press + egui::vec2(40.0, 0.0)); h.run_steps(1); + if escape { + h.key_press(egui::Key::Escape); + h.run_steps(1); + } let menu = press + egui::vec2(100.0, 60.0); for pressed in [true, false] { h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); } h.run_steps(1); - assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(1), "the menu is page 2's"); + let page = h.state().views[0].comments.context_at.map(|(page, _)| page); h.drop_at(press + egui::vec2(40.0, 0.0)); h.run_steps(2); + page +} + +/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, +/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page +/// right-clicked before. +#[test] +fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { + assert_eq!(menu_page_after_a_right_click_with_the_button_held(false), Some(1), "the menu is page 2's"); +} + +/// So does a right-click while a press Escape cancelled is held, though the cancelled press takes +/// nothing else. +#[test] +fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page() { + assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's"); } /// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 51999431d9..749eaac69a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1617,3 +1617,30 @@ fn with_prepare_open_a_shape_tool_drag_draws_no_comment() { let squares = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Square").count(); assert_eq!(squares, 0, "no rectangle under Prepare's selection rectangle"); } + +/// With Prepare open, a double-click on a field is the field's (it opens its properties) and leaves +/// a polygon being clicked out unfinished, as before. It used to finish the polygon too. +#[test] +fn with_prepare_open_a_double_click_on_a_field_leaves_the_polygon_unfinished() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.state_mut().execute("comment.polygon")); + h.run_steps(3); + // Three points below the fields. + for (x, y) in [(40.0, 260.0), (200.0, 260.0), (120.0, 360.0)] { + let p = at(&h, x, y); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + } + let on_field = field_rect(&h, "name").center(); + crate::support::input::double_click(&mut h, on_field); + let s = h.state(); + let polygons = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Polygon").count(); + assert_eq!(polygons, 0, "the polygon is not finished"); + assert!(matches!(s.views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { .. })), "it is still being clicked out"); +} From 2a4032b2fc54652a8b0dae1a07002cb0028b64c3 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 18:38:01 +0100 Subject: [PATCH 20/49] fix(ui): a press where two pages touch is one page's; the guard sees PageHits::hits Found in review: - **A press where two pages touch.** With the page gaps hidden, pages can share a row of pixels, and a press there was claimed by both: the first page acted on it, then the second took it over, and the text layer could count it twice. Only a press just begun is claimed now, by the first page that does. This was so on `main` too. - **The guard** listed who asks `PageHits::at` and `context_at`, but not `PageHits::hits`, which they wrap. A new call of it in `page_input.rs` now fails, and the guard's doc says what it still can't see. - **Docs.** `Hit::declines_drag` (a link's drag with the Link tool), `over_mark`, and when the Select tool still unselects under another tool's frame. Tests: two pages claiming one press, seen failing without the fix. A planted call of `PageHits::hits` fails the guard. The router's unit tests now begin a press before claiming it, as `draw_pages` does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 5 +++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 14 ++++++++----- crates/ui-egui/src/canvas/page_router.rs | 21 ++++++++++++++++++- crates/ui-egui/src/tools/comments/input.rs | 4 ++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 647a39d970..39b39e11b0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -236,8 +236,9 @@ fn link_of(owner: Option) -> Option { /// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor /// and gestures. The camera also drags a corner whose press it holds, and crop's double-click -/// opens Set Page Boxes wherever it is. `over_mark`: a Fill & Sign mark takes the press, or would take one under the -/// pointer. +/// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a +/// mark's, or a Prepare field's), or with no button down a mark lies under the pointer, where a +/// press would pick it up. fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2890a63cb1..4936c81367 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -453,13 +453,17 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; -/// The page view's asks for every module's hits at a point (`PageHits::at`, `PageHits::context_at`). -const PAGE_HITS_ASKS: [&str; 2] = [".at(", ".context_at("]; +/// The page view's asks for every module's hits at a point (`PageHits::hits`, and `at` and +/// `context_at`, which wrap it). +const PAGE_HITS_ASKS: [&str; 3] = [".hits(", ".at(", ".context_at("]; /// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is -/// private to `page_input.rs`, so this keeps a new place there from testing a point again; a -/// second ask inside one of these is for review to catch. -const PAGE_HITS_ASKERS: [(&str, &str, &str); 3] = [ +/// private to `page_input.rs`, so this keeps a new function there from asking by these names; a +/// call by path (`PageHits::at(&hits, …)`) and a second ask inside a listed function are for +/// review to catch. +const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ + ("canvas/page_input.rs", "at", "`PageHits::at`: the hits at a point, as a press there would take them"), + ("canvas/page_input.rs", "context_at", "`PageHits::context_at`: what a right-click at a point is on"), ( "canvas/page_input.rs", "page_tool_input", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index b59aee58be..0863642aee 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -90,7 +90,7 @@ pub(crate) struct Hit { /// Beside the pointer where this hit wins (a link's target). pub tip: Option, /// A drag from here is not this owner's: a click on a highlight selects it, a drag across it - /// selects the text underneath. + /// selects the text underneath; with the Link tool, a drag from a link draws a new one. pub declines_drag: bool, } @@ -159,7 +159,12 @@ impl PageRouter { /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + /// Only a press just begun ([`Self::begin`]) is claimed, by the first page that does: pages + /// that touch (the gaps hidden) can share a row of pixels, and a press there is one page's. pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { + if self.began != Some(None) { + return; + } self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); self.began = Some(Some(page)); } @@ -222,6 +227,7 @@ mod tests { #[test] fn a_press_is_held_until_the_button_is_up_and_its_release_seen() { let mut r = PageRouter::default(); + r.begin(); r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select); assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner)); assert_eq!(r.owner(1), None, "only on its page"); @@ -230,6 +236,7 @@ mod tests { assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); r.settle(false, false); assert_eq!(r.owner(2), None); + r.begin(); r.press(0, [], QuickTool::Select); assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); assert!(!r.keeps_drag()); @@ -257,9 +264,21 @@ mod tests { assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press leaves every page to the tool"); } + /// Pages that touch (the gaps hidden) can share a row of pixels: a press there is the first + /// page's to claim it, and the next page's claim is ignored. + #[test] + fn a_press_where_two_pages_touch_is_the_first_pages() { + let mut r = PageRouter::default(); + r.begin(); + r.press(0, [hit(Layer::Comment, 1)], QuickTool::Select); + r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select); + assert_eq!((r.press_for(0), r.press_for(1)), (PagePress::Mine(hit(Layer::Comment, 1).owner), PagePress::Elsewhere)); + } + #[test] fn a_highlight_lets_its_drag_go_to_the_text() { let mut r = PageRouter::default(); + r.begin(); r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); assert!(r.owner(0).is_some() && !r.keeps_drag()); r.cancel(); diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 8366e36453..4c4251907b 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -13,8 +13,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that /// comment's, and one nobody holds is a commenting tool's. When another tool took this frame /// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave -/// it to that tool, as before, though a click there still unselects. Returns `true` when the -/// pointer gesture belongs to commenting (text selection must ignore it). +/// it to that tool, as before; the Select tool's click there still unselects. Returns `true` when +/// the pointer gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; From 6756ab0ed46c00807bcb1f7dfcc6f1d0177cecd0 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 19:14:12 +0100 Subject: [PATCH 21/49] fix(ui): a right-click where two pages touch is one page's; the guard reads split calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: - **A right-click where two pages touch.** With the page gaps hidden, a right-click on the row two pages share ran on both: the first page selected its comment, the second unselected it, and the second page's anchor for the menu stood. A right-click is now the first page's, as a press has been since the last fix. This was so on `main` too. - **The guard** matched one line at a time, so a call rustfmt splits across lines (`view` / `.router` / `.owner(i)`) got past it. A continued line is now read with the one it continues. It also refuses a hit test imported under a name that isn't a hit test's (`use …::hit as under`), which the checks by name would miss. - **Docs.** Links still take a right-click while the primary button is held, as before, and when Escape comes in a press's own frame every page is `Elsewhere`, not `Free`. Tests: a stamp clicked where two pages touch lands on the first, a click pressed on one page and released on the next places nothing, a right-click where two pages touch is the first page's, Prepare keeps its selection on a click that began off the page, and the Note tool starts no note on added text with the Edit panel open. Each was seen failing with its fix, or the rule it pins, reverted. A planted split router read and a planted aliased hit test fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 16 +++- crates/ui-egui/src/canvas/page_input.rs | 5 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 48 ++++++++++- crates/ui-egui/src/canvas/page_router.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 83 +++++++++++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6775702384..95767e2c06 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1207,6 +1207,19 @@ struct PageFrame<'a> { current_height: f32, pressed_page: Option, calc_hover: Option, + /// A page took this frame's right-click. + right_clicked: bool, +} + +impl PageFrame<'_> { + /// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages + /// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one + /// page's, as a press is. + fn right_click_on(&mut self, rect: Rect) -> Option { + let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?; + self.right_clicked = true; + Some(p) + } } /// Visible heights (points) closer than this are equal: one page size can meet the viewport a @@ -1502,7 +1515,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. let input = if view.press_cancelled { - if let Some(p) = pointer.filter(|p| frame.resp.secondary_clicked() && xf.rect.contains(*p)) { + if let Some(p) = frame.right_click_on(xf.rect) { comments::anchor(&pcx, p, view); } PageInput { consumed: true, area_tool: false, hover: None, released_over: false } @@ -1683,6 +1696,7 @@ fn draw_pages( current_height: f32::INFINITY, pressed_page: None, calc_hover: None, + right_clicked: false, }; for &i in visible_pages { draw_page(ui, view, &mut frame, i); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 39b39e11b0..0a5d9b24e5 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -109,7 +109,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // goes down, so the button is what tells. A touch's long press, which egui reports as a // right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p, press_held)); + let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = @@ -332,7 +332,8 @@ fn tool_input( /// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none /// elsewhere; a Prepare field is selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the -/// selections its press acts on are left alone until its release. +/// comment and Prepare selections its press acts on are left alone until its release; links still +/// take theirs, as before. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { let context = hits.context_at(view, p, under); // Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4936c81367..e0ddc358fe 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -293,7 +293,8 @@ fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { } /// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, -/// the top-level function or `impl` method it is in, line, name). +/// the top-level function or `impl` method it is in, line, name). A line rustfmt continued (a +/// method chain split as `view` / `.router` / `.owner(i)`) is read with the one it continues. fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut found = Vec::new(); @@ -301,6 +302,7 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); // The top-level function, or method of an `impl`, that each line is in. let (mut function, mut in_impl) = (None, false); + let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); for (n, line) in outside_test_modules(&source) { if line.starts_with("impl") { (function, in_impl) = (None, true); @@ -312,7 +314,13 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, if line.trim_start().starts_with("//") { continue; } - for name in find(line) { + match lines.last_mut() { + Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), + _ => lines.push((n, function, line.to_owned())), + } + } + for (n, function, text) in lines { + for name in find(&text) { found.push((file, function.map(str::to_owned), n, name)); } } @@ -320,6 +328,41 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, found } +/// Each hit test a page module imports under a name that is not a hit test's (`use …::hit as +/// under;`), which the checks by name would not see. +fn hit_test_aliases() -> Vec { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut found = Vec::new(); + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module"); + // Each `use` statement, from its first line to its `;`, wherever it is. + let mut statement: Option = None; + for line in source.lines().map(str::trim) { + if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { + statement = Some(String::new()); + } + let Some(text) = statement.as_mut() else { continue }; + text.push_str(line); + text.push(' '); + if !line.ends_with(';') { + continue; + } + for part in text.split([',', '{', '}', ';']) { + let Some((path, alias)) = part.split_once(" as ") else { continue }; + let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); + if is_hit_test(name) && !is_hit_test(alias) { + found.push(format!( + "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: \ + name it `hit` or `…_hit`." + )); + } + } + statement = None; + } + } + found +} + /// Each use of one of `names` in the page modules' code (their tests left out). fn page_module_uses(names: &[&str]) -> Vec<(&'static str, Option, usize, String)> { page_module_matches(|line| names.iter().filter(|name| uses(line, name)).map(|name| (*name).to_owned()).collect()) @@ -615,6 +658,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); + problems.extend(hit_test_aliases()); // An entry no page module calls any more no longer guards anything. let called = page_module_uses(&PAGE_HIT_CALLS); problems.extend( diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 0863642aee..e3b88d1a2c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -118,7 +118,8 @@ pub(crate) enum PagePress { /// No button is down, or the press began on this page and nobody's hit won it: the active /// tool's. Also every page's while a press the page view does not hold is down (one that began /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is - /// up. + /// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a + /// cancelled press either way). Free, /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1fc7c51dd3..205a5b2663 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1607,3 +1607,86 @@ fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { right_click(&mut h, on_added); assert_eq!(h.state().views[0].comments.selected, None); } + +/// Two pages with their gaps hidden, after `tool` set the tool up, and a point on the row of pixels +/// where they touch, which lies on both. +fn touching_pages(tool: impl FnOnce(&mut PdfCraftApp)) -> (Harness<'static, PdfCraftApp>, egui::Pos2) { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + tool(h.state_mut()); + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let seam = egui::pos2(one.min.x + 100.0, one.max.y); + assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}"); + (h, seam) +} + +/// The pages the document's stamps are on. +fn stamp_pages(h: &Harness<'_, PdfCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").map(|a| a.page).collect() +} + +/// Where pages touch (the gaps hidden), a press on the row they share is the first page's: a stamp +/// clicked there lands on page 1. The second page used to take the press over. +#[test] +fn a_stamp_clicked_where_two_pages_touch_lands_on_the_first() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved)); + mouse_click(&mut h, seam); + assert_eq!(stamp_pages(&h), vec![0]); +} + +/// A click pressed on one page and released on the next (the gaps hidden, within egui's click +/// distance) places nothing: the press is the first page's, and the pointer is off it. `main` placed +/// the stamp on the second page. +#[test] +fn a_stamp_click_pressed_on_one_page_and_released_on_the_next_places_nothing() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved)); + slipping_click(&mut h, seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 2.0)); + assert_eq!(stamp_pages(&h), Vec::::new()); +} + +/// Where pages touch (the gaps hidden), a right-click on the row they share is the first page's: +/// the menu is anchored there. Both pages used to take it, and the second's anchor stood. +#[test] +fn a_right_click_where_two_pages_touch_is_the_first_pages() { + let (mut h, seam) = touching_pages(|_| {}); + right_click(&mut h, seam); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0)); +} + +/// With Prepare open, a click that began off the page (in the gutter beside it) is no page's: the +/// selected field stays selected, as the Link tool's and comments' selections do. +#[test] +fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + let on_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, on_box.y), egui::pos2(left + 3.0, on_box.y)); + assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected"); +} + +/// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is +/// started under it, as before. +#[test] +fn a_note_tool_click_on_added_text_starts_no_note() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Note); + h.run_steps(3); + let on_added = at(&h, 90.0, 202.0); + mouse_click(&mut h, on_added); + assert!(h.state().views[0].comments.composer.is_none(), "no note is started"); +} From 6752b36807159bb0710035e38bbd7d3a3b40856d Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 19:40:46 +0100 Subject: [PATCH 22/49] fix(ui): a mark being moved keeps Fill & Sign's presses; the guard sees calls by path Found in review: - **Fill & Sign during a mark's move.** `main` placed no Fill & Sign mark while a comment's move, resize or reshape was live on the page, whoever held the press. That guard was lost: with a move left over (its release never seen), the next click placed a new mark. A live gesture on the page now counts as over a mark again (`comments::moving_on`, which the comments' own dispatch uses too). - **The guard** missed the router's and the page view's hits called by path (`PageRouter::owner(&view.router, i)`), and its doc said the `.at(` it matches was confined to `page_input.rs`. - `measure_ui::Clicks` derived traits nothing uses. Tests: a Fill & Sign click while a mark's move is live places nothing, seen failing without the fix. A planted router read by path fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 13 ++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 22 ++++++++++++++----- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 10 ++++++--- crates/ui-egui/src/tools/measure_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 18 +++++++++++++++ 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 0a5d9b24e5..2f74110d7c 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -110,10 +110,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); - // A mark already placed takes its own press (comments, below), and with no button down the - // Fill & Sign tool's preview hides over one, where a press would pick it up. - let over_mark = - matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); + // A mark already placed takes its own press (comments, below), as does one being moved on this + // page until its move ends; with no button down the Fill & Sign tool's preview hides over one, + // where a press would pick it up. + let over_mark = matches!(tool, QuickTool::Fill(_)) + && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -237,8 +238,8 @@ fn link_of(owner: Option) -> Option { /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor /// and gestures. The camera also drags a corner whose press it holds, and crop's double-click /// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a -/// mark's, or a Prepare field's), or with no button down a mark lies under the pointer, where a -/// press would pick it up. +/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a mark +/// lies under the pointer, where a press would pick it up. fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index e0ddc358fe..f9280e9045 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -454,7 +454,16 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.settle(", "router.cancel("]; +const ROUTER_WRITES: [&str; 8] = [ + "router.begin(", + "router.press(", + "router.settle(", + "router.cancel(", + "PageRouter::begin(", + "PageRouter::press(", + "PageRouter::settle(", + "PageRouter::cancel(", +]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ @@ -498,12 +507,12 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( /// The page view's asks for every module's hits at a point (`PageHits::hits`, and `at` and /// `context_at`, which wrap it). -const PAGE_HITS_ASKS: [&str; 3] = [".hits(", ".at(", ".context_at("]; +const PAGE_HITS_ASKS: [&str; 6] = [".hits(", ".at(", ".context_at(", "PageHits::hits(", "PageHits::at(", "PageHits::context_at("]; /// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is -/// private to `page_input.rs`, so this keeps a new function there from asking by these names; a -/// call by path (`PageHits::at(&hits, …)`) and a second ask inside a listed function are for -/// review to catch. +/// private to `page_input.rs`, so this keeps a new function there from asking; a second ask inside +/// a listed function is for review to catch. The method names are matched in every page module, +/// so an `at` of another type there would need them narrowed. const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ ("canvas/page_input.rs", "at", "`PageHits::at`: the hits at a point, as a press there would take them"), ("canvas/page_input.rs", "context_at", "`PageHits::context_at`: what a right-click at a point is on"), @@ -528,7 +537,8 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ ]; /// What reads who holds the press the pages hold. -const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; +const ROUTER_READS: [&str; 6] = + ["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("]; /// The functions that read who holds the press: (file, function, why). The page view asks once per /// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 5ba9ca5401..8d118ac3c9 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 4c4251907b..ac3c2a6878 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -31,9 +31,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.gesture = None; return true; } - // A comment being moved, resized or reshaped on this page keeps the frame until it ends. - let moving = - matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page); + let moving = moving_on(view, cx.page); let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { // The selected shape's handles, and Fill & Sign's marks, take their own presses. @@ -51,6 +49,12 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, } } +/// A comment (a Fill & Sign mark included) is being moved, resized or reshaped on `page`: it keeps +/// the page's frames until its gesture ends. +pub(crate) fn moving_on(view: &DocView, page: usize) -> bool { + matches!(view.comments.gesture, Some(Gesture::Move { page: p, .. } | Gesture::Resize { page: p, .. } | Gesture::Reshape { page: p, .. }) if p == page) +} + /// What of the comments lies under `p`, for the cursor, the tip and the press: the one hit test /// of this module. With the Select tool, the selected comment's point handles, then its resize /// handles, then the topmost comment. With a Fill & Sign tool, the same over Fill & Sign's marks diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index a81f01b0b2..2d6336cee6 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -203,7 +203,7 @@ fn snap_to_content( } /// The Measure tools' own clicks this frame: those of a press nobody holds. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy)] pub(crate) struct Clicks { pub single: bool, pub double: bool, diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 205a5b2663..51444f4517 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1690,3 +1690,21 @@ fn a_note_tool_click_on_added_text_starts_no_note() { mouse_click(&mut h, on_added); assert!(h.state().views[0].comments.composer.is_none(), "no note is started"); } + +/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while +/// its move is still live (its release never seen) places no new mark. +#[test] +fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check); + h.run_steps(4); + let marks = |h: &Harness<'_, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.len() + }; + let before = marks(&h); + let blank = at(&h, 150.0, 100.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank }); + mouse_click(&mut h, blank); + assert_eq!(marks(&h), before, "no mark is placed"); +} From 568ea94f48db05398eb3f3c9d54cbd70a06c49f1 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 20:09:10 +0100 Subject: [PATCH 23/49] fix(ui): a right-click between the pages anchors no page's menu; the guard finds router readers Found in review: - **A right-click off the pages.** Between the pages or beside one, no page took the right-click, but the page menu still opened over the page view, on the last anchor: Delete page, Paste and Copy Image acted on a page right-clicked long before. This was so on `main` too. The anchor is now cleared on every right-click before the pages set it, so off the pages the menu acts on the current page. - **The guard** took a file as a page module only by marks a router read doesn't need. A file that reads `.router.` is one too, so its router reads are checked. - **Docs.** egui ends a press's drag when any button is released, not when a second goes down. `link_ui::hit` takes the tip from the link it found rather than looking it up again. Tests: a right-click between the pages clears the anchor, seen failing without the fix. A planted router read in an unlisted file fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/src/canvas/page_input.rs | 6 +++--- crates/ui-egui/src/canvas/page_pointer/tests.rs | 8 ++++---- crates/ui-egui/src/tools/link_ui.rs | 10 ++++++---- crates/ui-egui/tests/it/pointer.rs | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 95767e2c06..97e867a090 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1645,6 +1645,9 @@ fn draw_pages( if resp.secondary_clicked() { // Set again below when the right-click lands on a link. view.links.context = None; + // Set again below by the page right-clicked: a right-click off the pages (between them, + // beside one) anchors no page's menu, which then acts on the current page. + view.comments.context_at = None; let mut over_image = false; if tool == QuickTool::EditText { let pointer_pos = ptr.pos; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 2f74110d7c..49cf70067b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -105,9 +105,9 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut _ => false, }; // A right-click while the primary button is held (a comment moved, a box drawn) leaves the - // selections that press acts on alone; egui ends the press's own interaction when another button - // goes down, so the button is what tells. A touch's long press, which egui reports as a - // right-click with the finger still down, opens its menu. + // selections that press acts on alone. egui ends the press's own interaction when any button is + // released, the right-click's own frame included, so the button is what tells. A touch's long + // press, which egui reports as a right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), as does one being moved on this diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f9280e9045..40548a998a 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -191,10 +191,10 @@ const PAGE_MODULES: [&str; 37] = [ ]; /// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, -/// what a press is to a page, or the router's hits and owners. Such a file is a page module. One -/// that acts on the pages through the page view's response and a bare point alone uses none of -/// them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. -const PAGE_MODULE_MARKS: [&str; 5] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::"]; +/// what a press is to a page, the router's hits and owners, or the router itself. Such a file is a +/// page module. One that acts on the pages through the page view's response and a bare point alone +/// uses none of them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. +const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."]; /// The router's own files, which define what page modules use. const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 041edaf1c7..63db81b9f3 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -70,12 +70,14 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem if !xf.rect.contains(p) { return None; } - let index = if tool == QuickTool::Link { - links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + let (index, link) = if tool == QuickTool::Link { + let index = links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index; + (index, info.links.iter().find(|l| l.page == page && l.index == index)) } else { - info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + let link = info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + (link.index, Some(link)) }; - let tip = info.links.iter().find(|l| l.page == page && l.index == index).map(|l| target_label(info, &l.target)); + let tip = link.map(|l| target_label(info, &l.target)); let declines_drag = tool == QuickTool::Link; Some(Hit { tip, declines_drag, ..Hit::new(Owner::Link { index }, Layer::Link, egui::CursorIcon::PointingHand) }) } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 51444f4517..8364f3a635 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1708,3 +1708,20 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { mouse_click(&mut h, blank); assert_eq!(marks(&h), before, "no mark is placed"); } + +/// A right-click between the pages anchors no page's menu, so Delete page and Paste act on the +/// current page. The menu used to keep the anchor of the last right-click on a page. +#[test] +fn a_right_click_between_the_pages_anchors_no_pages_menu() { + let mut h = open(two_pages_pdf(), "100", false); + h.run_steps(2); + h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0); + assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}"); + right_click(&mut h, gap); + assert_eq!(h.state().views[0].comments.context_at, None); +} From 58bb5ba3d0eb3bfac73a40f8ac95c2c684dd64fd Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 20:37:51 +0100 Subject: [PATCH 24/49] test(ui): the page guard knows any router method, and its readers are tested Found in review: - **The router guard** listed the router's methods by name, so a method added later (`view.router.holds(i)`) would pass every check. A use of the router by a method neither list names now fails outside the functions allowed to write or read it. The comments' point test called by path (`PageCx::comment_at(cx, p)`) is caught too. - **The guard's own readers** were proven only by planted builds. Reading the lines of code (the function each is in, a call rustfmt split) and finding hit tests imported under another name now work on a source string, and a unit test pins them with `hit_test_calls` and `declares`. - **Docs.** Fill & Sign's preview hides wherever a press would be taken (a mark, its handles, a Prepare field), and the hits under the pointer before the tools act are taken on the frames that need them, not "once". Tests: the guard's readers, seen failing with the line joining planted out. A planted router method no list names, and a planted `PageCx::comment_at` by path, each fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 12 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 163 ++++++++++++------ 2 files changed, 121 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 49cf70067b..03bd3e807d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -96,8 +96,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); - // What lies under the pointer before the tools act, asked once: for a click's release, a - // right-click, and Fill & Sign's preview. + // What lies under the pointer before the tools act, on the frames that need it: for a click's + // release, a right-click's tool checks, and Fill & Sign's preview. let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { @@ -111,8 +111,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let press_held = ptr.primary_down && !resp.long_touched(); let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), as does one being moved on this - // page until its move ends; with no button down the Fill & Sign tool's preview hides over one, - // where a press would pick it up. + // page until its move ends; with no button down the Fill & Sign tool's preview hides where a + // press would be taken (a mark, its handles, a Prepare field). let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); @@ -238,8 +238,8 @@ fn link_of(owner: Option) -> Option { /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor /// and gestures. The camera also drags a corner whose press it holds, and crop's double-click /// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a -/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a mark -/// lies under the pointer, where a press would pick it up. +/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a press +/// under the pointer would be taken (a mark, its handles, a Prepare field). fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 40548a998a..f3c0952527 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -292,34 +292,40 @@ fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { kept } +/// The lines of code in `source` outside its test modules, numbered from 1, each with the +/// top-level function or `impl` method it is in; comment lines are left out. A line rustfmt +/// continued (a method chain split as `view` / `.router` / `.owner(i)`) is read with the one it +/// continues, under that one's number. +fn code_lines(source: &str) -> Vec<(usize, Option<&str>, String)> { + let (mut function, mut in_impl) = (None, false); + let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); + for (n, line) in outside_test_modules(source) { + if line.starts_with("impl") { + (function, in_impl) = (None, true); + } else if line == "}" { + (function, in_impl) = (None, false); + } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { + function = Some(name); + } + if line.trim_start().starts_with("//") { + continue; + } + match lines.last_mut() { + Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), + _ => lines.push((n, function, line.to_owned())), + } + } + lines +} + /// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, -/// the top-level function or `impl` method it is in, line, name). A line rustfmt continued (a -/// method chain split as `view` / `.router` / `.owner(i)`) is read with the one it continues. +/// the top-level function or `impl` method it is in, line, name) ([`code_lines`]). fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // The top-level function, or method of an `impl`, that each line is in. - let (mut function, mut in_impl) = (None, false); - let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); - for (n, line) in outside_test_modules(&source) { - if line.starts_with("impl") { - (function, in_impl) = (None, true); - } else if line == "}" { - (function, in_impl) = (None, false); - } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { - function = Some(name); - } - if line.trim_start().starts_with("//") { - continue; - } - match lines.last_mut() { - Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), - _ => lines.push((n, function, line.to_owned())), - } - } - for (n, function, text) in lines { + for (n, function, text) in code_lines(&source) { for name in find(&text) { found.push((file, function.map(str::to_owned), n, name)); } @@ -328,36 +334,45 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, found } -/// Each hit test a page module imports under a name that is not a hit test's (`use …::hit as -/// under;`), which the checks by name would not see. +/// Each hit test `source` imports under a name that is not a hit test's (`use …::hit as under;`), +/// as (name, alias), from each `use` statement, from its first line to its `;`, wherever it is. +fn aliased_hit_tests(source: &str) -> Vec<(String, String)> { + let mut found = Vec::new(); + let mut statement: Option = None; + for line in source.lines().map(str::trim) { + if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { + statement = Some(String::new()); + } + let Some(text) = statement.as_mut() else { continue }; + text.push_str(line); + text.push(' '); + if !line.ends_with(';') { + continue; + } + for part in text.split([',', '{', '}', ';']) { + let Some((path, alias)) = part.split_once(" as ") else { continue }; + let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); + if is_hit_test(name) && !is_hit_test(alias) { + found.push((name.to_owned(), alias.to_owned())); + } + } + statement = None; + } + found +} + +/// Each hit test a page module imports under a name that is not a hit test's, which the checks by +/// name would not see ([`aliased_hit_tests`]). fn hit_test_aliases() -> Vec { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module"); - // Each `use` statement, from its first line to its `;`, wherever it is. - let mut statement: Option = None; - for line in source.lines().map(str::trim) { - if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { - statement = Some(String::new()); - } - let Some(text) = statement.as_mut() else { continue }; - text.push_str(line); - text.push(' '); - if !line.ends_with(';') { - continue; - } - for part in text.split([',', '{', '}', ';']) { - let Some((path, alias)) = part.split_once(" as ") else { continue }; - let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); - if is_hit_test(name) && !is_hit_test(alias) { - found.push(format!( - "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: \ - name it `hit` or `…_hit`." - )); - } - } - statement = None; + for (name, alias) in aliased_hit_tests(&source) { + found.push(format!( + "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: name \ + it `hit` or `…_hit`." + )); } } found @@ -527,7 +542,7 @@ const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ ]; /// The comments' point test, which their hit tests are built on: which comment lies under a point. -const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; +const COMMENT_POINT_TESTS: [&str; 2] = [".comment_at(", "PageCx::comment_at("]; /// The functions that ask which comment lies under a point: (file, function, why). const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ @@ -620,6 +635,28 @@ fn page_modules_act_on_a_press_only_through_the_router() { Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", "no longer reads who holds the press: remove it from ROUTER_READERS.", )); + // A router method the lists above don't name yet is the router's all the same: only their + // functions use it. + let known: Vec<&str> = ROUTER_WRITES.iter().chain(ROUTER_READS.iter()).copied().collect(); + let unlisted = page_module_matches(|line| { + let router = uses(line, ".router.") || uses(line, "PageRouter::"); + if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() } + }); + problems.extend( + unlisted + .into_iter() + .filter(|(file, function, _, _)| { + !ROUTER_WRITERS.iter().chain(ROUTER_READERS.iter()).any(|(f, name, _)| f == file && function.as_deref() == Some(*name)) + }) + .map(|(file, _, n, _)| { + format!( + "src/{file}:{n}: uses the router by a method ROUTER_WRITES and ROUTER_READS don't name. The page view \ + decides the press and tells each module what it is to its page (`PagePress`): add the method to \ + ROUTER_WRITES or ROUTER_READS in src/canvas/page_pointer/tests.rs, and the function to their lists \ + only if it must." + ) + }), + ); problems.extend(misuses( &COMMENT_POINT_TESTS, &COMMENT_POINT_TESTERS, @@ -697,6 +734,36 @@ fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() { assert_eq!(kept, [(1, "fn before() {}"), (6, ""), (7, "fn after() {}"), (10, "fn last() {}")]); } +/// The guard's readers: hit tests by name, the function a line declares, a call rustfmt split, +/// and a hit test imported as another name. +#[test] +fn the_page_guards_read_hit_tests_split_calls_and_aliases() { + assert_eq!(hit_test_calls("let h = crate::forms_ui::hit(xf, p);"), ["hit"]); + assert_eq!(hit_test_calls("cx.comment_hit(p) && picture_hit(p)"), ["comment_hit", "picture_hit"]); + assert!(hit_test_calls("pub(crate) fn hit(xf: &PageXform) -> Option {").is_empty()); + assert!(hit_test_calls("let all = self.hits(view, p, false);").is_empty()); + assert_eq!(declares("pub(crate) const fn crop_hit<'a>(view: &DocView) {"), Some("crop_hit")); + assert_eq!(declares("let hit = 1;"), None); + let source = "impl PageHits<'_> {\n fn at(&self) {\n self\n .hits(view, p, false)\n }\n}\nfn after() {\n // view.router.owner(0)\n let _ = view\n .router\n .owner(0);\n}\n"; + assert_eq!( + code_lines(source), + [ + (1, None, "impl PageHits<'_> {".to_owned()), + (2, Some("at"), " fn at(&self) {".to_owned()), + (3, Some("at"), " self.hits(view, p, false)".to_owned()), + (5, Some("at"), " }".to_owned()), + (6, None, "}".to_owned()), + (7, Some("after"), "fn after() {".to_owned()), + (9, Some("after"), " let _ = view.router.owner(0);".to_owned()), + (12, None, "}".to_owned()), + ] + ); + let source = + "fn a() {}\n\n#[cfg(any())]\nuse crate::camera_ui::hit as corner_under;\nuse crate::x::{\n hit as x_hit,\n picture_hit as under,\n};\n"; + let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec(); + assert_eq!(aliased_hit_tests(source), aliases); +} + #[test] fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { assert!(routed_press_handler("let owner = view.router.owner(page);")); From b77903012d8433f5aac5d2cd1e6d1e64c5f3c401 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 21:51:52 +0100 Subject: [PATCH 25/49] fix(ui): a camera click on another page moves the quadrilateral there Found in review (CodeRabbit): with the camera tool, a press nobody holds on a page without the quadrilateral moves it there, but a click was located by its press's origin, which egui has cleared by the release frame. So only a drag moved it, and a click did nothing. This was so on `main` too. A click is now located where it came up; `PagePress::Free` already says its press began on this page. Test: `a_camera_click_on_another_page_moves_the_quadrilateral_there`, seen failing without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/camera_ui.rs | 5 ++++- crates/ui-egui/tests/it/pointer.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index ce88da25cf..f2e07fab35 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -98,9 +98,12 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe if view.camera.is_none() && page == view.current { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } + // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it + // began, a click where it came up (egui has cleared the press's origin by its release). + let at = if resp.clicked() { pointer } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free - && origin.is_some_and(|o| xf.rect.contains(o)) + && at.is_some_and(|p| xf.rect.contains(p)) && view.camera.is_none_or(|q| q.page != page) { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 8364f3a635..2fc79d1f63 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1725,3 +1725,17 @@ fn a_right_click_between_the_pages_anchors_no_pages_menu() { right_click(&mut h, gap); assert_eq!(h.state().views[0].comments.context_at, None); } + +/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it +/// does. The click was located by its press's origin, which egui has cleared by the release frame, +/// so it did nothing (on `main` too). +#[test] +fn a_camera_click_on_another_page_moves_the_quadrilateral_there() { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page"); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0)); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1)); +} From bdd2ff0ee64cef3b3bc74d6d95c88a29e3ee9a10 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 22:04:19 +0100 Subject: [PATCH 26/49] fix(ui): a camera click is located where it came up Found in review (CodeRabbit): the last fix located a camera click by the pointer, which can have moved on by the end of the release's frame. A click pressed on page 2 and released on page 1, with the pointer back on page 2 by then, moved the quadrilateral to page 2. `PagePointer` keeps each release's position, so `PagePointer::released` gives where the click came up, and the click is located there. Test: `a_camera_click_released_off_its_page_moves_nothing`, seen failing without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_pointer.rs | 9 +++++++++ crates/ui-egui/src/tools/camera_ui.rs | 5 +++-- crates/ui-egui/tests/it/pointer.rs | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 02cb2208ed..f56614411d 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -78,6 +78,15 @@ impl PagePointer { pub(crate) fn pressed(&self) -> Option { first_press(&self.edges) } + + /// Where this frame's last primary release was: where a click came up, which the pointer may + /// have left by the frame's end. + pub(crate) fn released(&self) -> Option { + self.edges.iter().rev().find_map(|e| match e { + Edge::Release(p) => Some(*p), + Edge::Press(_) => None, + }) + } } /// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index f2e07fab35..99b4356a26 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -99,8 +99,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it - // began, a click where it came up (egui has cleared the press's origin by its release). - let at = if resp.clicked() { pointer } else { origin }; + // began, a click where it came up (egui has cleared the press's origin by its release, and the + // pointer may have moved on in the same frame). + let at = if resp.clicked() { ptr.released() } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free && at.is_some_and(|p| xf.rect.contains(p)) diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 2fc79d1f63..d01a74cff0 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1739,3 +1739,22 @@ fn a_camera_click_on_another_page_moves_the_quadrilateral_there() { mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0)); assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1)); } + +/// A camera click is located where it came up, not where the pointer went after it in the same +/// frame: pressed on page 2 and released on page 1 (the gaps hidden), it moves nothing, though the +/// pointer is back on page 2 by the frame's end. The pointer's last position used to move the +/// quadrilateral to page 2. +#[test] +fn a_camera_click_released_off_its_page_moves_nothing() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::EnhanceCamera); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page"); + let (press, release, back) = (seam + egui::vec2(0.0, 2.0), seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 40.0)); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + let up = egui::Event::PointerButton { pos: release, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [up, egui::Event::PointerMoved(back)]); + h.run_steps(2); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0)); +} From a0dfbecd4c897d8c0857e50fe905833da71a3586 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 22:16:00 +0100 Subject: [PATCH 27/49] fix(ui): a frame's last release is kept past the cap on its edges Found in review (CodeRabbit): `PagePointer` keeps a frame's first eight primary presses and releases, and the camera's click was located at the last release among those. After a stall with more presses and releases than that, the click was located at an earlier release, so the quadrilateral could go to the wrong page. The frame's last release is now read from all its events (`PagePointer::released`), however many came before it. Test: `a_frames_last_release_is_kept_past_the_cap_on_its_edges`, seen failing with the release read from the capped edges. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_pointer.rs | 36 ++++++++++++------- .../ui-egui/src/canvas/page_pointer/tests.rs | 14 ++++++++ crates/ui-egui/src/tools/camera_ui.rs | 2 +- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index f56614411d..38b028de24 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -39,6 +39,9 @@ pub(crate) struct PagePointer { pub origin: Option, /// This frame's primary presses and releases of a press the pages hold, in order. pub edges: Vec, + /// Where this frame's last primary release of a press the pages hold was: where a click came + /// up, which the pointer may have left by the frame's end. Kept past the cap on `edges`. + pub released: Option, /// The primary button is down, wherever it was pressed: a gesture ends once it is up. pub primary_down: bool, /// Some button is down, wherever it was pressed. @@ -49,8 +52,16 @@ impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while /// a dialog is open over the window or not (`modal`). pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { - let (at, origin, edges, primary_down, any_down) = - ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down())); + let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| { + ( + i.pointer.interact_pos(), + i.pointer.press_origin(), + edges(&i.events), + last_release(&i.events), + i.pointer.primary_down(), + i.pointer.any_down(), + ) + }); // egui's own answer to "do the pages hold a press?": from the press on until the frame // of its click, release or drag stop. A drag can outlive egui's press while the // pointer is outside the window. With a dialog open only a drag counts, up to the frame @@ -64,9 +75,9 @@ impl PagePointer { resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p)))); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; + return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down }; } - Self { pos, origin: press_origin, edges, primary_down, any_down } + Self { pos, origin: press_origin, edges, released, primary_down, any_down } } /// Where the pointer is, when it is inside `r`. @@ -78,15 +89,6 @@ impl PagePointer { pub(crate) fn pressed(&self) -> Option { first_press(&self.edges) } - - /// Where this frame's last primary release was: where a click came up, which the pointer may - /// have left by the frame's end. - pub(crate) fn released(&self) -> Option { - self.edges.iter().rev().find_map(|e| match e { - Edge::Release(p) => Some(*p), - Edge::Press(_) => None, - }) - } } /// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's @@ -106,6 +108,14 @@ fn first_press(edges: &[Edge]) -> Option { }) } +/// Where the last primary release among `events` was, however many came before it. +fn last_release(events: &[Event]) -> Option { + events.iter().rev().find_map(|e| match e { + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, .. } if pos.is_finite() => Some(*pos), + _ => None, + }) +} + /// The first [`MAX_EDGES`] primary presses and releases among `events`, in order. fn edges(events: &[Event]) -> Vec { events diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f3c0952527..3f5d1e105f 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -53,6 +53,20 @@ fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() { assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after"); } +/// A click is located where it came up even after a stall's many presses and releases, past the +/// cap on a frame's edges. Read from the edges kept, it was an earlier release's. +#[test] +fn a_frames_last_release_is_kept_past_the_cap_on_its_edges() { + let ctx = Context::default(); + let (p, q) = (pos2(50.0, 60.0), pos2(80.0, 90.0)); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let mut events: Vec = (0..2 * MAX_EDGES).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect(); + events.extend([button(q, PointerButton::Primary, true), button(q, PointerButton::Primary, false)]); + let stall = frame(&ctx, events, false); + assert_eq!(stall.edges.len(), MAX_EDGES); + assert_eq!(stall.released, Some(q)); +} + #[test] fn a_press_and_release_in_one_frame_keep_the_press() { let ctx = Context::default(); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index 99b4356a26..817d77bb9f 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -101,7 +101,7 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it // began, a click where it came up (egui has cleared the press's origin by its release, and the // pointer may have moved on in the same frame). - let at = if resp.clicked() { ptr.released() } else { origin }; + let at = if resp.clicked() { ptr.released } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free && at.is_some_and(|p| xf.rect.contains(p)) From e69d1221ff4bc588d28edc4afe30ff7cfe85191f Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 14:41:30 +0100 Subject: [PATCH 28/49] fix(ui): a right-click between the pages deselects the comment; Paste keeps to the picture's page Two of CodeRabbit's findings on #5803. A right-click off the pages cleared only the menu's anchor and kept the selected comment, so the menu that opened was that comment's, not the current page's. A right-click on a page's empty part deselects it, so one that no page takes (`PageFrame.right_clicked`) now does the same. A selected Prepare field keeps its menu off the pages, as it does on a page's empty part; the comment there says so. Where pages touch, the first page takes a right-click on the row they share and anchors the menu there, but a picture on the second page under it still opens its menu. Paste from that menu centred the image on page 1's point, which on page 2 is at its foot. The image menu now uses the anchor only on the picture's own page, and otherwise pastes as Ctrl+V does. Tests, each seen failing with its fix reverted: `a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments`, `paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 24 ++++++++++-- crates/ui-egui/tests/it/pointer.rs | 60 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b22a38956..a1eeeafadb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1662,7 +1662,9 @@ fn draw_pages( // Set again below when the right-click lands on a link. view.links.context = None; // Set again below by the page right-clicked: a right-click off the pages (between them, - // beside one) anchors no page's menu, which then acts on the current page. + // beside one) anchors no page's menu, which then acts on the current page. It deselects the + // comment too (below), as one on a page's empty part does. A selected Prepare field keeps + // its menu, off the pages as on them. view.comments.context_at = None; let mut over_image = false; if tool == QuickTool::EditText { @@ -1734,8 +1736,22 @@ fn draw_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, .. + wanted, + visible_now, + mut current, + best_overlap, + current_overlap, + current_height, + pressed_page, + calc_hover, + out: page_out, + right_clicked, + .. } = frame; + // A right-click no page took deselects the comment, so the page's menu opens, not the comment's. + if resp.secondary_clicked() && !right_clicked { + view.comments.selected = None; + } let PageOut { mut canvas_action, clicked_link, @@ -1905,7 +1921,9 @@ fn draw_pages( })); if is_image_menu && let Some(sel) = view.image_selection.clone() { let images = cached_edit_images(view, doc, sel.page).to_vec(); - let at = view.comments.context_at.map(|(_, at)| at); + // Paste lands where the menu opened only on the picture's page: where pages touch, the + // first page takes the right-click, and its point means nothing on the second. + let at = view.comments.context_at.filter(|(page, _)| *page == sel.page).map(|(_, at)| at); let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360; crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d01a74cff0..50801d903f 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1726,6 +1726,66 @@ fn a_right_click_between_the_pages_anchors_no_pages_menu() { assert_eq!(h.state().views[0].comments.context_at, None); } +/// Two 300×400 pages, a rectangle comment on the first. +fn two_pages_and_a_comment_pdf() -> Vec { + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /F 4 /T (Reviewer) >>", + ]) +} + +/// A right-click between the pages selects no comment, as one on a page's empty part does not, so +/// the menu that opens is the current page's. The selected comment's menu used to open there. +#[test] +fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments() { + let mut h = open(two_pages_and_a_comment_pdf(), "100", false); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0); + assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}"); + right_click(&mut h, gap); + assert_eq!(h.state().views[0].comments.selected, None); + assert!(h.query_by_label("Rotate view counterclockwise").is_some(), "the page menu opens"); + assert!(h.query_by_label("Reply").is_none(), "not the comment's"); +} + +/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there +/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on +/// page 1's point, which on page 2 lies at its foot. +#[test] +fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() { + let (mut h, seam) = touching_pages(|app| { + app.system_clipboard = false; + app.quick_tool = QuickTool::EditText; + }); + let clip = || pdfcraft_ui_egui::Clip::Image { width: 60, height: 40, rgba: [50u8, 150, 250, 255].repeat(60 * 40) }; + h.state_mut().clipboard_override = Some(clip()); + assert!(h.state_mut().paste_image_at(1, [100.0, 400.0]), "a picture across page 2's top edge"); + h.state_mut().clipboard_override = Some(clip()); + h.state_mut().views[0].image_selection = None; + h.run_steps(4); + right_click(&mut h, seam); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click"); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu"); + h.get_by_label("Paste").click(); + h.run_steps(3); + let pasted = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().page_images(1) + }; + assert_eq!(pasted.len(), 2, "the image is pasted on page 2"); + let r = pasted[1].rect; + assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}"); +} + /// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it /// does. The click was located by its press's origin, which egui has cleared by the release frame, /// so it did nothing (on `main` too). From 01d3479f2680d1a931569a3605f540a6aac3b0dd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:06:56 +0100 Subject: [PATCH 29/49] M2: a JBIG2 region located past i32::MAX is off the page A region's location is unsigned (ISO/IEC 14492 5.4.4), and hayro-jbig2 took it for a signed one. A region at 0xFFFFFFFC was drawn back onto the page four pixels left of it, and a refinement anywhere past i32::MAX, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. Vendored patch 14 (13 is #5999): Bitmap::combine_region keeps such a location past the page, for the four kinds of region drawn onto it, and a refinement's offset to its reference is taken in 64 bits and held to an i32, which leaves every reference pixel it reads outside the reference, as the exact offset does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 51 ++++++++++ vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 11 +++ .../src/decode/generic_refinement.rs | 53 +++++++---- vendor/hayro-jbig2/src/lib.rs | 93 ++++++++++++++----- 5 files changed, 168 insertions(+), 42 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index a018e75937..6d6759e3ea 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -143,6 +143,57 @@ mod tests { assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); } + /// A region's location is unsigned (ISO/IEC 14492 5.4.4), so one past `i32::MAX` is off the + /// page. The decoder took it for a signed one: a region at 0xFFFFFFFC came back onto the page + /// four pixels left of it, and a refinement anywhere past `i32::MAX` was refused, and the page + /// with it. Each stream is a page of 16 × 12 covered by a generic region, then a region of 8 × 6 + /// of one kind past `i32::MAX`, coded by the standard's procedures, and decodes to that page + /// alone; moved onto the page, the region draws. Vendored hayro-jbig2 patch 14. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + const PAGE: &str = "0000000030000100000013000000100000000c00000000000000004200000000000126000100000035000000100000000c0000000000000000040003fffdff02fefefed906aa2cb6ee4ccb4bc975d02669ca7a9c14c20c638f5a5e1bffac"; + let cases = [ + // At 0xFFFFFFFC, 1. + ("generic", "00000002260001000000220000000800000006fffffffc00000001000003fffdff02fefefe17c876ae6f77ffac"), + // At 1, 0xFFFFFFFE, with its symbol dictionary. + ( + "text", + "000000020000010000001e000003fffdff02fefefe00000001000000014fdda14149cec477ed5fffac00000003062002010000001b000000080000000600000001fffffffe00001000000001a943ffac", + ), + // At 0xFFFFFFFC, 1, with its pattern dictionary. + ( + "halftone", + "000000021000010000000a000101000000011fffac00000003162002010000002e0000000800000006fffffffc0000000100000000000800000006000000000000000001000000d09301774485ffac", + ), + // At 0xFFFFFFFC, 1, reading the page there (7.4.7.4): 0s. + ("refinement of the page", "000000022a00010000001e0000000800000006fffffffc000000010400ffffffff0bd98f188428ffac"), + // Of an intermediate region at the same place (7.4.7.5), 1, 0xFFFFFFFE. + ( + "refinement of a region", + "0000000224000100000023000000080000000600000001fffffffe000003fffdff02fefefe3d81322da16b7fffac000000032a2002010000001e000000080000000600000001fffffffe0000ffffffffe29946b82229ffac", + ), + // Of an intermediate refinement of an intermediate region, all at 0xFFFFFFFC, 1. + ( + "refinement of a refinement", + "00000002240001000000230000000800000006fffffffc00000001000003fffdff02fefefecdfd921ec8c47fffac00000003282002010000001e0000000800000006fffffffc000000010000ffffffff903cdb447a47ffac000000042a2003010000001e0000000800000006fffffffc000000010000ffffffffdcd6078cab86ffac", + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + let decode = |segments: &str| decode_jbig2(&[hex(PAGE), hex(segments)].concat(), None, 16, 12, usize::MAX).unwrap(); + let page = decode(""); + assert!(page.iter().any(|&byte| byte != 0xFF), "the page has ink"); + for (kind, segments) in cases { + assert_eq!(decode(segments), page, "{kind}"); + // Moved onto the page, to 4, 1 or 1, 2, the same regions draw: they decode, and are + // left out for where they are. + let (off, on) = [("fffffffc00000001", "0000000400000001"), ("00000001fffffffe", "0000000100000002")] + .into_iter() + .find(|(off, _)| segments.contains(off)) + .unwrap(); + assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); + } + } + /// What a page costs is the same however much a run has left. Given exactly that it decodes, /// and less stops it: spending all that's left, and what comes after isn't read. A page that /// fails still spends what it took. diff --git a/vendor/README.md b/vendor/README.md index 4fda6483af..c668c77641 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page, and a refinement's offset to its reference is taken in 64 bits and held to an `i32`, which leaves every reference pixel it reads outside the reference, as the exact offset does | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `decode::generic_refinement::tests::a_reference_offset_is_the_difference_of_unsigned_locations` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 87738a849d..e9049f84bf 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -190,6 +190,17 @@ impl Bitmap { self.data[word_idx] |= (value as Word) << bit_pos; } + /// PdfCraft patch: combine a decoded region into this page at the region's own location. + /// + /// "Unless otherwise specified, all multi-bit values shall be treated as unsigned values" + /// (5.4.4), and a region's location (7.4.1.3, 7.4.1.4) is not specified otherwise. One past + /// `i32::MAX` is past any page, where cast to a signed coordinate it read as left of the page + /// or above it, and came back onto it. + pub(crate) fn combine_region(&mut self, region: &Self, operator: CombinationOperator) { + let signed = |location: u32| i32::try_from(location).unwrap_or(i32::MAX); + self.combine(region, signed(region.x_location), signed(region.y_location), operator); + } + /// Combine another bitmap into this one at a specific location. /// /// "These operators describe how the segment's bitmap is to be combined with diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index a6634439d4..c13d9bae65 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -10,7 +10,7 @@ use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; use crate::bitmap::{Bitmap, WORD_BITS, WORD_SHIFT, Word}; use crate::decode::generic::ContextGatherer; -use crate::error::{OverflowError, ParseError, RegionError, Result, bail}; +use crate::error::{ParseError, RegionError, Result, bail}; use crate::reader::Reader; /// Generic refinement region decoding procedure (6.3). @@ -35,6 +35,20 @@ pub(crate) fn decode( }) } +/// PdfCraft patch: the offset of a refinement's reference from the region, from their locations. +/// +/// A location is unsigned (5.4.4), so one past `i32::MAX` is a place off the page, not an +/// overflow: a refinement there, of a region at the same place (7.4.7.5) or of the page, was +/// refused, and the rest of the stream with it. Both bitmaps are at most 65,535 pixels a side, so +/// an offset of `i32::MAX` either way already puts every reference pixel the decoder reads outside +/// the reference, where they are 0 (6.3.5.2), as any larger offset would. +fn reference_offset(reference: u32, region: u32) -> i32 { + let limit = i64::from(i32::MAX); + let offset = (i64::from(reference) - i64::from(region)).clamp(-limit, limit); + // Provably in range after the clamp. + i32::try_from(offset).unwrap_or(0) +} + /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, @@ -51,22 +65,8 @@ pub(crate) fn decode_into( bail!(RegionError::InvalidDimension); } - let reference_dx = i32::try_from(reference.x_location) - .ok() - .and_then(|r: i32| { - i32::try_from(header.region_info.x_location) - .ok() - .and_then(|h| r.checked_sub(h)) - }) - .ok_or(OverflowError::ReferenceOffset)?; - let reference_dy = i32::try_from(reference.y_location) - .ok() - .and_then(|r: i32| { - i32::try_from(header.region_info.y_location) - .ok() - .and_then(|h| r.checked_sub(h)) - }) - .ok_or(OverflowError::ReferenceOffset)?; + let reference_dx = reference_offset(reference.x_location, header.region_info.x_location); + let reference_dy = reference_offset(reference.y_location, header.region_info.y_location); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -509,3 +509,22 @@ pub(crate) fn decode_bitmap( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// PdfCraft patch: the reference's offset is the difference of the locations, which past + /// `i32::MAX` are places off the page: equal ones give 0, as a refinement of a region has them + /// (7.4.7.5), and one too far for an `i32` is held to `i32::MAX`, either way. + #[test] + fn a_reference_offset_is_the_difference_of_unsigned_locations() { + assert_eq!(reference_offset(5, 3), 2); + assert_eq!(reference_offset(3, 5), -2); + assert_eq!(reference_offset(0x9000_0000, 0x9000_0000), 0, "a region and its refinement off the page"); + assert_eq!(reference_offset(0, 0xFFFF_FFFC), -i32::MAX, "the page and a refinement of it off the page"); + assert_eq!(reference_offset(u32::MAX, 0), i32::MAX); + assert_eq!(reference_offset(0x7FFF_FFFF, 0), i32::MAX, "in range"); + assert_eq!(reference_offset(0, 0x7FFF_FFFF), -i32::MAX, "in range"); + } +} diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 2ee0f8f050..5dd81bc6c0 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -356,12 +356,7 @@ fn decode_segments( generic::decode_into(&header, page_bitmap, scratch_buffers)?; } else { let region = generic::decode(&header, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -460,12 +455,7 @@ fn decode_segments( scratch_buffers, work_left, )?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -518,12 +508,7 @@ fn decode_segments( halftone::decode_into(&header, pattern_dict, page_bitmap, scratch_buffers)?; } else { let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -582,12 +567,7 @@ fn decode_segments( } else { let reference = referred_segment.unwrap_or(page_bitmap); let region = generic_refinement::decode(&header, reference, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -833,6 +813,71 @@ mod tests { assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); } + /// PdfCraft patch: a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the + /// page. Taken for a signed one, a region at 0xFFFFFFFE drew back onto the page two pixels left + /// of it, and a refinement there was refused, and the rest of the stream with it. Each region + /// here is 4 × 4 on a blank page of 8 × 8, its coded data arbitrary: on the page it draws, off + /// it nothing. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + struct Ink(u32); + impl Decoder for Ink { + fn push_pixel(&mut self, black: bool) { + self.0 += u32::from(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.0 += if black { 8 * chunks } else { 0 }; + } + fn next_line(&mut self) {} + } + let segment = |number: u8, kind: u8, refers_to: &[u8], data: &[u8]| { + let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; + segment.extend_from_slice(refers_to); + segment.push(1); // the page it belongs to + segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); + segment.extend_from_slice(data); + segment + }; + // Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own header, then + // arbitrary coded data. + let region = |x: u32, y: u32, operator: u8, header: &[u8]| { + let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); + data.push(operator); + data.extend_from_slice(header); + data.extend_from_slice(&[0x5a; 16]); + data + }; + const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT + const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT + let (or, replace) = (0, 4); + let ink = |x: u32, y: u32| { + // 8 × 8, resolution unknown, flags, not striped. + let page = segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]); + let forms = [ + // An immediate generic region. + vec![segment(1, 38, &[], ®ion(x, y, or, GENERIC))], + // An immediate refinement of the page, which reads the page in its own area (7.4.7.4). + vec![segment(1, 42, &[], ®ion(x, y, replace, REFINEMENT))], + // An immediate refinement of an intermediate region at the same place (7.4.7.5). + vec![ + segment(1, 36, &[], ®ion(x, y, or, GENERIC)), + segment(2, 42, &[1], ®ion(x, y, or, REFINEMENT)), + ], + ]; + forms.map(|form| { + let stream = [page.clone(), form.concat()].concat(); + let mut ink = Ink(0); + Image::new_embedded(&stream, None).and_then(|image| image.decode(&mut ink)).map(|()| ink.0) + }) + }; + for (x, y) in [(0, 0), (2, 2)] { + assert!(ink(x, y).iter().all(|ink| matches!(ink, Ok(n) if *n > 0)), "{x}, {y}: {:?}", ink(x, y)); + } + for (x, y) in [(0xFFFF_FFFE, 0), (0, 0xFFFF_FFFE), (0x8000_0000, 0x8000_0000), (u32::MAX, u32::MAX)] { + assert_eq!(ink(x, y).map(|ink| ink.ok()), [Some(0); 3], "{x:#x}, {y:#x}"); + } + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From b9e068aa3662d603289bfd8e10ff5a027c2b98d6 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:32:29 +0100 Subject: [PATCH 30/49] M2: a JBIG2 refinement of a region refines it where it is "GRREFERENCEDX 0, GRREFERENCEDY 0" (ISO/IEC 14492 Table 35): a refinement region segment that refers to another region refines that region where it is. hayro-jbig2 offset the reference by the difference of the two locations, which 7.4.7.5 says must be equal; where a stream has them differ, the refinement decoded against a shifted reference, or, the region past i32::MAX, against none. It now decodes as coded, and only a refinement of the page takes an offset, from its own location. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 8 +++ vendor/README.md | 2 +- .../src/decode/generic_refinement.rs | 53 ++++++++++--------- vendor/hayro-jbig2/src/lib.rs | 13 +++-- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 6d6759e3ea..a10de4af45 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -192,6 +192,14 @@ mod tests { .unwrap(); assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); } + + // A refinement at 2, 0 of an intermediate region at 0x80000000, 0. A refinement of a + // region refines it where it is, "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35), and the two + // should be at one place (7.4.7.5). Where they aren't, it decodes as it does where they are. + const ELSEWHERE: &str = "000000022400010000002300000008000000068000000000000000000003fffdff02fefefe1bca3149568cdfffac000000032a2002010000001e000000080000000600000002000000000000ffffffff784a909cc761ffac"; + let refined = decode(ELSEWHERE); + assert_eq!(refined, decode(&ELSEWHERE.replace("8000000000000000", "0000000200000000")), "the region at 2, 0 too"); + assert_ne!(refined, page, "drawn"); } /// What a page costs is the same however much a run has left. Given exactly that it decodes, diff --git a/vendor/README.md b/vendor/README.md index c668c77641..db21b1407e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page, and a refinement's offset to its reference is taken in 64 bits and held to an `i32`, which leaves every reference pixel it reads outside the reference, as the exact offset does | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `decode::generic_refinement::tests::a_reference_offset_is_the_difference_of_unsigned_locations` | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index c13d9bae65..fe7f8cc33d 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -17,6 +17,7 @@ use crate::reader::Reader; pub(crate) fn decode( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, + refines_page: bool, ctx: &mut ScratchBuffers, ) -> Result { let mut region = Bitmap::new_with( @@ -27,7 +28,7 @@ pub(crate) fn decode( false, )?; - decode_into(header, reference, &mut region, ctx)?; + decode_into(header, reference, refines_page, &mut region, ctx)?; Ok(RegionBitmap { bitmap: region, @@ -35,24 +36,29 @@ pub(crate) fn decode( }) } -/// PdfCraft patch: the offset of a refinement's reference from the region, from their locations. +/// PdfCraft patch: the offsets of a refinement region segment's reference from the region at `x`, +/// `y`, the GRREFERENCEDX and GRREFERENCEDY of 6.3. /// -/// A location is unsigned (5.4.4), so one past `i32::MAX` is a place off the page, not an -/// overflow: a refinement there, of a region at the same place (7.4.7.5) or of the page, was -/// refused, and the rest of the stream with it. Both bitmaps are at most 65,535 pixels a side, so -/// an offset of `i32::MAX` either way already puts every reference pixel the decoder reads outside -/// the reference, where they are 0 (6.3.5.2), as any larger offset would. -fn reference_offset(reference: u32, region: u32) -> i32 { - let limit = i64::from(i32::MAX); - let offset = (i64::from(reference) - i64::from(region)).clamp(-limit, limit); - // Provably in range after the clamp. - i32::try_from(offset).unwrap_or(0) +/// "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35): a refinement of another region refines it where +/// it is, whatever their locations say (they should be equal, 7.4.7.5). They were subtracted, so a +/// refinement of a region past `i32::MAX` was refused as an overflow, and the rest of the stream +/// with it. A refinement of the page refines the page at the refinement's location (7.4.7.4), the +/// page being at 0, 0. A location is unsigned (5.4.4), and the page is at most 65,535 pixels a +/// side, so held to `i32::MAX` one past that still puts every page pixel the decoder reads outside +/// the page, where they are 0 (6.3.5.2). +fn reference_offsets(refines_page: bool, x: u32, y: u32) -> (i32, i32) { + if !refines_page { + return (0, 0); + } + let offset = |location: u32| i32::try_from(location).map_or(-i32::MAX, |location| -location); + (offset(x), offset(y)) } /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, + refines_page: bool, region: &mut Bitmap, ctx: &mut ScratchBuffers, ) -> Result<()> { @@ -65,8 +71,8 @@ pub(crate) fn decode_into( bail!(RegionError::InvalidDimension); } - let reference_dx = reference_offset(reference.x_location, header.region_info.x_location); - let reference_dy = reference_offset(reference.y_location, header.region_info.y_location); + let (reference_dx, reference_dy) = + reference_offsets(refines_page, header.region_info.x_location, header.region_info.y_location); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -514,17 +520,14 @@ pub(crate) fn decode_bitmap( mod tests { use super::*; - /// PdfCraft patch: the reference's offset is the difference of the locations, which past - /// `i32::MAX` are places off the page: equal ones give 0, as a refinement of a region has them - /// (7.4.7.5), and one too far for an `i32` is held to `i32::MAX`, either way. + /// PdfCraft patch: a refinement of a region refines it where it is (Table 35), wherever their + /// locations are; a refinement of the page refines it at its own location, held to `i32::MAX` + /// past that. #[test] - fn a_reference_offset_is_the_difference_of_unsigned_locations() { - assert_eq!(reference_offset(5, 3), 2); - assert_eq!(reference_offset(3, 5), -2); - assert_eq!(reference_offset(0x9000_0000, 0x9000_0000), 0, "a region and its refinement off the page"); - assert_eq!(reference_offset(0, 0xFFFF_FFFC), -i32::MAX, "the page and a refinement of it off the page"); - assert_eq!(reference_offset(u32::MAX, 0), i32::MAX); - assert_eq!(reference_offset(0x7FFF_FFFF, 0), i32::MAX, "in range"); - assert_eq!(reference_offset(0, 0x7FFF_FFFF), -i32::MAX, "in range"); + fn a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location() { + assert_eq!(reference_offsets(false, 5, 0x8000_0000), (0, 0)); + assert_eq!(reference_offsets(true, 5, 3), (-5, -3)); + assert_eq!(reference_offsets(true, 0x7FFF_FFFF, 0), (-i32::MAX, 0), "in range"); + assert_eq!(reference_offsets(true, 0x8000_0000, u32::MAX), (-i32::MAX, -i32::MAX)); } } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 5dd81bc6c0..1ada85fd2a 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -527,16 +527,17 @@ fn decode_segments( } SegmentType::IntermediateGenericRefinementRegion => { // Same logic as immediate refinement, but store result instead of combining. - let reference = seg + let referred_segment = seg .header .referred_to_segments .first() - .and_then(|&num| page_state.get_referred_segment(num)) - .unwrap_or(page_bitmap); + .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; charge_work(work_left, region_work(&header.region_info))?; - let region = generic_refinement::decode(&header, reference, scratch_buffers)?; + let reference = referred_segment.unwrap_or(page_bitmap); + let refines_page = referred_segment.is_none(); + let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion @@ -561,12 +562,14 @@ fn decode_segments( generic_refinement::decode_into( &header, referred_segment, + false, page_bitmap, scratch_buffers, )?; } else { let reference = referred_segment.unwrap_or(page_bitmap); - let region = generic_refinement::decode(&header, reference, scratch_buffers)?; + let refines_page = referred_segment.is_none(); + let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; From 858ce9608b17cb0c8a6b3da33428c38911b480a4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:54:08 +0100 Subject: [PATCH 31/49] M2: a test pins which bitmap a JBIG2 refinement refines A region and its refinement, directly and through an intermediate refinement, decode otherwise than the same refinements of the page, and at 2, 3 to exactly the pixels they decode to at 0, 0, moved. A handler that refines the page instead, or reads the region at the page's offsets, fails it, as does dropping intermediate refinements; no test caught those. The test's helpers move to the module. A comment still said a refinement of the page reads "its own area"; it reads the page at its own location. And only a location within a region's size of 2^32 came back onto the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 4 +- vendor/hayro-jbig2/src/lib.rs | 108 +++++++++++++++++++++++-------- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index db21b1407e..c4244ce0d5 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page`, `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are` and `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index e9049f84bf..2e77a639b3 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -194,8 +194,8 @@ impl Bitmap { /// /// "Unless otherwise specified, all multi-bit values shall be treated as unsigned values" /// (5.4.4), and a region's location (7.4.1.3, 7.4.1.4) is not specified otherwise. One past - /// `i32::MAX` is past any page, where cast to a signed coordinate it read as left of the page - /// or above it, and came back onto it. + /// `i32::MAX` is past any page. Cast to a signed coordinate it read as left of the page or above + /// it, and one within a region's size of 2^32 came back onto it. pub(crate) fn combine_region(&mut self, region: &Self, operator: CombinationOperator) { let signed = |location: u32| i32::try_from(location).unwrap_or(i32::MAX); self.combine(region, signed(region.x_location), signed(region.y_location), operator); diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 1ada85fd2a..b78816b1a6 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -816,6 +816,36 @@ mod tests { assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); } + /// Segment `number` of `kind` on page 1, referring to `refers_to`. + fn segment(number: u8, kind: u8, refers_to: &[u8], data: &[u8]) -> Vec { + let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; + segment.extend_from_slice(refers_to); + segment.push(1); + segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); + segment.extend_from_slice(data); + segment + } + + /// A blank page of 8 × 8: resolution unknown, flags, not striped. + fn page_of_8() -> Vec { + segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]) + } + + /// Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own `header`, then + /// arbitrary coded data. + fn region_of_4(x: u32, y: u32, operator: u8, header: &[u8]) -> Vec { + let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); + data.push(operator); + data.extend_from_slice(header); + data.extend_from_slice(&[0x5a; 16]); + data + } + + const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT + const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT + const OR: u8 = 0; + const REPLACE: u8 = 4; + /// PdfCraft patch: a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the /// page. Taken for a signed one, a region at 0xFFFFFFFE drew back onto the page two pixels left /// of it, and a refinement there was refused, and the rest of the stream with it. Each region @@ -833,42 +863,20 @@ mod tests { } fn next_line(&mut self) {} } - let segment = |number: u8, kind: u8, refers_to: &[u8], data: &[u8]| { - let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; - segment.extend_from_slice(refers_to); - segment.push(1); // the page it belongs to - segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); - segment.extend_from_slice(data); - segment - }; - // Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own header, then - // arbitrary coded data. - let region = |x: u32, y: u32, operator: u8, header: &[u8]| { - let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); - data.push(operator); - data.extend_from_slice(header); - data.extend_from_slice(&[0x5a; 16]); - data - }; - const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT - const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT - let (or, replace) = (0, 4); let ink = |x: u32, y: u32| { - // 8 × 8, resolution unknown, flags, not striped. - let page = segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]); let forms = [ // An immediate generic region. - vec![segment(1, 38, &[], ®ion(x, y, or, GENERIC))], - // An immediate refinement of the page, which reads the page in its own area (7.4.7.4). - vec![segment(1, 42, &[], ®ion(x, y, replace, REFINEMENT))], + vec![segment(1, 38, &[], ®ion_of_4(x, y, OR, GENERIC))], + // An immediate refinement of the page, which reads the page at its own location (7.4.7.4). + vec![segment(1, 42, &[], ®ion_of_4(x, y, REPLACE, REFINEMENT))], // An immediate refinement of an intermediate region at the same place (7.4.7.5). vec![ - segment(1, 36, &[], ®ion(x, y, or, GENERIC)), - segment(2, 42, &[1], ®ion(x, y, or, REFINEMENT)), + segment(1, 36, &[], ®ion_of_4(x, y, OR, GENERIC)), + segment(2, 42, &[1], ®ion_of_4(x, y, OR, REFINEMENT)), ], ]; forms.map(|form| { - let stream = [page.clone(), form.concat()].concat(); + let stream = [page_of_8(), form.concat()].concat(); let mut ink = Ink(0); Image::new_embedded(&stream, None).and_then(|image| image.decode(&mut ink)).map(|()| ink.0) }) @@ -881,6 +889,50 @@ mod tests { } } + /// PdfCraft patch: a refinement of a region refines it where it is, "GRREFERENCEDX 0, + /// GRREFERENCEDY 0" (Table 35), not the page at the refinement's location. A region and its + /// refinement, directly or through an intermediate refinement, decode otherwise than the same + /// refinements of the page, and at 2, 3 to the pixels they decode to at 0, 0, moved. + #[test] + fn a_refinement_of_a_region_decodes_the_same_wherever_they_are() { + #[derive(Default)] + struct Pixels(Vec); + impl Decoder for Pixels { + fn push_pixel(&mut self, black: bool) { + self.0.push(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.0.extend((0..8 * chunks).map(|_| black)); + } + fn next_line(&mut self) {} + } + // The first refinement refers to the region before it, or `of_page`, to nothing: the page. + let decode = |x: u32, y: u32, through_intermediate: bool, of_page: bool| { + let first: &[u8] = if of_page { &[] } else { &[1] }; + let mut form = vec![segment(1, 36, &[], ®ion_of_4(x, y, OR, GENERIC))]; + if through_intermediate { + form.push(segment(2, 40, first, ®ion_of_4(x, y, OR, REFINEMENT))); + form.push(segment(3, 42, &[2], ®ion_of_4(x, y, OR, REFINEMENT))); + } else { + form.push(segment(2, 42, first, ®ion_of_4(x, y, OR, REFINEMENT))); + } + let stream = [page_of_8(), form.concat()].concat(); + let mut pixels = Pixels::default(); + Image::new_embedded(&stream, None).unwrap().decode(&mut pixels).unwrap(); + pixels.0 + }; + for through_intermediate in [false, true] { + let at_origin = decode(0, 0, through_intermediate, false); + assert!(at_origin.contains(&true), "draws"); + assert_ne!(at_origin, decode(0, 0, through_intermediate, true), "the page refined instead"); + for (i, &black) in decode(2, 3, through_intermediate, false).iter().enumerate() { + let (x, y) = (i % 8, i / 8); + let moved = x >= 2 && y >= 3 && at_origin[(y - 3) * 8 + x - 2]; + assert_eq!(black, moved, "{x}, {y}, through an intermediate refinement: {through_intermediate}"); + } + } + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From 76260bb6a5032fd9ab3cfd364add751c3f80b828 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:33:41 -0500 Subject: [PATCH 32/49] fix(xfa): scripts come only from the XDP's own packets and quote the date pattern (#5987) The template (and the saved form state) was found anywhere under the XDP root, so a template-shaped element inside imported datasets could supply the form and its event scripts. A date field's pattern went into AFDate_FormatEx/KeystrokeEx string literals unescaped. --- crates/xfa/src/parse.rs | 6 ++++-- crates/xfa/src/pdf.rs | 9 ++++++--- crates/xfa/src/pdf/common.rs | 32 ++++++++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 10 ++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 9ca6a7f3cd..76409dc697 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -746,10 +746,12 @@ pub fn parse(xml: &str) -> Result<(Template, Vec), XfaError> { && n.tag_name().name() == "template" && (n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-template")) || children(*n, "subform").next().is_some()) }; + // The template is a packet of the XDP itself, a child of its root: one nested deeper is + // data (an imported dataset, say), which must not supply the form's scripts. let template = if is_template(&root) { root } else { - root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no