Merge upstream/main into fix/update-without-prev-is-refused

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-08 00:25:48 +08:00
commit 4e522cd067
66 files changed
+4904 -850

No files matched your search

+1 -1
View File
@@ -173,7 +173,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd. `cos`, `filters`, `crypt` and `arlington` stay standalone.
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `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`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `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.
- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p <crate> --all-targets -- -D warnings` and `cargo test -p <crate>` to the crates you touched; the workspace sweep is CI's job.
Generated
+14 -1
View File
@@ -1312,10 +1312,21 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -4755,11 +4766,13 @@ dependencies = [
name = "pdfcraft-web"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
"js-sys",
"pdfcraft-ui-egui",
"serde_json",
"wasm-bindgen-futures",
"web-sys",
]
+4 -1
View File
@@ -12,9 +12,12 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
# The page command API (storytold/craft-libs#413): window.pdfcraft over the UI control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
+127
View File
@@ -0,0 +1,127 @@
//! `window.pdfcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
//! UI control channel (`pdfcraft_ui_egui::control`), opt-in with `?control`.
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is PdfCraft's part: a [`Request`] that
//! sends each call through the app's [`ControlClient`] and waits for the reply, and the table that
//! names PdfCraft's methods: its control channel drives the UI, so `commands` is `ui.commands`,
//! `execute` runs a registry command with `ui.command`, `docInspect` is `ui.state` and `uiInspect`
//! is `ui.inspect`.
//!
//! The channel keeps the accessibility tree of every frame, so it is off unless the page asks for it
//! (`?control`), as FigCraft's is. PdfCraft's control requests cannot be withdrawn, so a call the page
//! gave up on may still run (or be rejected) when the app reaches it, as it does for a desktop client that timed out.
use serde_json::{Value, json};
/// The reply of a control request as the protocol's `{ok, result}` / `{ok: false, error}` object.
fn reply_object(reply: Result<Value, String>) -> Value {
match reply {
Ok(result) => json!({"ok": true, "result": result}),
Err(error) => json!({"ok": false, "error": error}),
}
}
/// The params of `ui.command`. `execute(command, params)` sends `{command, params}`; `ui.command`
/// takes `{id}` (and `params` for `ui.panel`). A request that already names an `id` is the page's own
/// `request("ui.command", {id})` and goes through unchanged.
fn command_params(params: Value) -> Value {
match params.get("command").and_then(Value::as_str) {
Some(command) if params.get("id").is_none() => json!({"id": command, "params": params.get("params").cloned().unwrap_or(Value::Null)}),
_ => params,
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use pdfcraft_ui_egui::control::{ControlClient, Reply};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
use super::{command_params, reply_object};
/// The app id in `window.<app>`.
const APP: &str = "pdfcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// PdfCraft's control methods behind each function.
fn methods() -> Methods {
Methods {
commands: Some("ui.commands"),
execute: Some("ui.command"),
doc_inspect: Some("ui.state"),
ui_inspect: Some("ui.inspect"),
screenshot: Some("ui.screenshot"),
}
}
/// Put `window.pdfcraft` on the page, sending its calls through `client`. An existing
/// `window.pdfcraft` keeps the functions it already has.
pub fn install(client: ControlClient) {
let request: Request = Rc::new(move |method, params| {
let params = if method == "ui.command" { command_params(params) } else { params };
Box::pin(wait(client.send(&method, params)))
});
if let Err(e) = Rc::new(PageApi::new(APP, methods(), request)).install() {
eframe::web_sys::console::warn_1(&format!("PdfCraft: couldn't install window.{APP}: {e:?}").into());
}
}
/// The app's reply, polled about once a frame.
async fn wait(reply: Receiver<Reply>) -> Value {
loop {
match reply.try_recv() {
Ok(reply) => return reply_object(reply),
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
}
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn execute_becomes_a_ui_command_by_id() {
assert_eq!(command_params(json!({"command": "file.word_count", "params": {"a": 1}})), json!({"id": "file.word_count", "params": {"a": 1}}));
assert_eq!(command_params(json!({"command": "file.save", "params": {}})), json!({"id": "file.save", "params": {}}));
}
#[test]
fn a_request_that_names_an_id_goes_through_unchanged() {
let own = json!({"id": "file.save"});
assert_eq!(command_params(own.clone()), own);
let both = json!({"id": "ui.panel", "command": "x", "params": {"reset": true}});
assert_eq!(command_params(both.clone()), both, "an id wins over a command");
assert_eq!(command_params(json!({})), json!({}));
}
#[test]
fn replies_become_the_protocols_objects() {
assert_eq!(reply_object(Ok(json!({"n": 1}))), json!({"ok": true, "result": {"n": 1}}));
assert_eq!(reply_object(Err("boom".to_string())), json!({"ok": false, "error": "boom"}));
}
}
+8
View File
@@ -3,6 +3,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
fn main() {
use pdfcraft_ui_egui::PdfCraftApp;
@@ -68,6 +71,11 @@ fn main() {
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
);
}
// `?control`: `window.pdfcraft`, the page's command API (`api`). Opt-in: the control
// channel keeps the accessibility tree of every frame.
if query_param("control").is_some() {
api::install(app.attach_control(&cc.egui_ctx));
}
Ok(Box::new(app))
}),
)
+5
View File
@@ -26,6 +26,11 @@ its undo/redo history.
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
`page_add_text` and `content_update` take one of `color` (`#RRGGBB` or a name, painted in
DeviceRGB), `gray` (0 black to 1 white, DeviceGray `g`) and `cmyk` (`[c, m, y, k]`, each 0 to 1,
DeviceCMYK `k`), so text added to a print file can be 100 K black. `content_list` reports `gray` or
`cmyk` for such text.
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
## Conventions
+36 -5
View File
@@ -2,7 +2,7 @@
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
//! displayed page (y down), like every other tool.
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign};
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign, TextInk};
use serde_json::{Value, json};
use crate::comments::parse_color;
@@ -30,6 +30,27 @@ fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
.transpose()
}
const INK_ARGS: &str = "give one of color (#RRGGBB or a name), gray (0 black to 1 white) and cmyk ([c, m, y, k], each 0 to 1)";
/// `gray` or `cmyk`: the text painted in DeviceGray or DeviceCMYK rather than RGB. At most one of
/// them and `color`.
fn ink_arg(a: &Args) -> Result<Option<TextInk>> {
if ["color", "gray", "cmyk"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
return Err(bad(INK_ARGS));
}
let unit = |v: Option<f64>, key: &str| v.filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad(format!("{key} takes numbers from 0 to 1")));
if let Some(g) = a.get("gray") {
return Ok(Some(TextInk::Gray(unit(g.as_f64(), "gray")?)));
}
let Some(c) = a.get("cmyk") else { return Ok(None) };
let parts = c.as_array().filter(|p| p.len() == 4).ok_or_else(|| bad("cmyk must be [c, m, y, k]"))?;
let mut cmyk = [0.0; 4];
for (out, v) in cmyk.iter_mut().zip(parts) {
*out = unit(v.as_f64(), "cmyk")?;
}
Ok(Some(TextInk::Cmyk(cmyk)))
}
/// Apply the style arguments to `t`.
fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(s) = a.opt_num("size")? {
@@ -49,8 +70,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(i) = a.opt_bool("italic")? {
t.italic = i;
}
if let Some(c) = a.opt_str("color")? {
t.color = parse_color(c)?;
if let Some(ink) = ink_arg(a)? {
t.set_ink(Some(ink));
} else if let Some(c) = a.opt_str("color")? {
// An RGB colour paints in RGB, even one that is the ink's look.
t.set_ink(None);
t.set_color(parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
t.align = parse_align(al)?;
@@ -124,6 +149,12 @@ impl Automation {
v["bold"] = json!(t.bold);
v["italic"] = json!(t.italic);
v["char_spacing"] = json!(t.char_spacing);
// Text painted in a print colour space says so; RGB text has neither.
match t.painted_ink() {
Some(TextInk::Gray(g)) => v["gray"] = json!(g),
Some(TextInk::Cmyk(c)) => v["cmyk"] = json!(c),
None => {}
}
}
AddedContent::Image(_) => v["type"] = json!("image"),
}
@@ -205,8 +236,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
if ["font", "size", "bold", "italic", "color", "gray", "cmyk", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, gray, cmyk, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
+3
View File
@@ -375,6 +375,9 @@ pub(crate) fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"warnings": i.warnings,
"repairs": d.repair_log(),
// Objects that could not be read (each reads as null): nothing was repaired, what they held is
// lost. Last, so it includes what reading the fields above failed on.
"unreadable_objects": d.unreadable_objects(),
})
}
+50 -2
View File
@@ -20,6 +20,10 @@ pub(crate) const FIND_MAX_WARNINGS: usize = 100;
pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024;
/// HTML that one `text_extract` with `format: "html"` returns across all its pages; a page past
/// it gets none. `html_truncated` covers this and any page whose own copy hit its caps.
const MAX_EXTRACT_HTML_BYTES: usize = 32 << 20;
impl Automation {
// ---- pages and text ----------------------------------------------------------------------
@@ -201,6 +205,16 @@ impl Automation {
}
pub(crate) fn text_extract(&mut self, a: &Args) -> Result<Value> {
self.text_extract_within(a, MAX_EXTRACT_HTML_BYTES)
}
/// [`Self::text_extract`] with at most `html_budget` bytes of HTML across all pages.
pub(crate) fn text_extract_within(&mut self, a: &Args, html_budget: usize) -> Result<Value> {
let html = match a.opt_str("format")?.unwrap_or("text") {
"text" => false,
"html" => true,
other => return Err(ToolError::InvalidArgs(format!("format must be \"text\" or \"html\", not {other:?}"))),
};
let doc = self.doc_to_copy(a)?;
let id = doc.id;
let pages = match a.opt_ints("pages")? {
@@ -214,8 +228,42 @@ impl Automation {
Some(r) => t.column_text(&t.glyphs_in(r)),
None => t.plain_text(),
};
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect();
Ok(json!({ "pages": out }))
let doc = self.doc(a)?;
let mut out = Vec::with_capacity(pages.len());
// `spent`: the budget ran out, so later pages get no HTML. `cut`: some page's HTML is
// missing part of its text, by the budget or by the copy's own caps.
let (mut html_left, mut spent, mut cut) = (html_budget, false, false);
for (&p, t) in pages.iter().zip(texts) {
let mut page = json!({ "page": p + 1, "text": text(&t) });
if html {
// Copy with formatting: the same glyphs as an HTML fragment, within one budget for
// the whole call; a page past it gets none and the call says so.
let mut copy = pdfcraft_engine::RichCopy::default();
if !spent {
let glyphs: Vec<usize> = match rect {
Some(r) => t.glyphs_in(r),
None => (0..t.glyphs.len()).collect(),
};
copy = doc.rich_copy(p, &t, &glyphs).map_err(failed)?;
match html_left.checked_sub(copy.html.len()) {
Some(left) => html_left = left,
None => {
spent = true;
copy = pdfcraft_engine::RichCopy::default();
}
}
}
cut |= spent || copy.truncated;
page["html"] = json!(copy.html);
page["styles_incomplete"] = json!(copy.styles_incomplete || spent);
}
out.push(page);
}
let mut result = json!({ "pages": out });
if html {
result["html_truncated"] = json!(cut);
}
Ok(result)
}
/// Matches in document order, extracting page text in growing batches and stopping as soon as
+32
View File
@@ -550,3 +550,35 @@ fn tool_file_reads_refuse_inputs_exceeding_512_mib() {
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn text_extract_html_has_one_budget_across_pages() {
let mut a = Automation::new();
let id = search_document(&mut a, 3);
let args = json!({ "doc": id.0, "format": "html" });
let full = a.text_extract(&Args(&args)).unwrap();
assert_eq!(full["html_truncated"], false);
let sizes: Vec<usize> = full["pages"].as_array().unwrap().iter().map(|p| p["html"].as_str().unwrap().len()).collect();
assert!(sizes.iter().all(|n| *n > 0), "{full}");
// Room for two pages: the third gets no HTML, and the call says so.
let capped = a.text_extract_within(&Args(&args), sizes[0] + sizes[1] + sizes[2] - 1).unwrap();
let pages = capped["pages"].as_array().unwrap();
assert_eq!(capped["html_truncated"], true);
assert_eq!(pages[1]["html"], full["pages"][1]["html"]);
assert_eq!(pages[2]["html"], "");
assert_eq!(pages[2]["styles_incomplete"], true);
assert_eq!(pages[2]["text"], "Page 3", "the plain text is not capped by the HTML budget");
// Plain text alone says nothing about HTML.
assert!(a.text_extract(&Args(&json!({ "doc": id.0 }))).unwrap().get("html_truncated").is_none());
// A smaller page after the one that overflowed still gets none: "Page 1" fits what "Page 10"
// did not.
let id = search_document(&mut a, 10);
let args = json!({ "doc": id.0, "pages": [10, 1], "format": "html" });
let full = a.text_extract(&Args(&args)).unwrap();
let (ten, one) = (full["pages"][0]["html"].as_str().unwrap().len(), full["pages"][1]["html"].as_str().unwrap().len());
assert!(one < ten, "{full}");
let capped = a.text_extract_within(&Args(&args), ten - 1).unwrap();
assert_eq!(capped["pages"][0]["html"], "");
assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}");
assert_eq!(capped["pages"][1]["styles_incomplete"], true);
}
+11 -6
View File
@@ -164,7 +164,7 @@ pub fn tools() -> Vec<ToolDef> {
.cmd("file.open")
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.")
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.")
.ro()
.cmd("file.properties")
.with(schema(json!({ "doc": doc() }), &["doc"])),
@@ -216,13 +216,14 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"),
"exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" }
}), &["doc"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.")
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.")
.ro()
.cmd("edit.column_select")
.with(schema(
json!({
"doc": doc(), "pages": pages("to extract (default: all)"),
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." }
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." },
"format": { "type": "string", "enum": ["text", "html"], "description": "text (default) or html: also return each page's text as formatted HTML." }
}),
&["doc"],
)),
@@ -718,13 +719,13 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style.")
t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style; text painted in gray or CMYK also has gray (its level) or cmyk ([c, m, y, k]).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"page_add_text",
"Add text to a page",
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name, painted in RGB) or, for print, gray (0 black to 1 white, painted in DeviceGray) or cmyk ([c, m, y, k], each 0 to 1, painted in DeviceCMYK; [0, 0, 0, 1] is 100 K black), one of the three, align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
)
.cmd("edit.text")
.with(schema(
@@ -735,6 +736,8 @@ pub fn tools() -> Vec<ToolDef> {
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"gray": { "type": "number", "minimum": 0, "maximum": 1, "description": "DeviceGray level, 0 black to 1 white (instead of color)." },
"cmyk": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4, "description": "DeviceCMYK [c, m, y, k], each 0 to 1 (instead of color); [0, 0, 0, 1] is 100 K black." },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
@@ -751,13 +754,15 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }),
&["doc", "page", "path"],
)),
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color or gray or cmyk as page_add_text takes them, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"gray": { "type": "number", "minimum": 0, "maximum": 1, "description": "DeviceGray level, 0 black to 1 white (instead of color)." },
"cmyk": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4, "description": "DeviceCMYK [c, m, y, k], each 0 to 1 (instead of color); [0, 0, 0, 1] is 100 K black." },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
+137 -21
View File
@@ -2538,6 +2538,7 @@ fn protecting_through_tools() {
// Copying wasn't allowed either (#568): no text or images come out, though search works.
for (tool, args) in [
("text_extract", json!({ "doc": re })),
("text_extract", json!({ "doc": re, "format": "html" })),
("text_word_count", json!({ "doc": re })),
("text_paragraphs", json!({ "doc": re, "page": 1 })),
("doc_export_text", json!({ "doc": re, "path": "out.txt" })),
@@ -5214,6 +5215,83 @@ fn adding_content_through_tools() {
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
}
/// #6440: `page_add_text` paints text in CMYK or gray for a print file, so its black is 100 K
/// (`0 0 0 1 k`) rather than DeviceRGB; `content_update` restyles it either way.
#[test]
fn added_text_takes_a_cmyk_or_gray_ink_through_tools() {
let dir = workdir("content-ink");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "PROOF", "at": [20, 20], "cmyk": [0, 0, 0, 1] }));
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Note", "at": [20, 80], "gray": 0.5 }));
let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
assert_eq!((&items[0]["cmyk"], &items[1]["gray"]), (&json!([0.0, 0.0, 0.0, 1.0]), &json!(0.5)));
// The item streams of the saved file.
let saved_items = |a: &mut Automation, name: &str| -> Vec<String> {
ok(a, "doc_save", json!({ "doc": doc, "path": name }));
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap();
let page = &pdfcraft_model::pages(&saved)[0];
let list = saved.resolve(page.dict.get(b"Contents").unwrap()).as_array().cloned().unwrap();
list.iter()
.filter_map(|c| match &*saved.resolve(c) {
pdfcraft_cos::Object::Stream(s) if s.dict.name(b"PCMark") == Some(&b"Added"[..]) => {
Some(String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
}
_ => None,
})
.collect()
};
let streams = saved_items(&mut a, "ink.pdf");
assert!(streams[0].contains("0 0 0 1 k") && !streams[0].contains("rg"), "{}", streams[0]);
assert!(streams[1].contains("0.5 g") && !streams[1].contains("rg"), "{}", streams[1]);
// Restyled: to RGB with color, and back to CMYK.
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "color": "#000000" }));
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "cmyk": [1, 0, 0, 0] }));
let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
assert_eq!((&items[0]["cmyk"], items[1].get("gray")), (&json!([1.0, 0.0, 0.0, 0.0]), None));
let streams = saved_items(&mut a, "restyled.pdf");
assert!(streams[0].contains("1 0 0 0 k") && streams[1].contains("0 0 0 rg"), "{streams:?}");
// One colour at a time, four numbers from 0 to 1.
for bad in [
json!({ "color": "red", "cmyk": [0, 0, 0, 1] }),
json!({ "gray": 0.5, "cmyk": [0, 0, 0, 1] }),
json!({ "gray": 1.5 }),
json!({ "cmyk": [0, 0, 1] }),
json!({ "cmyk": [0, 0, 0, 1, 0] }),
json!({ "cmyk": [0, "x", 0, 1] }),
json!({ "cmyk": [0, 0, 0, -0.1] }),
] {
let mut args = json!({ "doc": doc, "page": 1, "text": "x", "at": [20, 200] });
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
assert!(matches!(a.call("page_add_text", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
}
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 2, "nothing refused was added");
// content_update refuses them too, an image takes no ink, and a refused update changes nothing.
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 40, 20);
enc.set_color(png::ColorType::Rgb);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[200u8; 2400]).unwrap();
}
std::fs::write(dir.join("logo.png"), png).unwrap();
ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] }));
let before = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
for (index, bad) in [
(1, json!({ "gray": 0.5, "cmyk": [0, 0, 0, 1] })),
(2, json!({ "color": "red", "gray": 0.5 })),
(1, json!({ "size": 30, "cmyk": [0, 0, 0, 2] })),
(2, json!({ "bold": true, "gray": -1 })),
(3, json!({ "gray": 0.5 })),
(3, json!({ "cmyk": [0, 0, 0, 1] })),
] {
let mut args = json!({ "doc": doc, "page": 1, "index": index });
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
}
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"], before, "a refused update changed nothing");
}
/// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own
/// page beside it, onto a point, and into another document.
#[test]
@@ -9311,27 +9389,15 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
/// page is turned with /Rotate 90.
fn table_pdf() -> Vec<u8> {
let body = "BT /F1 12 Tf 20 250 Td (Name) Tj 130 0 Td (Qty) Tj -130 -20 Td (Apple) Tj 130 0 Td (12) Tj -130 -20 Td (Pear) Tj 130 0 Td (7) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
let stream = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len());
pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
&stream,
])
}
/// Issue #740: `text_extract` with `rect` takes what Column select takes, in the displayed-page
@@ -9372,6 +9438,56 @@ fn text_extract_with_a_rect_takes_one_column() {
}
}
/// Copy with formatting, headless: `format: "html"` adds each page's text as the HTML fragment
/// Copy puts on the clipboard, for the whole page or only what `rect` takes.
#[test]
fn text_extract_as_html_keeps_the_formatting() {
let dir = workdir("rich-copy");
std::fs::write(dir.join("table.pdf"), table_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let page = &ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "format": "html" }))["pages"][0];
let html = page["html"].as_str().unwrap();
assert!(html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:12pt\">Name"), "{html}");
assert!(html.contains("Pear") && html.contains("Qty"), "{html}");
assert_eq!(page["text"], "Name\nApple\nPear\nQty\n12\n7", "the plain text is unchanged");
assert_eq!(page["styles_incomplete"], false);
// Plain text by default: no html key.
assert!(ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }))["pages"][0].get("html").is_none());
// Only what the rect takes; nothing inside gives empty HTML.
let column = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [120, 0, 300, 300], "format": "html" }));
let html = column["pages"][0]["html"].as_str().unwrap();
assert!(html.contains("Qty") && !html.contains("Name"), "{html}");
let none = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 5, 5], "format": "html" }));
assert_eq!(none["pages"][0]["html"], "");
// An unknown format is refused.
for bad in [json!("rtf"), json!(1)] {
let e = a.call("text_extract", &json!({ "doc": doc, "format": bad })).unwrap_err();
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
}
// Bold, italic, colour and size come through end to end.
let body = "BT /F1 18 Tf 20 250 Td (Big) Tj ET BT /F2 12 Tf 20 200 Td (Strong) Tj ET \
BT /F3 12 Tf 20 150 Td (Slanted) Tj ET BT 0 0 1 rg /F1 12 Tf 20 100 Td (Blue) Tj ET";
let stream = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len());
let styled = pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>",
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>",
&stream,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Oblique >>",
]);
std::fs::write(dir.join("styled.pdf"), styled).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
let page = &ok(&mut a, "text_extract", json!({ "doc": doc, "format": "html" }))["pages"][0];
let html = page["html"].as_str().unwrap();
assert!(html.contains("font-size:18pt\">Big"), "{html}");
assert!(html.contains("<strong>Strong</strong>"), "{html}");
assert!(html.contains("<em>Slanted</em>"), "{html}");
assert!(html.contains("color:#0000FF\">Blue"), "{html}");
}
#[test]
fn mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools() {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
+1
View File
@@ -17,3 +17,4 @@ mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
mod unreadable_objects;
@@ -0,0 +1,64 @@
//! `doc_info` lists the objects of a file that could not be read: each reads as null, so the file
//! opens, but what it held is lost and nothing was repaired.
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// A one-page PDF whose document information dictionary (object 4) is `info`.
fn with_info(info: &str) -> Vec<u8> {
let bodies = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
info,
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
out
}
fn info_of(a: &mut Automation, path: &str) -> Value {
let doc = match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) {
Content::Json(v) => v["doc"].as_u64().unwrap(),
other => panic!("expected JSON: {other:?}"),
};
match a.call("doc_info", &json!({ "doc": doc })).unwrap().remove(0) {
Content::Json(v) => v,
other => panic!("expected JSON: {other:?}"),
}
}
/// An unparseable `/Info` is listed with its number and why, the rest of the file reads, and
/// nothing is reported as repaired.
#[test]
fn doc_info_lists_an_object_that_could_not_be_read() {
let dir = std::env::temp_dir().join(format!("pdfcraft-unreadable-objects-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("broken.pdf"), with_info("<< /Title (Report) /Author ) >>")).unwrap();
std::fs::write(dir.join("sound.pdf"), with_info("<< /Title (Report) >>")).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let info = info_of(&mut a, "broken.pdf");
let lines = info["unreadable_objects"].as_array().unwrap();
assert_eq!(lines.len(), 1, "{info}");
let line = lines[0].as_str().unwrap();
assert!(line.starts_with("object 4 0 could not be read: syntax error"), "{line}");
assert_eq!(info["repairs"], json!([]), "nothing was repaired");
assert_eq!(info["pages"].as_array().unwrap().len(), 1, "the rest of the file reads");
let info = info_of(&mut a, "sound.pdf");
assert_eq!(info["unreadable_objects"], json!([]), "{info}");
assert_eq!(info["title"], "Report");
let _ = std::fs::remove_dir_all(dir);
}
+7 -1
View File
@@ -25,7 +25,7 @@ pub(crate) use reader::find;
#[cfg(test)]
use reader::rfind;
pub(crate) use state::ObjectReader;
use state::{ObjStm, Slot};
use state::{ObjStm, Slot, Unreadable};
pub use state::{ReadState, SourceIdentity};
#[cfg(test)]
use state::{SECTION_READS, held_bytes, objstm_member};
@@ -50,8 +50,14 @@ pub struct Document {
repair_log: Arc<Vec<String>>,
/// See [`Self::rebuilt_unlinked_update`].
unlinked_update: bool,
/// What loading each file object gave, a failure as well: a damaged object is otherwise
/// loaded again on every read, and a member of an object stream that can't be decoded
/// decodes the stream again (`Document::load`).
cache: Arc<Mutex<state::ObjectCache>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
/// See [`Self::unreadable_objects`]. Shared by clones, like the caches: it describes the
/// source, and is reset wherever they are.
unreadable: Arc<Mutex<Unreadable>>,
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
jbig2_globals: Arc<Mutex<HashMap<u32, Arc<Vec<u8>>>>>,
+98 -19
View File
@@ -1,7 +1,9 @@
use std::collections::HashSet;
use super::reader::find;
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member};
use super::state::{
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads,
};
use super::xref::{Revision, XrefEntry, XrefIndex};
use super::{
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
@@ -95,6 +97,39 @@ impl Document {
self.unlinked_update
}
/// Objects of the file that could not be read, lowest number first, as lines such as
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
/// unlike [`Self::repair_log`], nothing was repaired. Lists the objects read so far, by this
/// document, its clones or a scan of it, that have not since been edited; past 100, a last
/// line says there were more. Opening again, authenticating or rebuilding the index starts
/// the list afresh, as it does the object cache.
pub fn unreadable_objects(&self) -> Vec<String> {
let Ok(unreadable) = self.unreadable.lock() else { return Vec::new() };
unreadable.lines(|num| self.overlay.contains_key(&num))
}
/// Record `num` as unreadable. `reason` is built only for an object not yet listed, so a file
/// whose broken object is asked for again and again pays for it once.
fn note_unreadable(&self, num: u32, reason: impl FnOnce() -> String) {
// A poisoned lock only means the object goes unlisted; the read itself still fails.
let Ok(mut unreadable) = self.unreadable.lock() else { return };
let hidden = |n: u32| self.overlay.contains_key(&n);
if unreadable.wants(num, hidden) {
unreadable.note(num, self.generation(num), &reason(), hidden);
}
}
/// Why an object listed at `offset` could not be read: what parsing it there, without resolving
/// an indirect `/Length`, found. A best account, not the only one: the reader also tried
/// elsewhere.
fn why_unreadable(&self, offset: usize) -> String {
match parse_indirect_shared(&self.data, offset, &|_| None, &self.endstreams) {
Err(e) => e.to_string(),
Ok(_) => "no readable copy was found at its cross-reference offset or elsewhere in the file".into(),
}
}
/// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should
/// know about (fidelity: never silent).
pub fn note_repair(&mut self, line: String) {
@@ -504,27 +539,38 @@ impl Document {
}
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
match self.overlay.get(&num) {
Some(Slot::Set(_, o) | Slot::RepairedSet(_, _, o)) => return Ok(o.clone()),
Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)),
None => {}
if let Some(slot) = self.overlay.get(&num) {
note_overlay_read();
return Ok(match slot {
Slot::Set(_, o) | Slot::RepairedSet(_, _, o) => o.clone(),
Slot::Freed(_) => Arc::new(Object::Null),
});
}
if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(o.clone());
if let Some(loaded) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return loaded.clone();
}
// Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
let _guard =
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
let _scope = crate::security::FollowScope::enter();
let obj = self.load(num, 0)?;
let obj = Arc::new(obj);
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
// is cheap, and returned objects outlive eviction.
let owned = held_bytes(&obj, &self.data, 0);
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone(), owned);
self.evict_editor_cache_if_over_budget(num)?;
Ok(obj)
let edits_read = overlay_reads();
let loaded = self.load(num, 0).map(Arc::new);
if let Err(e) = &loaded
&& !matches!(e, CosError::Poisoned)
{
self.note_unreadable(num, || e.to_string());
}
// A failure is kept like an object, or every read of a damaged object loads it again. A
// poisoned lock says nothing about the object. Clones share the cache, so a load that read
// this document's edits (an edited object stream or `/Length`) is not kept.
if overlay_reads() == edits_read && !matches!(loaded, Err(CosError::Poisoned)) {
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
// is cheap, and returned objects outlive eviction.
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data));
self.evict_editor_cache_if_over_budget(num)?;
}
loaded
}
/// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB)
@@ -636,11 +682,20 @@ impl Document {
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
Some(XrefEntry::InFile { offset, .. }) => {
let resolve = |r: ObjRef| {
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if self.overlay.contains_key(&r.num) {
note_overlay_read();
}
// A length resolved through this document's edits counts as an edit read
// on every reuse.
if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if edits {
note_overlay_read();
}
return known;
}
let edits_read = overlay_reads();
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r, length));
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
length
};
let off = offset as usize;
@@ -653,7 +708,10 @@ impl Document {
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
.or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at))
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
.ok_or(CosError::MissingObject(num))
.ok_or_else(|| {
self.note_unreadable(num, || self.why_unreadable(off));
CosError::MissingObject(num)
})
}
Some(XrefEntry::InStream { stream, index }) => {
let stm = self.objstm(stream)?;
@@ -666,7 +724,9 @@ impl Document {
}
pub(super) fn objstm(&self, num: u32) -> Result<Arc<ObjStm>, CosError> {
if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
// An edited or freed stream is this document's alone: clones share the parsed streams.
let edited = self.overlay.contains_key(&num);
if !edited && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(s.clone());
}
// A file entry that cannot place the stream (one that says it is itself compressed
@@ -674,6 +734,7 @@ impl Document {
// header, as a plain object with a wrong offset is. An edited or freed stream, in the
// overlay or in a saved revision's table, is what that says it is: the file's copy
// must not come back.
let edits_read = overlay_reads();
let loaded = match self.try_get(num) {
Ok(o) if matches!(*o, Object::Stream(_)) => o,
_ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => {
@@ -681,6 +742,8 @@ impl Document {
}
_ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
};
// A stream whose load read this document's edits (its `/Length`) is as much its own as an edited one.
let own = edited || overlay_reads() != edits_read;
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok();
@@ -688,6 +751,8 @@ impl Document {
// the file's object streams comes here without a read around it.
let _scope = crate::security::FollowScope::enter();
let readable = crate::security::following(Cow::Borrowed(s), || self.with_direct_parms(s));
#[cfg(test)]
super::tests::OBJSTM_DECODES.with(|decodes| decodes.set(decodes.get() + 1));
let data = readable.decoded_within(self.stream_limit)?;
let mut lx = Lexer::new(&data, 0);
let mut index = Vec::with_capacity(n);
@@ -696,6 +761,9 @@ impl Document {
index.push(objstm_member(onum, off, first, data.len()));
}
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
if own {
return Ok(stm);
}
// Same bound as the object cache: decoded object streams can be megabytes each.
// Insert first so the retained `num` is actually in the map.
self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone());
@@ -752,6 +820,7 @@ impl Document {
pub fn set(&mut self, r: ObjRef, obj: impl Into<Object>) {
let obj = self.adopt(obj.into());
self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj)));
self.forget_member_failures(r.num);
if r.num >= self.next_num {
self.next_num = r.num.saturating_add(1);
}
@@ -811,6 +880,16 @@ impl Document {
/// Delete an object (its number becomes free with the next generation).
pub fn free(&mut self, r: ObjRef) {
self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
self.forget_member_failures(r.num);
}
/// Forget cached failures of the members `stream` held in the file: they were failures of
/// the file's copy, and an edit of `stream` may read. Clones share the cache, so theirs are
/// searched again too, which costs time only. A poisoned lock leaves the cache as it is.
fn forget_member_failures(&self, stream: u32) {
if let Ok(mut cache) = self.cache.lock() {
cache.forget_failures(|n| matches!(self.entries.lookup(*n, &self.data), Some(XrefEntry::InStream { stream: s, .. }) if s == stream));
}
}
/// Free what was added since the file was read and nothing refers to any more: the objects
+3
View File
@@ -24,6 +24,7 @@ impl Document {
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
jbig2_globals: Arc::default(),
stream_limit: crate::object::MAX_DECODED,
overlay: BTreeMap::new(),
@@ -112,6 +113,7 @@ impl Document {
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
jbig2_globals: Arc::default(),
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
overlay: BTreeMap::new(),
@@ -243,6 +245,7 @@ impl Document {
// Objects read while locating the catalog were read before decryption was possible.
self.cache = Arc::default();
self.objstms = Arc::default();
self.unreadable = Arc::default();
Ok(())
}
+4
View File
@@ -1,5 +1,6 @@
#[cfg(test)]
use super::state::SECTION_READS;
use super::state::Unreadable;
use super::xref::{
MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData,
};
@@ -648,6 +649,7 @@ impl Document {
}
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()), Arc::default()));
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
*self.unreadable.lock().map_err(|_| CosError::Poisoned)? = Unreadable::default();
// Objects inside object streams.
for num in objstms {
if let Ok(o) = self.try_get(num)
@@ -664,6 +666,8 @@ impl Document {
}
let object_count = entries.len();
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries), Arc::default()));
// Failures from the scan stand: the members added change no scanned entry, and a
// `/Length` found only now cannot make a stream unreadable.
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
+87 -8
View File
@@ -6,8 +6,20 @@ thread_local! {
static LOADING: std::cell::RefCell<Vec<u32>> = const { std::cell::RefCell::new(Vec::new()) };
/// Indirect `/Length` values resolved during the current outermost load: a damaged file
/// retries a parse several ways, and each retry would resolve the same lengths again
/// (exponential in the chain of streams whose lengths point at further streams).
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
/// (exponential in the chain of streams whose lengths point at further streams). Each is
/// kept with whether resolving it read unsaved edits.
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, (Option<i64>, bool)>> = std::cell::RefCell::new(HashMap::new());
/// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`).
static OVERLAY_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Unsaved edits read so far on this thread: a load during which this changes depended on one.
pub(super) fn overlay_reads() -> u64 {
OVERLAY_READS.with(std::cell::Cell::get)
}
pub(super) fn note_overlay_read() {
OVERLAY_READS.with(|c| c.set(c.get().wrapping_add(1)));
}
/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream
@@ -30,6 +42,14 @@ impl LoadGuard {
}
}
/// What loading a file object gave, kept in the document's cache either way.
pub(super) type Loaded = Result<Arc<Object>, CosError>;
/// The bytes a load result holds of its own ([`held_bytes`]); a failure holds none.
pub(super) fn loaded_bytes(loaded: &Loaded, data: &Arc<Vec<u8>>) -> usize {
loaded.as_ref().map_or(0, |o| held_bytes(o, data, 0))
}
impl Drop for LoadGuard {
fn drop(&mut self) {
let outermost = LOADING.with(|l| {
@@ -61,6 +81,60 @@ pub(super) fn objstm_member(num: i64, offset: i64, first: Option<usize>, len: us
(start < len).then_some((num, start))
}
/// Objects the reader could not read, each with the reason (see `Document::unreadable_objects`).
/// Bounded: a document records past [`Unreadable::MAX`] of the objects it shows only the fact that
/// there were more. Entries a clone has edited are hidden from it and do not count, so the whole
/// list holds at most `MAX` plus the objects edited in clones.
#[derive(Debug, Default)]
pub(super) struct Unreadable {
pub(super) objects: BTreeMap<u32, (u16, String)>,
pub(super) more: bool,
}
impl Unreadable {
pub(super) const MAX: usize = 100;
/// Characters of a reason kept: a parser's message can quote the bytes it stopped at.
const REASON: usize = 160;
/// Whether `num` is to be recorded: not listed yet and there is room among the entries the
/// asking document still shows (`hidden` are the ones it has edited, as in [`Self::lines`]),
/// so a clone's edits never use up its own allowance. A full list notes that there were more,
/// so a caller asks this before building the reason.
pub(super) fn wants(&mut self, num: u32, hidden: impl Fn(u32) -> bool) -> bool {
if self.objects.contains_key(&num) {
return false;
}
if self.objects.keys().filter(|n| !hidden(**n)).count() >= Self::MAX {
self.more = true;
return false;
}
true
}
/// Record `num` once; the first reason given is kept.
pub(super) fn note(&mut self, num: u32, generation: u16, reason: &str, hidden: impl Fn(u32) -> bool) {
if self.wants(num, hidden) {
self.objects.insert(num, (generation, reason.chars().take(Self::REASON).collect()));
}
}
/// One line per recorded object not `hidden`, then one saying there were more, if there were.
pub(super) fn lines(&self, hidden: impl Fn(u32) -> bool) -> Vec<String> {
// Clones share the list and each hides its own edits, so the list may hold more than
// `MAX` entries in all; each document still shows at most `MAX`.
let mut shown = self.objects.iter().filter(|(num, _)| !hidden(**num));
let mut lines: Vec<String> = shown
.by_ref()
.take(Self::MAX)
.map(|(num, (generation, reason))| format!("object {num} {generation} could not be read: {reason}"))
.collect();
if self.more || shown.next().is_some() {
lines.push(format!("more objects could not be read; only the first {} are listed", Self::MAX));
}
lines
}
}
/// Where an overlay slot came from.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Slot {
@@ -182,7 +256,7 @@ thread_local! {
pub(super) static SECTION_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Parsed objects by number, each with what it holds ([`held_bytes`]) measured once, when it goes
/// What loading each object gave (a failure as well), by number, each with what it holds ([`held_bytes`]) measured once, when it goes
/// in, and their sum kept beside them.
///
/// Both caches are bounded by those bytes. Summing them on every read walked every cached object
@@ -191,12 +265,12 @@ thread_local! {
/// (#5873).
#[derive(Default)]
pub(crate) struct ObjectCache {
entries: HashMap<u32, (Arc<Object>, usize)>,
entries: HashMap<u32, (Loaded, usize)>,
owned: usize,
}
impl ObjectCache {
pub(crate) fn get(&self, num: &u32) -> Option<&Arc<Object>> {
pub(crate) fn get(&self, num: &u32) -> Option<&Loaded> {
self.entries.get(num).map(|(object, _)| object)
}
@@ -205,8 +279,8 @@ impl ObjectCache {
self.entries.contains_key(num)
}
/// Keep `object` as `num`, which holds `owned` bytes (what [`held_bytes`] says of it).
pub(crate) fn insert(&mut self, num: u32, object: Arc<Object>, owned: usize) {
/// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]).
pub(crate) fn insert(&mut self, num: u32, object: Loaded, owned: usize) {
if let Some((_, before)) = self.entries.insert(num, (object, owned)) {
self.owned = self.owned.saturating_sub(before);
}
@@ -232,6 +306,11 @@ impl ObjectCache {
self.owned = 0;
}
/// Drop the cached failures `stale` picks. A failure holds no bytes, so the total stands.
pub(crate) fn forget_failures(&mut self, mut stale: impl FnMut(&u32) -> bool) {
self.entries.retain(|num, (loaded, _)| loaded.is_ok() || !stale(num));
}
pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) {
self.entries.retain(|num, _| keep(num));
self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned));
@@ -239,7 +318,7 @@ impl ObjectCache {
#[cfg(test)]
pub(crate) fn values(&self) -> impl Iterator<Item = &Arc<Object>> {
self.entries.values().map(|(object, _)| object)
self.entries.values().filter_map(|(object, _)| object.as_ref().ok())
}
}
+276 -8
View File
@@ -3,6 +3,8 @@ use super::*;
std::thread_local! {
/// How many times `Document::scan_for` read the file on this thread.
pub(super) static FILE_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// How many times `Document::objstm` decoded an object stream on this thread.
pub(super) static OBJSTM_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A scan that doesn't find an object isn't made again: each reads the whole file, and a file can
@@ -730,6 +732,163 @@ fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() {
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
}
/// An object that can't be read at its entry or anywhere else is searched for in the whole file
/// once: every later read, in a clone too, gives the same error without searching again. An
/// edit of that number still answers for it.
#[test]
fn an_unreadable_object_is_searched_for_once() {
let bodies = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Odd Key(x) >>"];
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R /Info 3 0 R"))).unwrap();
FILE_SCANS.with(|scans| scans.set(0));
let first = doc.try_get(3);
assert!(first.is_err(), "{first:?}");
for _ in 0..10 {
assert_eq!(doc.try_get(3), first);
assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
}
let mut edited = doc.clone();
assert_eq!(edited.try_get(3), first);
assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "searches for an object that isn't there");
let report = doc.unreadable_objects();
assert_eq!(report.len(), 1, "cached failures must stay listed exactly once: {report:?}");
assert_eq!(edited.unreadable_objects(), report, "clones retain the cached failure diagnostic");
edited.set(ObjRef::new(3, 0), Object::Int(7));
assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Int(7)));
assert_eq!(doc.try_get(3), first, "the edit is the clone's alone");
edited.free(ObjRef::new(3, 0));
assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Null));
}
/// A member of an object stream that can't be decoded (here, one that decodes past the document's
/// stream limit) is decoded once: every later read, in a clone too, gives the same error without
/// decoding the stream again. The stream itself loads, so no search of the file is involved.
#[test]
fn a_member_of_an_undecodable_object_stream_is_decoded_once() {
let file = object_stream_file("3 0 ", &format!("<< /Hidden ({}) >>", "x".repeat(4096)), 0);
let doc = Document::open_with_stream_limit(Arc::new(file), None, 1024).unwrap();
OBJSTM_DECODES.with(|decodes| decodes.set(0));
let first = doc.try_get(3);
assert!(matches!(first, Err(CosError::Filter(_))), "{first:?}");
for _ in 0..10 {
assert_eq!(doc.try_get(3), first);
}
assert_eq!(doc.clone().try_get(3), first);
assert_eq!(OBJSTM_DECODES.with(std::cell::Cell::get), 1, "decodes an object stream that can't be decoded");
}
/// Clones share the cache, and an edit is one clone's alone: a member of an object stream that
/// one clone freed or replaced still reads, in the others, from the file's stream.
#[test]
fn an_edited_object_stream_leaves_other_clones_members_alone() {
let file = Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0));
let doc = Document::open(file.clone()).unwrap();
let mut freed = doc.clone();
freed.free(ObjRef::new(4, 0));
assert_eq!(freed.try_get(3), Err(CosError::MissingObject(4)));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the free reached another clone");
let doc = Document::open(file).unwrap();
let mut replaced = doc.clone();
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
dict.set(b"N".to_vec(), Object::Int(1));
dict.set(b"First".to_vec(), Object::Int(4));
replaced.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (no) >>".to_vec()));
assert_eq!(text_of(&replaced, 3, b"Hidden").as_deref(), Some("no"));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone");
}
/// A member that failed to load from the file's object stream is kept as a failure, but replacing
/// or freeing that stream forgets it: the member then reads from the edit.
#[test]
fn an_edited_object_stream_forgets_its_members_failures() {
let file = Arc::new(object_stream_file("3 0 ", ") broken", 0));
let mut doc = Document::open(file.clone()).unwrap();
assert!(doc.try_get(3).is_err(), "the file's member does not parse");
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
dict.set(b"N".to_vec(), Object::Int(1));
dict.set(b"First".to_vec(), Object::Int(4));
doc.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (fixed) >>".to_vec()));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("fixed"), "the cached failure outlived the edit");
assert!(doc.try_get(3).is_ok());
let mut doc = Document::open(file).unwrap();
assert!(matches!(doc.try_get(3), Err(CosError::Syntax { .. })), "the file's member does not parse");
doc.free(ObjRef::new(4, 0));
assert_eq!(doc.try_get(3), Err(CosError::MissingObject(4)), "the freed stream's member");
}
/// An object stream framed by one clone's edit of its indirect `/Length` is that clone's alone:
/// the others still read its members within the file's length.
#[test]
fn an_edited_object_stream_length_leaves_other_clones_members_alone() {
let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true));
let doc = Document::open(file).unwrap();
let mut edited = doc.clone();
// Ends the stream at the `endstream` inside the member's string.
let cut = "3 0 << /Hidden (cut".len();
edited.set(ObjRef::new(6, 0), Object::Int(cut as i64));
assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used");
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone");
}
/// A `/Length` read through one clone's edits and reused within the same load still keeps that
/// load to the clone. Here the file's offsets are header-relative behind junk, so object 10's
/// offset first lands on object 12, whose length (object 8, in the edited object stream 9) is
/// read; object 10's own length then loads object stream 4, framed by object 8 too.
#[test]
fn a_reused_edited_length_leaves_other_clones_members_alone() {
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 13];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let stream = |dict: &str, data: &str| format!("<< {dict} >>\nstream\n{data}\nendstream").into_bytes();
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let inner = "11 0 3 2 5 << /Hidden (cut endstream here) >>";
obj(&mut out, 4, &stream("/Type /ObjStm /N 2 /First 9 /Length 8 0 R", inner));
let lengths = format!("8 0 {}", inner.len());
obj(&mut out, 9, &stream(&format!("/Type /ObjStm /N 1 /First 4 /Length {}", lengths.len()), &lengths));
obj(&mut out, 12, &stream("/Length 8 0 R", &"x".repeat(40)));
obj(&mut out, 10, &stream("/Length 11 0 R", "PDATA"));
// A cross-reference stream: a hybrid file's /XRefStm is not looked for behind junk.
let compressed = [(3, 4, 1), (8, 9, 0), (11, 4, 0)];
let mut data = Vec::new();
for (n, off) in offs.iter().enumerate() {
let row = match compressed.iter().find(|c| c.0 == n) {
Some(&(_, stream, index)) => [2, 0, stream, index],
None if *off == 0 => [0, 0, 0, 0],
None => {
let [.., hi, lo] = u32::try_from(*off).unwrap().to_be_bytes();
[1, hi, lo, 0]
}
};
data.extend_from_slice(&row);
}
let table = out.len();
let dict = format!("<< /Type /XRef /Size 14 /W [1 2 1] /Index [0 13] /Root 1 0 R /Length {} >>", data.len());
out.extend_from_slice(format!("13 0 obj\n{dict}\nstream\n").as_bytes());
out.extend_from_slice(&data);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{table}\n%%EOF\n").as_bytes());
let mut file = vec![b'J'; offs[10] - offs[12] - 1];
file.push(b'\n');
file.extend_from_slice(&out);
let doc = Document::open(Arc::new(file)).unwrap();
let mut edited = doc.clone();
let cut = "11 0 3 2 5 << /Hidden (cut".len();
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
dict.set(b"N".to_vec(), Object::Int(1));
dict.set(b"First".to_vec(), Object::Int(4));
edited.set(ObjRef::new(9, 0), crate::Stream::from_raw(dict, format!("8 0 {cut}").into_bytes()));
assert!(matches!(&*edited.get(ObjRef::new(10, 0)), Object::Stream(s) if &s.raw[..] == b"PDATA"));
assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used");
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone");
}
/// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered
/// `1 n` instead of `0 n` (#2676): every row is one number off.
fn misnumbered_table(start: u32) -> Vec<u8> {
@@ -1255,7 +1414,7 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() {
// cleared after the next read, including a failed one.
let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1]));
let size = held_bytes(&owned, &reader.document.data, 0);
reader.document.cache.lock().unwrap().insert(3, Arc::new(owned), size);
reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size);
assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null);
assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted");
}
@@ -1468,8 +1627,14 @@ fn hybrid_reference_files_read_the_xref_stream_too() {
/// stream puts it at position `member` of object stream 4, which holds `header` (the number
/// and offset pairs) and then `members`.
fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
object_stream_file_with(header, members, member, false)
}
/// [`object_stream_file`], with the stream's `/Length` in object 6 when `indirect_length`.
fn object_stream_file_with(header: &str, members: &str, member: u8, indirect_length: bool) -> Vec<u8> {
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 6];
let size = if indirect_length { 7 } else { 6 };
let mut offs = vec![0usize; size];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
@@ -1480,7 +1645,8 @@ fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let count = header.split_whitespace().count() / 2;
let inner = format!("{header}{members}");
let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {} >>\nstream\n", header.len(), inner.len()).into_bytes();
let length = if indirect_length { "6 0 R".to_string() } else { inner.len().to_string() };
let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {length} >>\nstream\n", header.len()).into_bytes();
stm.extend_from_slice(inner.as_bytes());
stm.extend_from_slice(b"\nendstream");
obj(&mut out, 4, &stm);
@@ -1489,13 +1655,16 @@ fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
xs.extend_from_slice(&data);
xs.extend_from_slice(b"\nendstream");
obj(&mut out, 5, &xs);
if indirect_length {
obj(&mut out, 6, inner.len().to_string().as_bytes());
}
let table = out.len();
out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for (n, off) in offs.iter().enumerate().skip(1) {
let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") };
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
out
}
@@ -1849,6 +2018,105 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
/// A file whose object 3 (its `/Info`) can't be parsed: the rest reads.
fn with_unreadable_info() -> Vec<u8> {
build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) /Author ) >>"],
"/Root 1 0 R /Info 3 0 R",
)
}
/// An object that can't be parsed reads as null and is listed once, by number and generation,
/// with why. Clones and scans share the list, an edit takes the object off it (in that document
/// only), and a sound file lists nothing.
#[test]
fn unreadable_objects_are_listed_with_the_reason() {
let doc = Document::open(Arc::new(with_unreadable_info())).unwrap();
assert!(doc.unreadable_objects().is_empty(), "nothing has read object 3 yet");
assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
assert!(doc.try_get(3).is_err());
let lines = doc.unreadable_objects();
assert_eq!(lines.len(), 1, "read twice, listed once: {lines:?}");
assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}");
let scanned = doc.clone();
assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1);
assert_eq!(doc.unreadable_objects(), lines, "the scan found the same object");
let unread = Document::open(Arc::new(with_unreadable_info())).unwrap();
let scanned = unread.clone();
assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1);
assert_eq!(unread.unreadable_objects(), lines, "a clone's scan reaches the original");
let mut edited = doc.clone();
edited.set(ObjRef::new(3, 0), Object::Dict(Dict::new()));
assert!(edited.unreadable_objects().is_empty(), "the edit replaced it");
assert_eq!(doc.unreadable_objects(), lines);
let sound =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) >>"], "/Root 1 0 R /Info 3 0 R");
let sound = Document::open(Arc::new(sound)).unwrap();
assert_eq!(sound.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 0);
assert!(sound.unreadable_objects().is_empty());
}
/// A file rebuilt from its object headers still lists an object the scan could not read.
#[test]
fn a_rebuilt_file_lists_the_objects_its_scan_could_not_read() {
let text = "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n3 0 obj\n<< /A ) >>\nendobj\n%%EOF\n";
let doc = Document::open(Arc::new(text.as_bytes().to_vec())).unwrap();
assert!(doc.repair_log().iter().any(|l| l.starts_with("rebuilt cross-reference index")), "{:?}", doc.repair_log());
let lines = doc.unreadable_objects();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}");
}
/// The list keeps at most 100 objects and then says there were more; an object whose
/// cross-reference offset holds no object is listed with that as its reason.
#[test]
fn the_unreadable_object_list_is_bounded() {
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
bodies.extend(std::iter::repeat_n("<< /A ) >>", 120));
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
assert_eq!(doc.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 120);
let lines = doc.unreadable_objects();
assert_eq!(lines.len(), 101, "{:?}", lines.last());
assert!(lines[0].starts_with("object 3 0 could not be read"), "{}", lines[0]);
assert_eq!(lines[100], "more objects could not be read; only the first 100 are listed");
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let text = String::from_utf8_lossy(&base).into_owned();
let gone = text.replacen("3 0 obj\n(three)", "9 0 obj\n(three)", 1);
let doc = Document::open(Arc::new(gone.into_bytes())).unwrap();
assert!(doc.try_get(3).is_err());
assert_eq!(
doc.unreadable_objects(),
["object 3 0 could not be read: no readable copy was found at its cross-reference offset or elsewhere in the file"]
);
}
/// The unreadable list is shared by clones, and each hides what it has edited. A clone that edited
/// every listed object still lists its own failures: hidden entries don't use up its allowance.
#[test]
fn objects_a_clone_edited_do_not_use_up_its_unreadable_allowance() {
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
bodies.extend(std::iter::repeat_n("<< /A ) >>", 110));
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
for num in 3..103 {
assert!(doc.try_get(num).is_err());
}
assert_eq!(doc.unreadable_objects().len(), 100);
let mut edited = doc.clone();
for num in 3..103 {
edited.set(ObjRef::new(num, 0), Object::Null);
}
for num in 103..113 {
assert!(edited.try_get(num).is_err());
}
let lines = edited.unreadable_objects();
assert_eq!(lines.len(), 10, "{lines:?}");
assert!(lines[0].starts_with("object 103 0 could not be read"), "{}", lines[0]);
let original = doc.unreadable_objects();
assert_eq!(original.len(), 101, "the original still shows at most 100, then says there were more");
assert_eq!(original[100], "more objects could not be read; only the first 100 are listed");
}
#[test]
fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() {
// The earlier revision: an empty document read back from a file whose catalog and pages
@@ -1884,10 +2152,10 @@ fn the_object_cache_keeps_its_total_as_objects_come_and_go() {
let two = Arc::new(Object::Array(vec![Object::Int(1); 10]));
let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0));
let mut cache = super::state::ObjectCache::default();
cache.insert(1, one.clone(), a);
cache.insert(2, two, b);
cache.insert(1, Ok(one.clone()), a);
cache.insert(2, Ok(two), b);
assert_eq!(cache.owned(), a + b);
cache.insert(2, one, a);
cache.insert(2, Ok(one), a);
assert_eq!(cache.owned(), a + a, "a replaced object no longer counts");
cache.retain(|n| *n == 1);
assert_eq!((cache.len(), cache.owned()), (1, a));
+1 -1
View File
@@ -31,7 +31,7 @@ use layout::Piece;
pub(crate) use layout::WRAP_CALLS;
pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width};
pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};
pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan};
pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan};
#[derive(Clone, Debug, PartialEq)]
pub enum Content {
+9 -5
View File
@@ -1,7 +1,7 @@
use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::types::{AddedText, Align, Family, Ink};
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
@@ -105,8 +105,7 @@ pub(super) fn draw_laid_shaped(
fonts.set(name.clone().into_bytes(), Object::Ref(font));
names.push(name);
}
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
out.extend(format!("BT {}\n", t.fill_op()).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
@@ -293,8 +292,7 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
let r = text_rect(t);
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
out.extend(format!("BT /{name} {} Tf {}\n", n(t.size), t.fill_op()).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
@@ -355,6 +353,12 @@ pub(super) fn params(c: &Content) -> Dict {
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
d.set(b"Size".to_vec(), Object::Real(t.size));
d.set(b"Color".to_vec(), arr(&t.color));
// The ink beside its RGB look, which a reader that knows no ink keeps showing.
match t.painted_ink() {
Some(Ink::Gray(g)) => d.set(b"Gray".to_vec(), Object::Real(g)),
Some(Ink::Cmyk(c)) => d.set(b"CMYK".to_vec(), arr(&c)),
None => {}
}
d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
// Each paragraph's own alignment, or null where it follows /Align.
let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
+39 -3
View File
@@ -1,8 +1,33 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan, TAG};
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged};
/// A text item's gray or CMYK ink. Items written before inks, and an ink out of range or not
/// made only of numbers (a `/Gray` name, a `/CMYK` entry that isn't a number), have none: the
/// text paints in its RGB `/Color`.
fn ink(doc: &Document, d: &Dict) -> Option<Ink> {
let ink = if let Some(g) = d.get(b"Gray") {
Ink::Gray(read_value(doc, g, Object::as_f64)?)
} else {
// Four entries or none: a longer array is never copied or read through.
read_value(doc, d.get(b"CMYK")?, |cmyk| {
let [c, m, y, k] = cmyk.as_array()?.as_slice() else { return None };
Some(Ink::Cmyk([c.as_f64()?, m.as_f64()?, y.as_f64()?, k.as_f64()?]))
})?
};
Some(ink).filter(|i| i.valid())
}
/// `f` of `o`, a reference followed. A direct value is read where it is, never copied, so a
/// large one a file puts there costs nothing to look at.
fn read_value<T>(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> T) -> T {
match o {
Object::Ref(_) => f(&doc.resolve(o)),
direct => f(direct),
}
}
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
let rect: [f64; 4] = r.try_into().ok()?;
@@ -21,7 +46,7 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
.map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
})
.unwrap_or_default();
Some(Content::Text(AddedText {
let mut t = AddedText {
rect,
text,
family,
@@ -29,11 +54,19 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
italic,
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
ink: ink(doc, d),
align,
aligns: crate::paragraph_align::tidy(aligns, align),
// Items written before character spacing have none.
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
}))
};
// An ink whose look isn't the stored colour (a file edited by hand) is none.
t.drop_stale_ink();
// One that stands is the ink as painted, finer digits dropped, and its look.
if let Some(i) = t.ink {
t.set_ink(Some(i));
}
Some(Content::Text(t))
}
b"Image" => {
let crop = nums(doc, d.get(b"Crop"));
@@ -138,6 +171,9 @@ fn validate(c: &Content) -> Result<(), EditError> {
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
)));
}
if t.ink.is_some_and(|i| !i.valid()) {
return Err(EditError::Invalid("a gray level and each CMYK value must be from 0 to 1".into()));
}
if (r[2] - r[0]).abs() < 1.0 {
return Err(EditError::Invalid("the text box is too narrow".into()));
}
+101 -1
View File
@@ -1,5 +1,5 @@
use super::layout::norm;
use super::{Content, MarkKind, ObjRef, Std14};
use super::{Content, MarkKind, ObjRef, Std14, n};
pub(super) const TAG: &str = "Added";
@@ -90,6 +90,60 @@ pub enum Align {
Justify,
}
/// A device colour added text is painted in instead of RGB, for print files: a text's black in
/// CMYK stays 100 K rather than a DeviceRGB black the printer separates into rich black.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Ink {
/// A DeviceGray level, from 0 (black) to 1 (white): painted with `g`.
Gray(f64),
/// DeviceCMYK `[c, m, y, k]`, each from 0 to 1 (`[0, 0, 0, 1]` is 100 K): painted with `k`.
Cmyk([f64; 4]),
}
impl Ink {
/// Whether every component is a number from 0 to 1.
pub fn valid(self) -> bool {
self.components().iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))
}
/// The ink as its operator paints it: each component to the thousandth that `g` and `k` are
/// written with (as RGB's `rg`), so the item, what it reads back as and the page agree.
pub fn painted(self) -> Ink {
let round = |v: f64| (v * 1000.0).round() / 1000.0;
match self {
Ink::Gray(g) => Ink::Gray(round(g)),
Ink::Cmyk(c) => Ink::Cmyk(c.map(round)),
}
}
fn components(&self) -> &[f64] {
match self {
Ink::Gray(g) => std::slice::from_ref(g),
Ink::Cmyk(c) => c,
}
}
/// How the ink looks on screen: gray is the same level in each of red, green and blue
/// (ISO 32000-2 §10.4.2.2), CMYK takes each colour plus black from 1 (§10.4.2.5).
pub fn rgb(self) -> [f64; 3] {
match self {
Ink::Gray(g) => [g.clamp(0.0, 1.0); 3],
Ink::Cmyk([c, m, y, k]) => [c, m, y].map(|v| 1.0 - (v + k).clamp(0.0, 1.0)),
}
}
/// The operator that makes this ink the fill colour, without a line end.
pub(super) fn fill_op(self) -> String {
match self {
Ink::Gray(g) => format!("{} g", n(g.clamp(0.0, 1.0))),
Ink::Cmyk(c) => {
let [c, m, y, k] = c.map(|v| v.clamp(0.0, 1.0));
format!("{} {} {} {} k", n(c), n(m), n(y), n(k))
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AddedText {
/// The text box in display space; its width wraps the text and its top is the first line's
@@ -100,7 +154,14 @@ pub struct AddedText {
pub bold: bool,
pub italic: bool,
pub size: f64,
/// The RGB colour: what the text is painted in, unless it has an [`AddedText::ink`] this is
/// the look of.
pub color: [f64; 3],
/// A gray or CMYK ink the text is painted in, with `color` its look ([`Ink::rgb`]). Set it
/// with [`AddedText::set_ink`] and the colour with [`AddedText::set_color`], which drops an
/// ink the colour no longer shows; one left behind by setting `color` directly paints nothing
/// ([`AddedText::painted_ink`]) until [`AddedText::drop_stale_ink`] forgets it.
pub ink: Option<Ink>,
pub align: Align,
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
pub char_spacing: f64,
@@ -114,6 +175,44 @@ impl AddedText {
pub fn paragraph_align(&self, i: usize) -> Align {
crate::paragraph_align::align_of(&self.aligns, i, self.align)
}
/// Paint the text in `ink` (its look becomes [`AddedText::color`]), or in RGB with `None`. An
/// ink in range is kept as painted ([`Ink::painted`]); one out of range stays as given, for
/// saving to refuse.
pub fn set_ink(&mut self, ink: Option<Ink>) {
let ink = ink.map(|i| if i.valid() { i.painted() } else { i });
if let Some(i) = ink {
self.color = i.rgb();
}
self.ink = ink;
}
/// Paint the text in RGB `rgb`. Its ink stays only when `rgb` is the ink's own look; once
/// gone it stays gone, so picking that look again later paints in RGB.
pub fn set_color(&mut self, rgb: [f64; 3]) {
self.color = rgb;
self.drop_stale_ink();
}
/// Forget an ink `color` no longer shows, so two styles that paint the same compare equal.
pub fn drop_stale_ink(&mut self) {
self.ink = self.painted_ink();
}
/// The ink the text is painted in, or `None` for RGB: the one it has while `color` is still
/// its look.
pub fn painted_ink(&self) -> Option<Ink> {
// Within rounding: a look read back from a file, or from a colour picker, is the same.
self.ink.filter(|i| i.rgb().iter().zip(self.color).all(|(a, b)| (a - b).abs() < 1e-6))
}
/// The operator that sets the text's fill colour, without a line end.
pub(super) fn fill_op(&self) -> String {
match self.painted_ink() {
Some(ink) => ink.fill_op(),
None => crate::marks::rgb(self.color),
}
}
}
impl Default for AddedText {
@@ -126,6 +225,7 @@ impl Default for AddedText {
italic: false,
size: 12.0,
color: [0.0; 3],
ink: None,
align: Align::Left,
aligns: Vec::new(),
char_spacing: 0.0,
+2 -2
View File
@@ -61,8 +61,8 @@ pub use text::{
};
pub mod added;
pub use added::{
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added,
scan_markers, update_content,
Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content,
list_added, scan_markers, update_content,
};
mod apply;
+158
View File
@@ -1048,6 +1048,164 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
assert!(marks_present(&doc).is_empty());
}
/// #6440: added text in a print file can be gray or CMYK, painted with `g` / `k`, so its black
/// stays 100 K. The ink survives save and reopen; another colour drops it for good.
#[test]
fn gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured() {
use crate::added::Ink;
let item_stream = |doc: &Document, i: usize| {
let obj = list_added(doc)[i].obj;
let Object::Stream(s) = &*doc.get(obj) else { panic!("an item is a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
};
let mut doc = fixture();
let base = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), size: 14.0, ..AddedText::default() };
let mut black = base.clone();
black.set_ink(Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])));
assert_eq!(black.color, [0.0; 3], "100 K looks black on screen");
let mut gray = AddedText { rect: [72.0, 400.0, 300.0, 500.0], ..base.clone() };
gray.set_ink(Some(Ink::Gray(0.25)));
add_content(&mut doc, 0, &Content::Text(black)).unwrap();
add_content(&mut doc, 0, &Content::Text(gray)).unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { rect: [72.0, 200.0, 300.0, 300.0], ..base.clone() })).unwrap();
let doc = reopen(&doc);
let (k, g, rgb) = (item_stream(&doc, 0), item_stream(&doc, 1), item_stream(&doc, 2));
assert!(k.contains("/PCFHelvetica 14 Tf 0 0 0 1 k\n") && !k.contains("rg"), "{k}");
assert!(g.contains("/PCFHelvetica 14 Tf 0.25 g\n") && !g.contains("rg"), "{g}");
assert!(rgb.contains("/PCFHelvetica 14 Tf 0 0 0 rg\n"), "RGB text paints as before: {rgb}");
let inks: Vec<Option<Ink>> = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.painted_ink() } else { None }).collect();
assert_eq!(inks, [Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])), Some(Ink::Gray(0.25)), None]);
// Another colour drops the ink, and picking the ink's look again later doesn't bring it back;
// picking that look while the ink is there keeps it.
let Content::Text(cmyk) = list_added(&doc)[0].content.clone() else { panic!() };
let mut same = cmyk.clone();
same.set_color([0.0; 3]);
assert_eq!(same, cmyk, "black on 100 K stays 100 K");
let mut recoloured = cmyk.clone();
recoloured.set_color([1.0, 0.0, 0.0]);
assert_eq!(recoloured.ink, None);
recoloured.set_color([0.0; 3]);
assert_eq!((recoloured.ink, recoloured.painted_ink()), (None, None), "the ink stays gone");
// Set directly, a colour paints in RGB too, and the item reads back without the ink: what a
// draft with its stale ink dropped compares equal to.
let mut doc = doc;
let red = AddedText { color: [1.0, 0.0, 0.0], ..cmyk };
update_content(&mut doc, 0, 0, &Content::Text(red.clone())).unwrap();
let stream = item_stream(&doc, 0);
assert!(stream.contains("1 0 0 rg") && !stream.contains(" k\n"), "{stream}");
let mut settled = red;
settled.drop_stale_ink();
for doc in [&doc, &reopen(&doc)] {
let Content::Text(back) = list_added(doc)[0].content.clone() else { panic!() };
// The stored box is the text's own height.
assert_eq!(back, AddedText { rect: back.rect, ..settled.clone() });
}
// CMYK looks as ISO 32000-2 §10.4.2.5 maps it to RGB; an ink out of range is refused.
assert_eq!(Ink::Cmyk([0.5, 0.0, 1.0, 0.25]).rgb(), [0.25, 0.75, 0.0]);
let mut wrong = base;
wrong.ink = Some(Ink::Gray(1.5));
assert!(add_content(&mut doc, 0, &Content::Text(wrong)).is_err());
}
/// #6440: a file edited by hand paints its added text in RGB unless the stored gray or CMYK is
/// in range, the right length and still the look of `/Color`; nothing in it panics.
#[test]
fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() {
use crate::added::Ink;
let mut doc = fixture();
add_content(&mut doc, 0, &Content::Text(AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), ..AddedText::default() })).unwrap();
let obj = list_added(&doc)[0].obj;
let real = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
// A `/CMYK` far longer than four numbers, as a hostile file might store it: an ink first.
let long = real(&[0.0, 0.0, 0.0, 1.0].repeat(50_000));
let long_ref = Object::Ref(doc.add(long.clone()));
let ink_ref = Object::Ref(doc.add(real(&[0.0, 0.0, 0.0, 1.0])));
let read_text = |entries: &[(&[u8], Object)]| {
let mut edited = doc.clone();
let Object::Stream(mut s) = (*edited.get(obj)).clone() else { panic!("an item is a stream") };
let mut params = s.dict.get(b"PCAdded").and_then(|p| p.as_dict().cloned()).unwrap();
for (key, value) in entries {
params.set(key.to_vec(), value.clone());
}
s.dict.set(b"PCAdded".to_vec(), Object::Dict(params));
edited.set(obj, Object::Stream(s));
let Content::Text(t) = list_added(&edited)[0].content.clone() else { panic!() };
t
};
let read = |entries: &[(&[u8], Object)]| read_text(entries).ink;
// As PDFCraft writes it: the ink stands.
assert_eq!(read(&[(b"Color", real(&[0.5; 3])), (b"Gray", Object::Real(0.5))]), Some(Ink::Gray(0.5)));
assert_eq!(read(&[(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))]), Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])));
assert_eq!(read(&[(b"Color", real(&[0.0; 3])), (b"CMYK", ink_ref)]), Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])), "by reference");
// Finer than it paints, it reads back as painted (`0.5 g`), its look with it.
let fine = read_text(&[(b"Color", real(&[0.50049; 3])), (b"Gray", Object::Real(0.50049))]);
assert_eq!((fine.ink, fine.color), (Some(Ink::Gray(0.5)), [0.5; 3]));
for (what, entries) in [
("a colour the ink isn't the look of", vec![(&b"Color"[..], real(&[1.0, 0.0, 0.0])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))]),
("three CMYK numbers", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 1.0]))]),
("five CMYK numbers", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0, 0.0]))]),
("a long CMYK", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", long.clone())]),
("a long CMYK by reference", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", long_ref.clone())]),
("gray a long array", vec![(b"Color", real(&[0.0; 3])), (b"Gray", long.clone())]),
("CMYK not an array", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", Object::Real(1.0))]),
("gray out of range", vec![(b"Color", real(&[1.0; 3])), (b"Gray", Object::Real(2.0))]),
("gray not a number", vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::name("Black"))]),
(
"a CMYK entry not a number",
vec![
(b"Color", real(&[0.0; 3])),
(b"CMYK", Object::Array(vec![Object::Real(0.0), Object::name("Bad"), Object::Real(0.0), Object::Real(1.0)])),
],
),
(
"gray not a number beside a CMYK",
vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::name("Black")), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))],
),
("gray NaN", vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::Real(f64::NAN))]),
("CMYK infinite", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, f64::INFINITY]))]),
] {
assert_eq!(read(&entries), None, "{what}");
}
}
/// #6440: an ink finer than the thousandth `g` and `k` are written to is kept as painted, so the
/// item, its stored `/CMYK` and what it reads back as are what the page shows.
#[test]
fn an_ink_is_kept_as_it_is_painted() {
use crate::added::Ink;
let mut doc = fixture();
let mut t = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), size: 14.0, ..AddedText::default() };
t.set_ink(Some(Ink::Cmyk([0.12345, 0.00049, 0.0, 0.99951])));
let painted = Ink::Cmyk([0.123, 0.0, 0.0, 1.0]);
assert_eq!((t.ink, t.color), (Some(painted), painted.rgb()));
add_content(&mut doc, 0, &Content::Text(t)).unwrap();
let doc = reopen(&doc);
let item = &list_added(&doc)[0];
let Object::Stream(s) = &*doc.get(item.obj) else { panic!("an item is a stream") };
let stream = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(
stream.contains(
"/PCFHelvetica 14 Tf 0.123 0 0 1 k
"
),
"{stream}"
);
let stored = s
.dict
.get(b"PCAdded")
.and_then(|p| p.as_dict())
.and_then(|p| p.get(b"CMYK"))
.and_then(Object::as_array)
.map(|a| a.iter().filter_map(Object::as_f64).collect::<Vec<_>>());
assert_eq!(stored, Some(vec![0.123, 0.0, 0.0, 1.0]));
let Content::Text(back) = &item.content else { panic!() };
assert_eq!(back.painted_ink(), Some(painted));
}
/// #4464: a page left with one added item draws that item and nothing else.
#[test]
fn an_isolated_item_is_all_that_is_left_on_its_page() {
+18 -6
View File
@@ -206,6 +206,20 @@ pub struct OfficeExport {
/// `Err(false)`: it can't be encoded).
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
/// Displayed points per user-space unit on `info`'s page (`/UserUnit`), 1 when the page has no
/// usable size.
pub(crate) fn display_unit(info: &pdfcraft_render::PageInfo) -> f64 {
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation.is_multiple_of(180) { info.width } else { info.height });
if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }
}
/// Bold and italic as a font's name says them (`Arial-BoldItalicMT`).
pub(crate) fn weight_and_slant(base_font: &str) -> (bool, bool) {
let f = base_font.to_ascii_lowercase();
(f.contains("bold") || f.contains("black") || f.contains("heavy"), f.contains("italic") || f.contains("oblique"))
}
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
/// form XObjects draw.
@@ -247,9 +261,7 @@ impl crate::Document {
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
};
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
let unit = display_unit(info);
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
let forms_unread = reading.truncated;
let blocks = reading
@@ -257,7 +269,7 @@ impl crate::Document {
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
let f = b.base_font.to_ascii_lowercase();
let (bold, italic) = weight_and_slant(&b.base_font);
// `text` is trimmed; the editor text keeps the source's own spaces.
let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)];
pdfcraft_export::Block {
@@ -265,8 +277,8 @@ impl crate::Document {
text: b.text,
rect: visible_rect(b.rect),
size: b.size * unit,
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
italic: f.contains("italic") || f.contains("oblique"),
bold,
italic,
color: b.color,
rotation: b.rotation - f64::from(info.rotation),
}
+6
View File
@@ -631,6 +631,12 @@ impl Document {
pub fn repair_log(&self) -> Vec<String> {
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
}
/// Objects of the file that could not be read and read as null, one line each (see
/// [`pdfcraft_cos::Document::unreadable_objects`]): only those read so far.
pub fn unreadable_objects(&self) -> Vec<String> {
self.editor.as_ref().map(|e| e.cos.unreadable_objects()).unwrap_or_default()
}
}
/// A title that names no document: what a web browser writes when it saves a blank page or a
+4 -1
View File
@@ -44,6 +44,7 @@ mod pictures;
pub mod print_raster;
pub mod printcheck;
mod protection;
mod rich_copy;
pub mod scan_clean;
mod scan_colours;
mod scan_fields;
@@ -68,6 +69,7 @@ pub use links::{
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
pub use pdfcraft_edit::strip_subset_tag;
pub use rich_copy::RichCopy;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::{CommentLayer, StampPreview};
@@ -91,7 +93,8 @@ pub use pdfcraft_edit::added::{
};
pub use pdfcraft_edit::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk,
MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
+389
View File
@@ -0,0 +1,389 @@
//! Copy with formatting: the selected glyphs of a page as paragraphs of styled spans and an
//! HTML fragment ([`pdfcraft_export::clip_html`]) for the clipboard, next to the plain text.
//!
//! Glyphs come from the text layer (view space); style comes from the page's reading blocks,
//! which never mix styles, mapped into view space. A glyph takes the style of the block its
//! centre lies in; a glyph in no block is copied unstyled.
use pdfcraft_export::{ClipBuilder, ClipStyle, ClipText, clip_html};
use pdfcraft_render::{PageInfo, PageText};
use crate::EditError;
use crate::compare::{display_unit, weight_and_slant};
/// Block tests spent matching glyphs to blocks for one copy; past it the rest is unstyled.
const MAX_MATCH_STEPS: usize = 20_000_000;
/// How far outside a block's box (points) a glyph centre still belongs to it.
const SLACK: f32 = 0.5;
/// Selected text with its formatting.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RichCopy {
/// The paragraphs and spans.
pub text: ClipText,
/// The HTML fragment for the clipboard (empty when nothing was selected).
pub html: String,
/// Styles were left out: the document has no editor to read them, reading stopped at its
/// limits on this page, or matching glyphs to blocks ran out of steps.
pub styles_incomplete: bool,
/// A cap left part of the selection out of `text` and `html`; the plain text has all of it.
pub truncated: bool,
}
struct StyledBlock {
rect: [f32; 4],
style: ClipStyle,
}
impl crate::Document {
/// Selected `glyphs` of `page` (indices into `text`, in copy order) with their formatting.
/// Refused when the document does not allow copying.
pub fn rich_copy(&self, page: usize, text: &PageText, glyphs: &[usize]) -> Result<RichCopy, EditError> {
self.rich_copy_within(page, text, glyphs, MAX_MATCH_STEPS)
}
/// [`Self::rich_copy`] with `steps` block tests to match glyphs to blocks.
fn rich_copy_within(&self, page: usize, text: &PageText, glyphs: &[usize], mut steps: usize) -> Result<RichCopy, EditError> {
self.check_copying()?;
let (blocks, mut incomplete) = self.styled_blocks(page);
let mut nested = vec![None; blocks.len()];
let mut b = ClipBuilder::new();
// The previous glyph's line, block and box, and whether it was a hyphen.
let mut prev: Option<(usize, Option<usize>, [f32; 4], bool)> = None;
for &i in glyphs {
let (Some(g), Some(&line)) = (text.glyphs.get(i), text.line_of.get(i)) else { continue };
let hint = prev.and_then(|(_, b, _, _)| b).filter(|&k| !holds_smaller(&blocks, &mut nested, k, &mut steps));
let found = block_at(&blocks, g.rect, hint, &mut steps).unwrap_or_else(|| {
incomplete = true;
None
});
let line = usize::try_from(line).unwrap_or(usize::MAX);
if let Some((prev_line, prev_block, prev_rect, hyphen)) = prev {
if line != prev_line {
let style = |k: Option<usize>| k.and_then(|k| blocks.get(k)).map(|s| &s.style);
if found.is_none() && prev_block.is_none() {
b.line_break();
} else if found == prev_block || wraps(style(prev_block), style(found), prev_rect, g.rect) {
// A paragraph wrapping: one space, none after a hyphen.
if !hyphen {
b.space();
}
} else {
b.paragraph();
}
} else if text.space_before.get(i).copied().unwrap_or(false) {
b.space();
}
}
let style = found.and_then(|k| blocks.get(k)).map(|s| &s.style);
if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) {
break;
}
prev = Some((line, found, g.rect, g.text.ends_with('-')));
}
Ok(finish(b, incomplete))
}
/// `page`'s reading blocks in view space with their style, and whether reading stopped at
/// its limits.
fn styled_blocks(&self, page: usize) -> (Vec<StyledBlock>, bool) {
let Some(info) = self.info.pages.get(page) else { return (Vec::new(), false) };
let Some(editor) = self.editor.as_ref() else { return (Vec::new(), true) };
let Ok(reading) = pdfcraft_edit::reading_blocks_checked(&editor.cos, page) else { return (Vec::new(), true) };
let unit = display_unit(info);
let blocks = reading
.blocks
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
let (bold, italic) = weight_and_slant(&b.base_font);
let style = ClipStyle {
families: pdfcraft_export::font_family(&b.base_font),
size: b.size * unit,
bold: b.bold || bold,
italic: b.italic || italic,
color: b.color,
};
StyledBlock { rect: view_rect(info, b.rect), style }
})
.collect();
(blocks, reading.truncated)
}
}
/// A user-space box as a view-space box (points from the displayed page's top-left, y down).
fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
/// The copy `b` collected, with its HTML; `truncated` when a cap cut the text or the HTML.
fn finish(b: ClipBuilder, styles_incomplete: bool) -> RichCopy {
let text = b.finish();
let (html, cut) = clip_html(&text);
RichCopy { html, truncated: text.truncated || cut, text, styles_incomplete }
}
/// Whether the next line starts inside the same paragraph although its first glyph is in another
/// block: blocks split where the style changes, so a style change at a line's end is still a
/// wrap when the size stays and the lines sit no further apart than their own height allows.
/// Two blocks of the same style, a change of size, or a gap of over half a line are paragraphs.
fn wraps(prev: Option<&ClipStyle>, next: Option<&ClipStyle>, prev_rect: [f32; 4], rect: [f32; 4]) -> bool {
let (Some(prev), Some(next)) = (prev, next) else { return false };
let diff = (prev.size - next.size).abs();
if prev == next || diff.is_nan() || diff > 0.01 {
return false;
}
let height = (prev_rect[3] - prev_rect[1]).max(rect[3] - rect[1]);
// The next line starts just below the last one; a line far above it is another column.
let gap = rect[1] - prev_rect[3];
height > 0.0 && gap.is_finite() && gap >= -height / 2.0 && gap <= height / 2.0
}
/// Whether a block smaller than block `k` overlaps it, so a glyph inside `k` may belong to that
/// one instead. Worked out once per block (`nested` keeps it), at a step per block tested; when
/// the steps run out the answer is yes, so the full search decides.
fn holds_smaller(blocks: &[StyledBlock], nested: &mut [Option<bool>], k: usize, steps: &mut usize) -> bool {
if let Some(Some(known)) = nested.get(k) {
return *known;
}
let Some(outer) = blocks.get(k) else { return true };
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
let overlaps = |r: &[f32; 4]| r[0] < outer.rect[2] && r[2] > outer.rect[0] && r[1] < outer.rect[3] && r[3] > outer.rect[1];
let mut found = false;
for (j, b) in blocks.iter().enumerate() {
if *steps == 0 {
return true;
}
*steps -= 1;
if j != k && overlaps(&b.rect) && area(&b.rect) < area(&outer.rect) {
found = true;
break;
}
}
if let Some(slot) = nested.get_mut(k) {
*slot = Some(found);
}
found
}
/// The block whose box holds the centre of `glyph` (view space): `hint` first (a block no
/// smaller one overlaps), then the smallest that holds it. Each block tested costs a step;
/// `None` when the steps ran out first.
fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option<usize>, steps: &mut usize) -> Option<Option<usize>> {
let (x, y) = ((glyph[0] + glyph[2]) / 2.0, (glyph[1] + glyph[3]) / 2.0);
let holds = |r: &[f32; 4]| x >= r[0] - SLACK && x <= r[2] + SLACK && y >= r[1] - SLACK && y <= r[3] + SLACK;
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
if let Some(k) = hint
&& *steps > 0
{
*steps -= 1;
if blocks.get(k).is_some_and(|b| holds(&b.rect)) {
return Some(Some(k));
}
}
let mut best: Option<(usize, f32)> = None;
for (k, b) in blocks.iter().enumerate() {
if *steps == 0 {
return None;
}
*steps -= 1;
if holds(&b.rect) && best.is_none_or(|(_, a)| area(&b.rect) < a) {
best = Some((k, area(&b.rect)));
}
}
Some(best.map(|(k, _)| k))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use pdfcraft_export::{ClipBuilder, ClipStyle};
use crate::Session;
/// A heading in bold, a body paragraph of two lines with HTML-special characters, and a red
/// word on the body's first line.
fn styled_page() -> Vec<u8> {
let content = b"BT /F1 24 Tf 20 250 Td (Title) Tj ET \
BT /F2 12 Tf 20 200 Td (Body text) Tj ET \
BT 1 0 0 rg /F2 12 Tf 90 200 Td (red) Tj ET \
BT /F2 12 Tf 20 186 Td (a<b & c) Tj ET";
page_with(content)
}
/// A one-page PDF drawing `content` with `/F1` Helvetica-Bold and `/F2` Helvetica.
fn page_with(content: &[u8]) -> Vec<u8> {
pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>")
.stream("", content)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.build()
}
fn page_text(s: &Session, id: crate::DocId) -> Arc<pdfcraft_render::PageText> {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
r.render(request).text.unwrap()
}
#[test]
fn copy_keeps_headings_body_and_colour_as_html() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
let html = &copy.html;
assert!(!copy.styles_incomplete);
assert!(
html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n"),
"{html}"
);
assert!(html.contains("font-size:12pt\">Body text "), "{html}");
assert!(html.contains("font-size:12pt;color:#FF0000\">red</span>"), "{html}");
assert!(html.contains("a&lt;b &amp; c"), "{html}");
assert!(!html.contains("<b &"), "{html}");
assert_eq!(copy.text.paragraphs.len(), html.matches("<p ").count());
assert!(copy.text.paragraphs.len() >= 2, "{html}");
// The heading and the body are separate paragraphs; the red word shares the body's.
let body = copy.text.paragraphs.iter().find(|p| p.iter().any(|s| s.text.contains("Body"))).unwrap();
assert!(body.iter().any(|s| s.text == "red" && s.style.color == [1.0, 0.0, 0.0]), "{body:?}");
// A part of the selection: only those glyphs.
let part = s.get(id).unwrap().rich_copy(0, &text, &all[..5]).unwrap();
assert_eq!(part.text.paragraphs.len(), 1);
assert!(part.html.contains("<strong>Title</strong>"), "{}", part.html);
// Nothing selected, and indices past the text: an empty copy, not a panic.
let none = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap();
assert!(none.html.is_empty() && none.text.paragraphs.is_empty());
let past = s.get(id).unwrap().rich_copy(7, &text, &[usize::MAX, text.glyphs.len()]).unwrap();
assert!(past.html.is_empty());
}
#[test]
fn running_out_of_match_steps_copies_the_rest_unstyled() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
let doc = s.get(id).unwrap();
let full = doc.rich_copy(0, &text, &all).unwrap();
// Enough for the first glyphs only: the text is all there, the rest has no style.
let short = doc.rich_copy_within(0, &text, &all, 8).unwrap();
assert!(short.styles_incomplete);
let plain = |c: &super::RichCopy| c.text.paragraphs.iter().flatten().map(|s| s.text.clone()).collect::<String>();
assert_eq!(plain(&short).replace([' ', '\n'], ""), plain(&full).replace([' ', '\n'], ""));
let last = short.text.paragraphs.last().and_then(|p| p.last()).unwrap();
assert_eq!(last.style, pdfcraft_export::ClipStyle::default(), "{last:?}");
assert!(short.html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>T"), "{}", short.html);
// No steps at all: nothing styled, still no panic.
let none = doc.rich_copy_within(0, &text, &all, 0).unwrap();
assert!(none.styles_incomplete && !none.html.contains("font-size"), "{}", none.html);
// A page without blocks to match needs no steps.
let page_less = doc.rich_copy_within(5, &text, &all, 0).unwrap();
assert!(!page_less.styles_incomplete);
}
fn copy_all(s: &Session, id: crate::DocId) -> super::RichCopy {
let text = page_text(s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
s.get(id).unwrap().rich_copy(0, &text, &all).unwrap()
}
#[test]
fn a_style_change_at_a_line_end_stays_in_its_paragraph() {
let content = b"BT /F2 12 Tf 20 200 Td (plain then) Tj ET \
BT /F1 12 Tf 85 200 Td (bold) Tj ET \
BT /F2 12 Tf 20 186 Td (continues here) Tj ET \
BT /F2 12 Tf 20 120 Td (Another paragraph) Tj ET";
let mut s = Session::new();
let id = s.open("wrap.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let text = |p: &Vec<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
let paragraphs: Vec<String> = copy.text.paragraphs.iter().map(text).collect();
assert!(paragraphs.iter().any(|p| p.contains("bold") && p.contains("continues")), "{paragraphs:?}");
assert!(paragraphs.iter().any(|p| p.contains("Another") && !p.contains("continues")), "a wide gap is still a paragraph: {paragraphs:?}");
}
#[test]
fn a_style_change_into_the_next_column_starts_a_paragraph() {
let content = b"BT /F2 12 Tf 20 200 Td (left top) Tj ET \
BT /F2 12 Tf 20 186 Td (left end) Tj ET \
BT /F1 12 Tf 170 200 Td (Right head) Tj ET \
BT /F2 12 Tf 170 186 Td (right body) Tj ET";
let mut s = Session::new();
let id = s.open("columns.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let text = |p: &Vec<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
let paragraphs: Vec<String> = copy.text.paragraphs.iter().map(text).collect();
assert!(paragraphs.iter().any(|p| p.contains("Right")), "{paragraphs:?}");
assert!(!paragraphs.iter().any(|p| p.contains("left end") && p.contains("Right")), "{paragraphs:?}");
}
#[test]
fn a_styled_word_inside_a_paragraph_keeps_its_style() {
let content = b"BT /F2 12 Tf 20 200 Td (first row of the text) Tj ET \
BT /F2 12 Tf 20 186 Td (second) Tj ET \
BT /F1 12 Tf 62 186 Td (bold) Tj ET \
BT /F2 12 Tf 92 186 Td (row) Tj ET";
let mut s = Session::new();
let id = s.open("nested.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let spans: Vec<_> = copy.text.paragraphs.iter().flatten().collect();
assert!(spans.iter().any(|s| s.text.contains("bold") && s.style.bold), "{spans:?}");
assert!(spans.iter().any(|s| s.text.contains("second") && !s.style.bold), "{spans:?}");
}
#[test]
fn a_capped_copy_is_marked_truncated() {
let (plain, bold) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
let mut b = ClipBuilder::new();
for k in 0..=pdfcraft_export::MAX_CLIP_SPANS {
b.push("x", if k % 2 == 0 { &plain } else { &bold });
}
let copy = super::finish(b, false);
assert!(copy.truncated && copy.text.truncated);
let mut b = ClipBuilder::new();
b.push("x", &plain);
assert!(!super::finish(b, false).truncated);
}
#[test]
fn without_an_editor_the_styles_are_incomplete() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
s.doc_mut(id).unwrap().editor = None;
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
assert!(copy.styles_incomplete && !copy.html.contains("font-size"), "{}", copy.html);
assert!(!copy.truncated);
}
#[test]
fn copy_with_formatting_is_refused_when_copying_is_withheld() {
let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap();
cos.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: 0b0100, // print only
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("locked.pdf", None, bytes, None).unwrap();
let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() };
let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err();
assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}");
}
}
+3
View File
@@ -4182,6 +4182,7 @@ fn pasted_added_content_keeps_its_style_and_picture() {
italic: true,
size: 12.0,
color: [0.8, 0.1, 0.2],
ink: None,
align: TextAlign::Center,
aligns: Vec::new(),
char_spacing: 0.0,
@@ -8112,6 +8113,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
ink: None,
align: TextAlign::Left,
aligns: Vec::new(),
char_spacing: 0.0,
@@ -8153,6 +8155,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
ink: None,
align: TextAlign::Left,
aligns: Vec::new(),
char_spacing: 0.0,
+433
View File
@@ -0,0 +1,433 @@
//! Copy with formatting: selected text as paragraphs of styled spans, written as an HTML
//! fragment for the clipboard (inline styles, so a word processor keeps font, size, weight,
//! slant and colour when it pastes).
use crate::as_html::allowed;
use crate::{esc, hex};
/// Spans kept for one copy; text past it is left out and [`ClipText::truncated`] is set.
pub const MAX_CLIP_SPANS: usize = 20_000;
/// Text bytes kept for one copy (before escaping).
pub const MAX_CLIP_TEXT_BYTES: usize = 2 << 20;
/// The HTML written for one copy never passes this many bytes.
pub const MAX_CLIP_HTML_BYTES: usize = 16 << 20;
/// Font sizes are written within this range (points), so a hostile size stays a font size.
const CLIP_SIZE_PT: std::ops::RangeInclusive<f64> = 1.0..=1000.0;
/// A family name longer than this is not a family name.
const MAX_FAMILY_CHARS: usize = 64;
/// How a span looks.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ClipStyle {
/// Family names to try in order ([`font_family`]); empty keeps the target's own.
pub families: Vec<String>,
/// Size in points; zero or not finite leaves the target's size, and any other is written
/// within 1 to 1000.
pub size: f64,
pub bold: bool,
pub italic: bool,
/// Fill colour as RGB in 0..=1.
pub color: [f64; 3],
}
/// A run of text in one style.
#[derive(Clone, Debug, PartialEq)]
pub struct ClipSpan {
pub text: String,
pub style: ClipStyle,
}
/// The copied text: paragraphs of spans, and whether a cap left some out.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ClipText {
pub paragraphs: Vec<Vec<ClipSpan>>,
pub truncated: bool,
}
/// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and
/// stopping at the caps.
#[derive(Debug)]
pub struct ClipBuilder {
out: ClipText,
/// The separator owed before the next piece in this paragraph (a space or a line break).
pending: Option<char>,
new_paragraph: bool,
spans: usize,
bytes: usize,
}
impl Default for ClipBuilder {
fn default() -> Self {
Self { out: ClipText::default(), pending: None, new_paragraph: true, spans: 0, bytes: 0 }
}
}
impl ClipBuilder {
pub fn new() -> Self {
Self::default()
}
/// A word space before the next piece (dropped at the start of a paragraph).
pub fn space(&mut self) {
self.pending.get_or_insert(' ');
}
/// A line break inside the paragraph before the next piece.
pub fn line_break(&mut self) {
self.pending = Some('\n');
}
/// The next piece starts a new paragraph.
pub fn paragraph(&mut self) {
self.new_paragraph = true;
self.pending = None;
}
/// Append `text` in `style`. Returns `false` once a cap is reached; nothing more is kept.
pub fn push(&mut self, text: &str, style: &ClipStyle) -> bool {
if self.out.truncated {
return false;
}
if text.is_empty() {
return true;
}
let sep = if self.new_paragraph { None } else { self.pending };
let need = text.len().saturating_add(sep.map_or(0, char::len_utf8));
let Some(bytes) = self.bytes.checked_add(need).filter(|b| *b <= MAX_CLIP_TEXT_BYTES) else {
self.out.truncated = true;
return false;
};
let new_span = self.new_paragraph || self.out.paragraphs.last().and_then(|p| p.last()).is_none_or(|last| last.style != *style);
if new_span && self.spans >= MAX_CLIP_SPANS {
self.out.truncated = true;
return false;
}
if self.new_paragraph {
self.out.paragraphs.push(Vec::new());
self.new_paragraph = false;
}
self.pending = None;
let Some(spans) = self.out.paragraphs.last_mut() else { return false };
match spans.last_mut() {
Some(last) if last.style == *style => {
last.text.extend(sep);
last.text.push_str(text);
}
last => {
// A separator belongs to the span before it, so a new style starts at its text.
if let (Some(last), Some(sep)) = (last, sep) {
last.text.push(sep);
}
spans.push(ClipSpan { text: text.to_owned(), style: style.clone() });
self.spans += 1;
}
}
self.bytes = bytes;
true
}
pub fn finish(self) -> ClipText {
self.out
}
}
/// The family names to try for a font's `/BaseFont`, best first: subset tag and style suffix
/// removed, `PSMT`/`MT`/`PS` dropped. The first has camel case and hyphens spaced
/// (`ABCDEF+TimesNewRomanPSMT` → `Times New Roman`, `MS-Gothic` → `MS Gothic`); the second,
/// when it differs, is the name as spelt (`SimSun`). A hyphen ends the family only before a
/// style word (`Arial-BoldMT`). Only letters, digits, spaces, `-`, `_` and `.` are kept, so a
/// name cannot leave its CSS string. Empty when nothing is left.
pub fn font_family(base_font: &str) -> Vec<String> {
let name = match base_font.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => base_font,
};
let name = name.split_once(',').map_or(name, |(family, _)| family);
let family = match name.rsplit_once('-') {
Some((family, style)) if STYLE_WORDS.iter().any(|w| style.starts_with(w)) => family,
_ => name,
};
let family = family.strip_suffix("PSMT").or_else(|| family.strip_suffix("MT")).or_else(|| family.strip_suffix("PS")).unwrap_or(family);
let mut spaced = String::with_capacity(family.len().min(MAX_FAMILY_CHARS * 2));
let mut prev_lower = false;
for c in family.chars().take(MAX_FAMILY_CHARS) {
if prev_lower && c.is_ascii_uppercase() {
spaced.push(' ');
}
prev_lower = c.is_ascii_lowercase();
spaced.push(if c == '-' { ' ' } else { c });
}
let spelt: String = family.chars().take(MAX_FAMILY_CHARS).collect();
let mut out: Vec<String> = Vec::with_capacity(2);
for f in [safe_family(&spaced), safe_family(&spelt)].into_iter().flatten() {
if !out.contains(&f) {
out.push(f);
}
}
out
}
/// What may follow a hyphen in a `/BaseFont` when the rest names the style, not the family.
const STYLE_WORDS: &[&str] = &[
"Bold", "Italic", "Oblique", "Regular", "Roman", "Light", "Medium", "Semi", "Demi", "Black", "Heavy", "Thin", "Book", "Cond", "Extra", "Ultra",
"Narrow", "Plain", "Normal", "It", "Bd", "Lt", "Md", "Blk", "MT", "PS",
];
/// `family` with only letters, digits, spaces, `-`, `_` and `.`, so it cannot leave a CSS
/// string; `None` when nothing is left.
fn safe_family(family: &str) -> Option<String> {
let kept: String = family.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')).take(MAX_FAMILY_CHARS * 2).collect();
let kept = kept.trim();
(!kept.is_empty()).then(|| kept.to_owned())
}
/// The copied text as an HTML fragment, and whether [`MAX_CLIP_HTML_BYTES`] cut it: one `<p>`
/// per paragraph, a `<span>` per run with its families, size and colour inline, bold as
/// `<strong>` and italic as `<em>` (so a target that drops style attributes still keeps them).
/// Text is escaped; a line break is `<br>`, and a space HTML would collapse (after another
/// space, at a paragraph's start or after a break, across runs too) is `&nbsp;`. Never longer
/// than the cap: a paragraph or run that would pass it ends the fragment.
pub fn clip_html(text: &ClipText) -> (String, bool) {
const OPEN: &str = "<p style=\"margin:0\">";
const CLOSE: &str = "</p>\n";
let fits = |len: usize, more: usize| len.saturating_add(more).saturating_add(CLOSE.len()) <= MAX_CLIP_HTML_BYTES;
let mut out = String::new();
for spans in &text.paragraphs {
if !fits(out.len(), OPEN.len()) {
return (out, true);
}
let mut p = String::from(OPEN);
// A paragraph's first space would collapse.
let mut space_before = true;
for span in spans {
let s = span_html(span, &mut space_before);
if !fits(out.len().saturating_add(p.len()), s.len()) {
if p.len() > OPEN.len() {
out.push_str(&p);
out.push_str(CLOSE);
}
return (out, true);
}
p.push_str(&s);
}
p.push_str(CLOSE);
out.push_str(&p);
}
(out, false)
}
/// One run as HTML. `space_before` says whether the text before it ended in a space or a break,
/// and is left saying so for the next run.
fn span_html(span: &ClipSpan, space_before: &mut bool) -> String {
let st = &span.style;
let mut css = Vec::new();
let families: Vec<String> = st.families.iter().filter_map(|f| safe_family(f)).map(|f| format!("'{f}'")).collect();
if !families.is_empty() {
css.push(format!("font-family:{}", families.join(",")));
}
if st.size.is_finite() && st.size != 0.0 {
css.push(format!("font-size:{}pt", num(st.size.clamp(*CLIP_SIZE_PT.start(), *CLIP_SIZE_PT.end()))));
}
if let Some(h) = hex(st.color) {
css.push(format!("color:#{h}"));
}
let mut body = text_html(&span.text, space_before);
if st.italic {
body = format!("<em>{body}</em>");
}
if st.bold {
body = format!("<strong>{body}</strong>");
}
if css.is_empty() { body } else { format!("<span style=\"{}\">{body}</span>", esc(&css.join(";"))) }
}
/// Escape for HTML text in one pass; `\n` becomes `<br>`, and a space after a space or a break
/// (`space_before` carries that in and out) is `&nbsp;`, so a run of spaces keeps its width.
/// Dropped: what [`esc`] drops (C0 controls), C1 controls, a carriage return
/// and the bidirectional overrides and isolates, which would reorder the pasted text.
fn text_html(text: &str, space_before: &mut bool) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars().filter(|c| clip_char(*c)) {
match c {
'\n' => out.push_str("<br>"),
' ' if *space_before => out.push_str("&nbsp;"),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
c => out.push(c),
}
*space_before = matches!(c, ' ' | '\n');
}
out
}
/// Whether `c` may go into the clipboard HTML's text.
fn clip_char(c: char) -> bool {
allowed(c) && !matches!(c, '\r' | '\u{80}'..='\u{9F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
/// A size with at most one decimal and no trailing `.0`.
fn num(v: f64) -> String {
let r = (v * 10.0).round() / 10.0;
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r:.1}") }
}
#[cfg(test)]
mod tests {
use super::*;
fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle {
ClipStyle { families: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
}
#[test]
fn heading_body_and_coloured_span_keep_their_formatting() {
let mut b = ClipBuilder::new();
let heading = style(24.0, true, false, [0.0; 3]);
let body = style(11.5, false, false, [0.0; 3]);
let red = style(11.5, false, true, [1.0, 0.0, 0.0]);
b.push("Title", &heading);
b.paragraph();
b.push("Body", &body);
b.space();
b.push("text", &body);
b.space();
b.push("red", &red);
let clip = b.finish();
assert_eq!(clip.paragraphs.len(), 2);
assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{clip:?}");
let (html, cut) = clip_html(&clip);
assert!(!cut);
assert_eq!(
html,
"<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n\
<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:11.5pt\">Body text </span>\
<span style=\"font-family:'Helvetica';font-size:11.5pt;color:#FF0000\"><em>red</em></span></p>\n"
);
}
#[test]
fn special_characters_are_escaped_and_cannot_leave_the_style() {
let st = ClipStyle { families: vec!["Evil';}</style><script>\\\n".into()], size: f64::NAN, ..ClipStyle::default() };
let mut b = ClipBuilder::new();
b.push("<a href=\"x\">&\u{0}\u{1b}</a> two\nline", &st);
let (html, _) = clip_html(&b.finish());
assert_eq!(
html,
"<p style=\"margin:0\"><span style=\"font-family:'Evilstylescript'\">&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt; &nbsp;two<br>line</span></p>\n"
);
}
#[test]
fn invisible_controls_and_bidi_overrides_are_dropped() {
let mut b = ClipBuilder::new();
b.push("a\u{85}b\u{9F}c\rd\u{202E}e\u{2066}f\u{2069}g\u{202A}h\u{2067}i\tj", &ClipStyle::default());
assert_eq!(clip_html(&b.finish()).0, "<p style=\"margin:0\">abcdefghi\tj</p>\n");
}
#[test]
fn font_sizes_stay_within_range() {
for (size, want) in [(1e300, "1000"), (f64::MAX, "1000"), (2e306, "1000"), (0.2, "1"), (9.96, "10"), (-12.0, "1"), (f64::MIN, "1")] {
let st = ClipStyle { size, ..ClipStyle::default() };
let mut b = ClipBuilder::new();
b.push("x", &st);
let (html, _) = clip_html(&b.finish());
assert_eq!(html, format!("<p style=\"margin:0\"><span style=\"font-size:{want}pt\">x</span></p>\n"), "{size}");
}
}
#[test]
fn families_read_like_a_word_processor_names_them() {
let f = |b: &str| font_family(b);
assert_eq!(f("ABCDEF+TimesNewRomanPSMT"), ["Times New Roman", "TimesNewRoman"]);
assert_eq!(f("Arial,BoldItalic"), ["Arial"]);
assert_eq!(f("Arial-BoldMT"), ["Arial"]);
assert_eq!(f("Helvetica-Oblique"), ["Helvetica"]);
assert_eq!(f("MS-Gothic"), ["MS Gothic", "MS-Gothic"], "a hyphen before a family word stays in the name");
assert_eq!(f("Segoe-UI-Bold"), ["Segoe UI", "Segoe-UI"], "the style is the last part");
assert_eq!(f("SimSun"), ["Sim Sun", "SimSun"], "the name as spelt is tried too");
assert_eq!(f("abcdef+Foo"), ["abcdefFoo"], "a lower-case prefix is not a subset tag");
assert!(f("").is_empty());
assert!(f("+'\"\\;").is_empty());
assert!(f(&"Ab".repeat(10_000)).iter().all(|f| f.chars().count() <= MAX_FAMILY_CHARS * 2));
let st = ClipStyle { families: f("SimSun"), ..ClipStyle::default() };
let mut b = ClipBuilder::new();
b.push("x", &st);
assert_eq!(clip_html(&b.finish()).0, "<p style=\"margin:0\"><span style=\"font-family:'Sim Sun','SimSun'\">x</span></p>\n");
}
#[test]
fn spaces_html_would_collapse_keep_their_width() {
let (a, c) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
let mut b = ClipBuilder::new();
b.push(" lead a ", &a);
b.push(" b", &c);
b.line_break();
b.push(" c", &a);
assert_eq!(
clip_html(&b.finish()).0,
"<p style=\"margin:0\">&nbsp;lead a <strong>&nbsp;b<br></strong>&nbsp;c</p>\n",
"at a paragraph's start, across runs and after a break"
);
}
#[test]
fn a_default_builder_starts_a_paragraph() {
let mut b = ClipBuilder::default();
assert!(b.push("x", &ClipStyle::default()));
assert_eq!(b.finish().paragraphs.len(), 1);
}
#[test]
fn empty_and_huge_selections_are_bounded() {
let b = ClipBuilder::new();
assert_eq!(clip_html(&b.finish()), (String::new(), false));
let mut b = ClipBuilder::new();
b.space();
assert!(b.push("", &ClipStyle::default()));
assert!(b.finish().paragraphs.is_empty(), "nothing pushed, nothing kept");
// Alternating styles: the span cap stops it.
let (a, c) = (style(10.0, false, false, [0.0; 3]), style(10.0, true, false, [0.0; 3]));
let mut b = ClipBuilder::new();
let mut kept = 0usize;
for i in 0..MAX_CLIP_SPANS + 10 {
if b.push("x", if i % 2 == 0 { &a } else { &c }) {
kept += 1;
}
}
let clip = b.finish();
assert!(clip.truncated);
assert_eq!(kept, MAX_CLIP_SPANS);
// Exactly at the cap, a new paragraph is refused before it is opened.
let mut b = ClipBuilder::new();
for i in 0..MAX_CLIP_SPANS {
assert!(b.push("x", if i % 2 == 0 { &a } else { &c }));
}
b.paragraph();
assert!(!b.push("x", &a));
let clip = b.finish();
assert!(clip.truncated);
assert_eq!(clip.paragraphs.len(), 1, "no empty paragraph past the cap");
// One style: the text cap stops it.
let mut b = ClipBuilder::new();
let chunk = "y".repeat(1 << 16);
// Bounded, so the test fails rather than runs on when the cap is gone.
let pushes = (0..MAX_CLIP_TEXT_BYTES / chunk.len() + 2).take_while(|_| b.push(&chunk, &a)).count();
assert_eq!(pushes, MAX_CLIP_TEXT_BYTES / chunk.len(), "the cap stopped it");
let clip = b.finish();
assert!(clip.truncated);
assert!(clip.paragraphs.iter().flatten().map(|s| s.text.len()).sum::<usize>() <= MAX_CLIP_TEXT_BYTES);
// HTML: escaping grows text up to sixfold; the writer still stops at its cap.
let big = ClipText { paragraphs: vec![vec![ClipSpan { text: "<".repeat(1 << 20), style: a.clone() }]; 8], truncated: false };
let (html, cut) = clip_html(&big);
assert!(cut, "the cut is reported");
assert!(html.len() <= MAX_CLIP_HTML_BYTES && html.ends_with("</p>\n"), "{}", html.len());
assert!(html.len() > MAX_CLIP_HTML_BYTES / 2);
// Empty paragraphs count against the cap too.
let empty = ClipText { paragraphs: vec![Vec::new(); MAX_CLIP_HTML_BYTES / 20], truncated: false };
let (html, cut) = clip_html(&empty);
assert!(cut && html.len() <= MAX_CLIP_HTML_BYTES, "{}", html.len());
}
}
+2
View File
@@ -20,6 +20,7 @@ mod as_docx;
mod as_html;
mod as_markdown;
mod as_rtf;
mod clip;
mod layout;
pub use as_docx::docx;
@@ -34,6 +35,7 @@ pub use as_markdown::markdown;
#[cfg(test)]
use as_markdown::{emphasis, paragraph};
pub use as_rtf::rtf;
pub use clip::{ClipBuilder, ClipSpan, ClipStyle, ClipText, MAX_CLIP_HTML_BYTES, MAX_CLIP_SPANS, MAX_CLIP_TEXT_BYTES, clip_html, font_family};
pub use layout::{Block, Cell, Image, Page, Table};
#[cfg(test)]
use layout::{LOOKUP_STEPS, MAX_COLS, body_size};
+5
View File
@@ -175,6 +175,11 @@ impl PageText {
.collect()
}
/// Index of the first glyph whose box holds a view-space point: the text under the pointer.
pub fn glyph_at(&self, x: f32, y: f32) -> Option<usize> {
self.glyphs.iter().position(|g| x >= g.rect[0] && x <= g.rect[2] && y >= g.rect[1] && y <= g.rect[3])
}
/// Index of the glyph nearest to a view-space point (for selection anchors).
///
/// A glyph whose drawn em box (`quad`) holds the point wins. Skewed or turned text has bounding
+15
View File
@@ -1965,3 +1965,18 @@ fn text_outside_the_crop_box_is_clipped() {
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
}
}
/// The glyph under a point is the first whose box holds it, edges included; between glyphs, none.
#[test]
fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() {
let text = PageText {
glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)],
line_of: vec![0, 0],
space_before: vec![false, false],
};
assert_eq!(text.glyph_at(5.0, 5.0), Some(0));
assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes");
assert_eq!(text.glyph_at(20.0, 10.0), Some(1), "an edge is in");
assert_eq!(text.glyph_at(25.0, 5.0), None);
assert_eq!(PageText::default().glyph_at(0.0, 0.0), None);
}
+11 -2
View File
@@ -245,6 +245,9 @@ impl PdfCraftApp {
last_copied_image: None,
last_copied_file: None,
system_clipboard: true,
html_clipboard_override: None,
#[cfg(not(target_arch = "wasm32"))]
os_clipboard: None,
clipboard_override: None,
attach_override: None,
duplicate_draft: None,
@@ -605,9 +608,15 @@ impl eframe::App for PdfCraftApp {
// opening a modal/palette, or returning to a window that lost focus.
let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused);
for (index, view) in self.views.iter_mut().enumerate() {
view.objects.set_input_blocked(blocked || self.active != Some(index));
if blocked || self.active != Some(index) {
let shut = blocked || self.active != Some(index);
view.objects.set_input_blocked(shut);
if shut {
view.auto_scroll.cancel();
// Edit text and Add text's drags end while their input is blocked rather than wait, to
// land wherever the pointer is once it opens again.
if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) {
view.cancel_edit_text_gestures();
}
}
}
// A field that refused its value this frame keeps the shortcut from saving or printing
+39 -1
View File
@@ -211,10 +211,48 @@ impl PdfCraftApp {
if !self.may_copy(id) {
return false;
}
ctx.copy_text(text);
// Built only when there is a clipboard to take it.
let html = if self.takes_html() {
self.views
.get(i)
.and_then(DocView::selected_glyphs)
.and_then(|(page, layer, glyphs)| self.session.get(id)?.rich_copy(page, &layer, &glyphs).ok())
// A capped copy would paste less than the plain text holds; paste that instead.
.filter(|copy| !copy.truncated)
.map(|copy| copy.html)
.filter(|html| !html.is_empty())
} else {
None
};
if !html.is_some_and(|html| self.put_rich_text(&html, &text)) {
ctx.copy_text(text);
}
true
}
/// Whether Copy with formatting has a clipboard to write: the system clipboard is on, and
/// this isn't the web build (or a test stands in for the clipboard).
fn takes_html(&self) -> bool {
self.system_clipboard && (self.html_clipboard_override.is_some() || cfg!(not(target_arch = "wasm32")))
}
/// Put `html` on the clipboard with `text` as its plain alternative, in one set (egui has no
/// HTML output, and its own text copy at the end of the frame would replace the HTML).
/// `false` when it wasn't, so the caller copies the plain text the usual way.
fn put_rich_text(&mut self, html: &str, text: &str) -> bool {
if let Some(set) = self.html_clipboard_override.as_mut() {
return set(html, text);
}
#[cfg(not(target_arch = "wasm32"))]
{
crate::os_clipboard::with_kept(&mut self.os_clipboard, |c| c.set().html(html, Some(text))).is_ok()
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
/// The clipboard text of a whole-document copy, once its pages are read; dropped with the
/// document it was copied from, and by any later copy (a text field's, a comment's), which
/// it must not replace. Run at the end of the frame, so it sees this frame's copies.
+71 -112
View File
@@ -255,22 +255,6 @@ impl ImageObb {
}
}
/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835).
pub(crate) fn crop_contains_point(
img: &pdfcraft_engine::PageImage,
crop: &crate::edit_text_ui::CropMode,
xf: &PageXform,
info: &DocInfo,
page: usize,
p: Pos2,
) -> bool {
let obb = ImageObb::from_image(img, xf, info, page);
let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page);
crate::edit_text_ui::quad_contains(obb.corners(), p)
|| crate::edit_text_ui::quad_contains(full_obb.corners(), p)
|| crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p)
}
/// The page display as it was before full screen, put back on leaving it
/// ([`DocView::enter_presentation`]).
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -676,8 +660,13 @@ pub struct DocView {
pub(crate) signature_drag: crate::signature_drag::SignatureDrag,
/// What the page modules claim for the cursor this frame; `document_area` sets it once.
pub(crate) cursor: crate::page_cursor::PageCursor,
/// Who the press the pages hold belongs to.
pub(crate) router: crate::page_router::PageRouter,
/// Who the press the pages hold belongs to. Only the page view reads it: it tells each page
/// module what the press is to its page (`PagePress`).
pub(in crate::canvas) router: crate::page_router::PageRouter,
/// What the last right-click on a page went to, and on which page (`None` off the pages, and for
/// Add text, which takes every right-click): the page's menu shows its entries when it has a menu
/// and is the one selected, or else the page's own.
pub(in crate::canvas) menu_for: Option<(usize, crate::page_router::Owner)>,
/// Escape cancelled the press the pages hold: the page ignores it until the button is up, so
/// its release neither clicks nor ends a drag.
pub(crate) press_cancelled: bool,
@@ -906,13 +895,20 @@ impl DocView {
if let Some(q) = &mut self.camera {
q.grab = None;
}
self.block_drag = None;
self.org_drag = None;
self.panel_drag = None;
self.selection_drag = false;
self.router.cancel();
self.auto_scroll.cancel();
self.prepare.cancel_pointer_gesture();
self.cancel_edit_text_gestures();
}
/// Abandon Edit text's pointer gestures (a paragraph moved or rewrapped, a picture moved,
/// resized, turned or cropped, added content moved, the objects' area selection or move), as
/// [`Self::cancel_pointer_gestures`] does: what they were previewing is not applied.
pub(crate) fn cancel_edit_text_gestures(&mut self) {
self.block_drag = None;
self.content.cancel_pointer_gesture();
self.objects.cancel_pointer_gesture();
if let Some(image) = &mut self.image_selection {
@@ -1060,6 +1056,7 @@ impl DocView {
signature_drag: Default::default(),
cursor: Default::default(),
router: Default::default(),
menu_for: None,
press_cancelled: false,
}
}
@@ -1272,11 +1269,10 @@ struct PageFrame<'a> {
}
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<Pos2> {
let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?;
/// Where this frame's right-click is, 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, on: impl FnOnce(Pos2) -> bool) -> Option<Pos2> {
let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && on(*p))?;
self.right_clicked = true;
Some(p)
}
@@ -1575,10 +1571,17 @@ 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) = frame.right_click_on(xf.rect) {
if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) {
comments::anchor(&pcx, p, view);
}
PageInput { consumed: true, area_tool: false, hover: None, released_over: false }
PageInput {
press: crate::page_router::PagePress::Elsewhere,
text_drag: false,
consumed: true,
area_tool: false,
hover: None,
released_over: false,
}
} else {
page_tool_input(ui, view, frame, &slot)
};
@@ -1638,8 +1641,6 @@ fn draw_pages(
prefs,
preparing,
selection_wheel,
ppp,
rects,
single_paging,
tool,
unobstructed,
@@ -1708,54 +1709,10 @@ fn draw_pages(
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. 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.
// comment, and Edit text's picture, too (below), as one on a page's empty part does. A
// selected Prepare field stays selected, and the menu is the page's there too.
view.comments.context_at = None;
let mut over_image = false;
if tool == QuickTool::EditText {
let pointer_pos = ptr.pos;
if let Some(p) = pointer_pos {
for &page_idx in visible_pages {
let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp);
if !r.intersects(visible) {
continue;
}
let xf =
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
let images = cached_edit_images(view, doc, page_idx).to_vec();
// Crop controls own the click even when another image overlaps them.
if view.image_selection.as_ref().is_some_and(|s| {
s.page == page_idx
&& s.crop
.as_ref()
.zip(images.get(s.index))
.is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p))
}) {
over_image = true;
break;
}
for (idx, img) in images.iter().enumerate().rev() {
let obb = ImageObb::from_image(img, &xf, info, page_idx);
let b = image_screen_rect(img, &xf, info, page_idx);
let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some());
let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) };
if hit {
over_image = true;
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
}
break;
}
}
if over_image {
break;
}
}
}
}
if !over_image && tool == QuickTool::EditText {
view.image_selection = None;
}
view.menu_for = None;
}
let mut frame = PageFrame {
env: *env,
@@ -1793,9 +1750,13 @@ fn draw_pages(
right_clicked,
..
} = frame;
// A right-click no page took deselects the comment, so the page's menu opens, not the comment's.
// A right-click no page took deselects the comment, so the page's menu opens, not the comment's,
// and with Edit text the picture (one on a page went to that page, `page_input`).
if resp.secondary_clicked() && !right_clicked {
view.comments.selected = None;
if tool == QuickTool::EditText {
view.image_selection = None;
}
}
let PageOut {
mut canvas_action,
@@ -1867,28 +1828,34 @@ fn draw_pages(
if view.continuous {
view.current = current;
}
// The menu of what the right-click went to (`menu_for`), or else the page's.
use crate::page_router::{Owner, PicturePart};
let menu_for = view.menu_for.map(|(_, owner)| owner);
// A right-click while a press is held leaves the selections alone, so the menu is its owner's only
// when that owner is the one selected.
let on_selected_field = match menu_for {
Some(Owner::PrepareField { field, widget }) => {
form.get(field).is_some_and(|f| view.prepare.selected.as_ref().is_some_and(|(name, w)| *name == f.name && *w == widget))
}
Some(Owner::PrepareCorner { .. }) => true,
_ => false,
};
// The row as it is now: in continuous view the current page may have changed above.
let row_on_screen =
shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union);
resp.context_menu(|ui| {
let is_crop_over_image = tool == QuickTool::EditText
&& view.image_selection.clone().is_some_and(|sel| {
let Some(ref crop) = sel.crop else { return false };
let pointer_pos = ptr.pos;
let Some(p) = pointer_pos else { return false };
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
let images = cached_edit_images(view, doc, sel.page).to_vec();
let Some(img) = images.get(sel.index) else { return false };
crop_contains_point(img, crop, &xf, info, sel.page, p)
});
if is_crop_over_image {
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
// Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is
// the crop's (#5835).
if matches!(menu_for, Some(Owner::Picture { part: PicturePart::Crop(_), .. })) {
egui::Popup::close_all(ui.ctx());
ui.close();
return;
}
// Preparing a form: the selected field's menu.
if preparing && let Some((name, wi)) = view.prepare.selected.clone() {
// Preparing a form: the selected field's menu, on a field.
if preparing
&& on_selected_field
&& let Some((name, wi)) = view.prepare.selected.clone()
{
// Several fields: Align, Center, Distribute, Set Fields to Same Size.
if !view.prepare.also.is_empty() {
use crate::prepare::Arrange as A;
@@ -1935,7 +1902,9 @@ fn draw_pages(
}
return;
}
if let Some((page, index)) = view.links.context {
if matches!(menu_for, Some(Owner::Link { .. }))
&& let Some((page, index)) = view.links.context
{
if ui.button(tl!("Properties…")).clicked() {
view.links.open_existing = Some((page, index));
ui.close();
@@ -1956,31 +1925,24 @@ fn draw_pages(
return;
}
let can_paste_image = env.can_paste_image;
let is_image_menu = tool == QuickTool::EditText
&& (ui.ctx().data_mut(|d| d.get_temp::<bool>(crate::edit_text_ui::image_menu_id())).unwrap_or(false)
|| view.image_selection.clone().is_some_and(|sel| {
let pointer_pos = ptr.pos;
let Some(p) = pointer_pos else { return false };
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
let images = cached_edit_images(view, doc, sel.page).to_vec();
let Some(img) = images.get(sel.index) else { return false };
let obb = ImageObb::from_image(img, &xf, info, sel.page);
let b = image_screen_rect(img, &xf, info, sel.page);
obb.contains(b, p)
}));
if is_image_menu && let Some(sel) = view.image_selection.clone() {
if tool == QuickTool::EditText
&& let Some((page, Owner::Picture { index, .. })) = view.menu_for
&& let Some(sel) = view.image_selection.clone().filter(|s| (s.page, s.index) == (page, index))
{
let images = cached_edit_images(view, doc, sel.page).to_vec();
// 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.
// Paste lands where the menu opened, on the picture's page: the right-click that opened
// it went to that page (`page_input`), and another page's point means nothing there.
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;
}
// A selected comment, or connected lines being drawn (Complete, Undo last point): the
// comment menu.
// The comment the right-click went to, when it is the one selected, or connected lines being
// drawn (Complete, Undo last point): the comment menu.
let on_selected_comment =
matches!(view.menu_for, Some((page, Owner::Comment { index, .. })) if view.comments.selected == Some((page, index)));
let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points());
if view.comments.selected.is_some() || drawing_points {
if on_selected_comment || drawing_points {
canvas_action = comments::context_menu(
ui,
view,
@@ -2037,9 +1999,6 @@ fn draw_pages(
};
default_page_context_menu(ui, page, at, perms, &mut page_action);
});
if !resp.context_menu_opened() {
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
}
// Copy comments / Paste comments from the page's menu or a Comments panel card.
let copy_comments = std::mem::take(&mut view.comments.copy_request);
let paste_comments_at = view.comments.paste_request.take();
+10 -7
View File
@@ -3,7 +3,7 @@
//! other image as a lossless PNG (with its transparency). The clipboard gets the file, as when a
//! file is copied in Finder or Explorer; Copy Image copies the pixels instead.
use egui::{Color32, CornerRadius, Pos2, Stroke};
use egui::{Color32, CornerRadius, Stroke};
use crate::PdfCraftApp;
use crate::canvas::{DocView, PageXform};
@@ -22,23 +22,26 @@ pub struct PickedImage {
const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0);
/// A click on page `page` (Select tool, not taken by anything else) selects the picture under
/// it, even with text drawn over it (scans with an OCR layer, captions); dragging or a double
/// click still selects text, and clears the picture.
/// it, even with text drawn over it (scans with an OCR layer, captions), with `pick` (a press
/// nobody holds: a link's click follows the link only); dragging or a double click still selects
/// text, and clears the picture.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
resp: &egui::Response,
xf: &PageXform,
page: usize,
info: &DocInfo,
doc: &pdfcraft_engine::Document,
pointer: Option<Pos2>,
ptr: &crate::page_pointer::PagePointer,
pick: bool,
view: &mut DocView,
) {
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return };
if resp.drag_started() || resp.double_clicked() || resp.triple_clicked() {
let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return };
if ptr.drag_started || resp.double_clicked() || resp.triple_clicked() {
view.picked_image = None;
return;
}
if !resp.clicked() {
if !resp.clicked() || !pick {
return;
}
let Some(info_page) = info.pages.get(page) else { return };
+190 -99
View File
@@ -12,7 +12,13 @@ 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.
/// What the press the pages hold is to this page.
pub(super) press: PagePress,
/// The press's drag is the page's text's: nobody holds it, or its owner lets its drag go (text
/// markup: a click selects it, a drag selects the text under it).
pub(super) text_drag: bool,
/// A tool, a field or a comment took the pointer this frame (its own press, or a gesture of the
/// active tool's): text selection and image pick leave it alone.
pub(super) consumed: bool,
/// Redact or Highlight draws boxes off text.
pub(super) area_tool: bool,
@@ -37,8 +43,9 @@ pub(crate) struct PageToolContext<'a> {
/// 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.
/// tool's: `tool_input`, and the tools still called here), or another page's. Edit text's group
/// paints from what lies under the pointer too (`hovered`). 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,
@@ -91,16 +98,47 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
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 Some(o) = ptr.pressed() {
// A press with Shift or ⌘ in Edit text is the objects', whatever lies under it
// (`object_ui::modified_hit`): the page's objects are read for it alone.
let adds = tool == QuickTool::EditText
&& can_modify
&& xf.rect.contains(o)
&& crate::object_ui::adds(ptr.modifiers)
&& !crate::object_ui::menu_open(ui, resp);
let objects = adds.then(|| view.objects.inventory(doc, i));
let at = hits.at(view, o, objects.as_ref());
// A press is the page's it lies on, or past its edge the one whose selected picture has its
// rotate knob or a grip there (crop mode's grips too).
let past_edge = |h: &crate::page_router::Hit| {
use crate::page_router::PicturePart;
matches!(h.owner, Owner::Picture { part: PicturePart::Knob | PicturePart::Corner(_) | PicturePart::Crop(_), .. })
};
if xf.rect.contains(o) || at.iter().any(past_edge) {
view.router.press(i, at, tool);
}
}
// A drag that the press's owner declines is nobody's once it starts.
if ptr.drag_started {
view.router.drag_began();
}
// 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, 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();
// release, a right-click's tool checks, Fill & Sign's preview, and Edit text's hover outlines.
let editing = matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify;
let wants_under = editing || 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, None)).unwrap_or_default();
let hovered = top(under.iter().cloned(), tool);
// A double-click on the objects selected together is the object's its press was on: the
// double-click leaves the group (`object_ui`), and added text opens for typing, a picture or a
// paragraph is the one taken (the user's ruling).
let press = match press {
PagePress::Mine(Owner::Objects { part: crate::page_router::ObjectsPart::Selection }) if resp.double_clicked() => {
view.router.under_objects().map_or(PagePress::Free, PagePress::Mine)
}
press => press,
};
let released_over = match press {
PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner),
_ => false,
@@ -110,12 +148,27 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
// 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 right-click is the page's it lies on, or past its edge the one whose crop grip lies there,
// as a press is (#5835): what a right-click there would go to is asked only off the page.
let right = frame.right_click_on(|p| {
use crate::page_router::PicturePart;
xf.rect.contains(p) || matches!(hits.context_at(view, p, &[]), Some(Owner::Picture { part: PicturePart::Crop(_), .. }))
});
let context = right.and_then(|p| right_click(&hits, &under, view, p, press_held));
// Edit text: a right-click on a picture selects it for its menu, and one on anything else on the
// page unselects it.
if right.is_some() && tool == QuickTool::EditText && can_modify && !press_held {
let picture = match context {
Some(Owner::Picture { index, .. }) => Some(index),
_ => None,
};
crate::edit_text_ui::picture_context(i, picture, view);
}
// 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 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());
&& (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && hovered.is_some());
tool_input(ui, view, frame, slot, &pcx, press, over_mark);
let on_field = if preparing && !placing {
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view);
@@ -132,61 +185,45 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
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]))
text.as_ref().is_some_and(|t| t.glyph_at(vx, vy).is_some())
};
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
// 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;
// Edit text's group: the objects selected together, added content, the paragraphs and the
// pictures, each told what the press is to this page. Edit text ignores input while a dialog,
// the palette, another window or another tab has it (`gate`), as before.
let gate = !(tool == QuickTool::EditText && view.objects.input_blocked());
if tool == QuickTool::EditText && can_modify && gate {
crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down);
}
let on_content = !on_objects && !placing && 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
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view);
let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText);
let ccx = crate::content_ui::ContentCx {
added,
press,
gate,
picks_up,
adding_text: tool == QuickTool::AddText,
edit_on_click: matches!(tool, QuickTool::AddText | QuickTool::EditText),
style: text_style,
};
// 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 on_content = !placing && editing_content && can_modify && crate::content_ui::page_input(cx, ccx, view);
// Add text acts on the page's own paragraphs: the one its press was on, until the button is up,
// and the one under the pointer. Edit text boxes every paragraph and picture.
let paragraph = |o: Option<Owner>| matches!(o, Some(Owner::Paragraph { .. }));
let source = tool == QuickTool::AddText && (paragraph(press.owner()) || paragraph(hovered.as_ref().map(|h| h.owner)));
let on_edit_text = (tool == QuickTool::EditText || source) && can_modify && {
let adding = (tool == QuickTool::AddText).then_some(added.as_slice());
let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding };
let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view);
let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate };
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
!others_own_pointer && (text_used || image_used)
text_used || image_used
};
if tool == QuickTool::EditText {
if others_own_pointer {
// A press the objects or added content took leaves the picture unselected; so does one
// while Edit text's input is blocked. Otherwise added content follows the picture selected.
if on_objects || on_content {
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
view.image_selection = None;
}
@@ -204,20 +241,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
}
// Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that
// 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.
// selection rectangle, added content) 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 }
PageInput { press, text_drag: !view.router.keeps_drag(), consumed, area_tool, hover, released_over }
}
/// 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<crate::page_router::Hit> {
let under = hits.at(view, p);
let under = hits.at(view, p, None);
if claim {
for h in &under {
view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone());
@@ -334,7 +370,8 @@ fn tool_input(
/// 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.
/// elsewhere; a Prepare field is selected; links and Edit text's pictures take theirs from what this
/// returns.
/// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the
/// comment and Prepare selections its press acts on are left alone until its release; links still
/// take theirs, as before.
@@ -342,16 +379,16 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu
let context = hits.context_at(view, p, under);
// Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click.
comments::anchor(hits.pcx, p, view);
// The menu is what the right-click went to (Add text's is the page's), when that is selected:
// under a held press, the selections stay as they are.
view.menu_for = context.filter(|_| !(hits.tool == QuickTool::AddText && hits.can_modify)).map(|o| (hits.page, o));
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.
// Add text takes every right-click on a page it can change, 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,
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(),
};
@@ -413,14 +450,26 @@ struct PageHits<'a> {
}
impl PageHits<'_> {
/// The hits at `p`, bottom up ([`Self::hits`]).
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
self.hits(view, p, false)
/// Edit text's crop mode is on for a picture on this page.
fn cropping(&self, view: &DocView) -> bool {
self.tool == QuickTool::EditText && self.can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == self.page && s.crop.is_some())
}
/// The hits at `p`, bottom up ([`Self::hits`]). For a press with Shift or ⌘ in Edit text, the
/// page's `objects` ([`crate::object_ui::ObjectSelection::inventory`]), which take it whole.
fn at(&self, view: &DocView, p: Pos2, objects: Option<&Result<Vec<pdfcraft_engine::EditableObject>, String>>) -> Vec<crate::page_router::Hit> {
self.hits(view, p, false, objects)
}
/// 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`]).
/// tool reaching comments, as a comment's menu shows whatever the tool, and a Prepare field
/// whatever the field tool. Links count for the tools that reach them (Select; the Link tool,
/// below). Edit text's paragraphs have no menu, and give way to the picture under them (the
/// user's ruling): with Edit text, a picture under a link or a paragraph takes the right-click.
/// Nor have added content or an open paragraph's frame: those give way to a picture under them,
/// and to nothing else. (Where objects are selected together, the page view unselects the picture
/// a right-click selects, and the menu is the page's.)
/// 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 (`under`, the hits at `p`), when that is
/// anything.
@@ -431,17 +480,36 @@ impl PageHits<'_> {
{
return Some(press.owner);
}
top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner)
let reached: Vec<_> = self
.hits(view, p, true, None)
.into_iter()
.filter(|h| (h.layer != Layer::Link || h.layer.reached_by(self.tool)) && !matches!(h.owner, Owner::Paragraph { .. }))
.collect();
let on_top = top(reached.iter().cloned(), QuickTool::Select)?.owner;
if self.tool == QuickTool::EditText && matches!(on_top, Owner::Added { .. } | Owner::Objects { part: crate::page_router::ObjectsPart::Frame })
{
let pictures = reached.into_iter().filter(|h| matches!(h.owner, Owner::Picture { .. }));
return Some(top(pictures, QuickTool::Select).map_or(on_top, |h| h.owner));
}
Some(on_top)
}
/// 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; 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.
/// and the comments that move). For a press with Shift or ⌘ in Edit text, only the objects
/// (`objects`, the page's); in crop mode, only the picture being cropped, which a right-click
/// reaches only on the crop.
/// 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<crate::page_router::Hit> {
fn hits(
&self,
view: &DocView,
p: Pos2,
context: bool,
objects: Option<&Result<Vec<pdfcraft_engine::EditableObject>, String>>,
) -> Vec<crate::page_router::Hit> {
let PageHits {
page,
xf,
@@ -461,13 +529,24 @@ impl PageHits<'_> {
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];
// A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`).
if let Some(objects) = objects {
return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect();
}
// Crop mode holds every press on its page (`edit_text_ui::crop_hit`). A right-click is the
// crop's only on the picture being cropped, its box or its grips (#5835), where it keeps crop
// mode and opens no menu; elsewhere it goes to what lies there, as without crop mode.
if self.cropping(view) {
use crate::page_router::{CropPart, PicturePart};
let crop = crate::edit_text_ui::crop_hit(xf, page, info, images, view, p);
let on_crop = crop.as_ref().is_some_and(|h| !matches!(h.owner, Owner::Picture { part: PicturePart::Crop(CropPart::Page), .. }));
if !context || on_crop {
return crop.into_iter().collect();
}
}
// A right-click reaches a Prepare field whatever the field tool, and while a table is set up,
// on a document that can be changed.
let (field_tool, prepare_allowed) = if context { (None, can_modify) } else { (field_tool, prepare_allowed) };
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
@@ -484,9 +563,9 @@ impl PageHits<'_> {
.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.
// Add text acts on the page's paragraphs only in their boxes, edges included (its click
// there finishes the box being typed and opens the paragraph, `ParagraphCx::adding`):
// 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);
(
@@ -511,7 +590,7 @@ impl PageHits<'_> {
/// 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 PageEnv { 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() {
@@ -520,7 +599,11 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page
&& !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 });
// The I-beam drops where a press would go to a link (ruling E: the tools that select a
// link's text show it there).
let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. }));
let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag };
text_pointer_input(ui, view, &text, pointer);
}
}
}
@@ -565,11 +648,14 @@ struct TextPointer<'a> {
page: usize,
xf: PageXform,
rect: Rect,
info: &'a DocInfo,
tool: QuickTool,
resp: &'a egui::Response,
ptr: PagePointer,
pointer: Pos2,
/// A press under the pointer would go to a link.
over_link: bool,
/// The press's drag is the page's text's ([`PageInput::text_drag`]).
text_drag: bool,
}
struct TextGesture<'a> {
@@ -584,16 +670,16 @@ struct TextGesture<'a> {
vy: f32,
over_text: bool,
over_link: bool,
text_drag: 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 TextPointer { page, xf, rect, tool, resp, ptr, pointer: screen_pointer, over_link, text_drag } = 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 over_text = text.glyph_at(vx, vy).is_some();
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 };
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, text_drag, column };
update_text_cursor(view, &gesture);
update_selection_on_press(ui, view, text, &gesture);
update_dragged_selection(view, text, &gesture);
@@ -613,7 +699,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText,
// 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() {
if gesture.ptr.pressed().is_some() && press_here && gesture.text_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);
@@ -628,7 +714,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText,
view.selection = Some(Selection::run(gesture.page, first, last));
}
}
if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() {
if gesture.ptr.drag_started && press_here && gesture.text_drag {
view.actions_dismissed = None;
view.selection_drag = true;
let origin = gesture.ptr.origin.unwrap_or(gesture.pointer);
@@ -649,7 +735,7 @@ fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 {
fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
let clicks = selection_click_count(view.clicks, gesture.tool);
if gesture.resp.dragged()
if gesture.resp.dragged_by(egui::PointerButton::Primary)
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
{
match &mut selection.span {
@@ -700,7 +786,7 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex
&& 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() {
} else if gesture.resp.clicked() && view.clicks < 2 && gesture.text_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),
@@ -740,8 +826,13 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
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);
// A press nobody holds picks the picture under it: a link's click follows the link only (ruling
// 5), though its drag or double-click still lets the picked picture go, as does a drag the page
// it is over first sees, whichever page it began on.
let pick_sees =
matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) || input.press == PagePress::Elsewhere && ptr.drag_started;
if tool == QuickTool::Select && pick_sees && !input.consumed {
crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, input.press == PagePress::Free, view);
}
crate::image_pick::drop_stale(doc, view);
crate::image_pick::paint(painter, &xf, i, info, view);
@@ -797,7 +888,7 @@ 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::Link { index }) = view.router.owner(i)
&& let PagePress::Mine(Owner::Link { index }) = input.press
&& input.released_over
&& let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index)
{
+20 -4
View File
@@ -11,7 +11,7 @@
//! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet:
//! the reading also takes whether one is open.
use egui::{Event, PointerButton, Pos2, Rect};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect};
/// A primary-button press or release, and where it happened.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -46,13 +46,19 @@ pub(crate) struct PagePointer {
pub primary_down: bool,
/// Some button is down, wherever it was pressed.
pub any_down: bool,
/// The modifier keys held at this frame's first primary press, or else now: a press with Shift
/// or ⌘ adds to a selection.
pub modifiers: Modifiers,
/// A primary press the pages hold turned into a drag this frame. egui's own `drag_started()`
/// counts every button, so a right-button drag would start what a left-button drag does.
pub drag_started: bool,
}
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, released, primary_down, any_down) = ui.input(|i| {
let (at, origin, edges, released, primary_down, any_down, modifiers) = ui.input(|i| {
(
i.pointer.interact_pos(),
i.pointer.press_origin(),
@@ -60,6 +66,7 @@ impl PagePointer {
last_release(&i.events),
i.pointer.primary_down(),
i.pointer.any_down(),
press_modifiers(&i.events).unwrap_or(i.modifiers),
)
});
// egui's own answer to "do the pages hold a press?": from the press on until the frame
@@ -75,9 +82,10 @@ 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(), released: None, primary_down, any_down };
return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down, modifiers, drag_started: false };
}
Self { pos, origin: press_origin, edges, released, primary_down, any_down }
let drag_started = resp.drag_started_by(PointerButton::Primary);
Self { pos, origin: press_origin, edges, released, primary_down, any_down, modifiers, drag_started }
}
/// Where the pointer is, when it is inside `r`.
@@ -108,6 +116,14 @@ fn first_press(edges: &[Edge]) -> Option<Pos2> {
})
}
/// The modifier keys held at the first primary press among `events`.
fn press_modifiers(events: &[Event]) -> Option<Modifiers> {
events.iter().find_map(|e| match e {
Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers),
_ => None,
})
}
/// Where the last primary release among `events` was, however many came before it.
fn last_release(events: &[Event]) -> Option<Pos2> {
events.iter().rev().find_map(|e| match e {
+84 -88
View File
@@ -207,7 +207,7 @@ 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, 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.
/// would use none of them, and PAGE_MODULES would list 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.
@@ -260,7 +260,11 @@ fn declares(line: &str) -> Option<&str> {
let mut rest = line;
loop {
let before = rest;
for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] {
// `pub(crate)`, `pub(super)`, `pub(in crate::canvas)` and the like.
if let Some(scoped) = rest.strip_prefix("pub(") {
rest = scoped.split_once(") ").map_or(rest, |(_, after)| after);
}
for prefix in ["pub ", "const ", "async ", "unsafe "] {
rest = rest.strip_prefix(prefix).unwrap_or(rest);
}
if rest == before {
@@ -271,6 +275,19 @@ fn declares(line: &str) -> Option<&str> {
rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end])
}
/// egui's drag start, which counts every button: a right-button drag would start what a left one
/// does. Page modules read `PagePointer::drag_started`, the primary button's, or ask for that button
/// (`drag_started_by`).
const ANY_BUTTON_DRAG: [&str; 1] = [".drag_started()"];
/// Functions in the page modules that start a drag of a widget of their own, not of the pages:
/// (file, function, why).
const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [
("canvas/organize.rs", "drag_pages", "Organize Pages drags thumbnails, not pages"),
("tools/fill_sign.rs", "signature_pad", "the signature dialog's drawing pad"),
("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"),
];
/// Ways to set egui's cursor.
const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("];
@@ -462,6 +479,14 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
}
}
}
problems.extend(misuses(
&ANY_BUTTON_DRAG,
&NOT_PAGE_DRAGS,
"starts a drag with any button: a right-button drag would start what a left one does. Read \
`PagePointer::drag_started` (the primary button's) instead. A widget of its own, not the pages? Add the \
function to NOT_PAGE_DRAGS in src/canvas/page_pointer/tests.rs, with why.",
"starts no drag of its own any more: remove it from NOT_PAGE_DRAGS.",
));
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
@@ -483,20 +508,22 @@ fn page_modules_claim_the_cursor_and_never_set_it() {
}
/// What decides, keeps or drops the press the pages hold.
const ROUTER_WRITES: [&str; 8] = [
const ROUTER_WRITES: [&str; 10] = [
"router.begin(",
"router.press(",
"router.drag_began(",
"router.settle(",
"router.cancel(",
"PageRouter::begin(",
"PageRouter::press(",
"PageRouter::drag_began(",
"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] = [
("canvas/page_input.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, and a declined drag lets it go"),
("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, automation's cancel"),
];
@@ -523,11 +550,12 @@ fn hit_test_calls(line: &str) -> Vec<String> {
/// 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). 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.
/// has a list of its own (`COMMENT_POINT_TESTERS`). Point tests by other names are not seen: what is
/// left of them paints (a picture's hover outline), keeps a behaviour no hit stands for (added
/// text's click with the tools that don't pick it up, crop mode's controls) or tells a tool that a
/// press nobody holds began on its page (the text layer's `press_here`, redaction'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] = [(
"canvas/page_input.rs",
"hits",
@@ -566,27 +594,38 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [
];
/// What reads who holds the press the pages hold.
const ROUTER_READS: [&str; 6] =
["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("];
const ROUTER_READS: [&str; 4] = ["router.press_for(", "router.keeps_drag(", "PageRouter::press_for(", "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
/// 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",
"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)"),
];
/// page and tells each module what the press is to its page (`PagePress`): modules never read the
/// router, and outside `canvas` the compiler refuses them (its methods are `pub(in crate::canvas)`).
/// An unused entry fails the test.
const ROUTER_READERS: [(&str, &str, &str); 1] = [(
"canvas/page_input.rs",
"page_tool_input",
"the page view: what the press is to each page (`press_for`), and whether its drag is the text's (`keeps_drag`)",
)];
/// Words a page module's functions are not named with: a function that grabs or owns a press decides
/// for itself whether the press is its own, which the page view tells it (`PagePress`).
const PRESS_CLAIMING_WORDS: [&str; 2] = ["grabs", "owns"];
/// The function `line` declares, when one of the words of its name is one of
/// [`PRESS_CLAIMING_WORDS`].
fn press_claiming_fn(line: &str) -> Vec<String> {
declares(line.trim_start())
.filter(|name| name.split('_').any(|word| PRESS_CLAIMING_WORDS.contains(&word)))
.map(str::to_owned)
.into_iter()
.collect()
}
/// Whether `line` takes the router whole (`let r = &view.router;`), which the checks by method
/// name would not see through: anything but a method call on it.
fn takes_the_router(line: &str) -> bool {
line.match_indices(".router")
.any(|(at, name)| line[at + name.len()..].chars().next().is_none_or(|c| c != '.' && !(c.is_alphanumeric() || c == '_')))
}
/// What ignores the press the pages hold until the button is up.
const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"];
@@ -595,34 +634,6 @@ const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"];
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).
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(",
"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::<Vec<_>>().join("\n");
code.contains("router.owner(")
&& !PAGE_HIT_CALLS.iter().any(|call| code.contains(call))
&& !uses(&code, "hit(")
&& !uses(&code, "body_hit(")
&& !uses(&code, "handle_hit(")
}
/// 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
@@ -653,7 +664,7 @@ fn page_modules_act_on_a_press_only_through_the_router() {
// 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::");
let router = uses(line, ".router.") || uses(line, "PageRouter::") || takes_the_router(line);
if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() }
});
problems.extend(
@@ -687,6 +698,13 @@ fn page_modules_act_on_a_press_only_through_the_router() {
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.",
));
// A module is told what the press is to its page; none decides it for itself.
problems.extend(page_module_matches(press_claiming_fn).into_iter().map(|(file, _, n, name)| {
format!(
"src/{file}:{n}: `fn {name}` decides whether a press is its module's. The page view tells each module what the \
press is to its page (`PagePress`): act on that, in a function named for what it does."
)
}));
problems.extend(misuses(
&PRESS_IGNORES,
&PRESS_IGNORERS,
@@ -694,19 +712,6 @@ fn page_modules_act_on_a_press_only_through_the_router() {
`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 [
("tools/content_ui.rs", "page_input"),
("tools/edit_text_ui.rs", "page_input"),
("tools/edit_text_ui/image.rs", "image_input"),
("tools/object_ui.rs", "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::<Vec<_>>().join("\n");
if !routed_press_handler(&body) {
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(
@@ -720,14 +725,6 @@ fn page_modules_act_on_a_press_only_through_the_router() {
"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(
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)| {
@@ -758,6 +755,15 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() {
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);
assert_eq!(press_claiming_fn(" pub(crate) fn fill_grabs(view: &DocView) -> bool {"), ["fill_grabs"]);
assert_eq!(press_claiming_fn("fn owns_press() {"), ["owns_press"]);
assert_eq!(press_claiming_fn(" pub(in crate::canvas) fn link_grabs(view: &DocView) {"), ["link_grabs"]);
assert!(press_claiming_fn("fn dropdowns() {").is_empty(), "a word that ends in one");
assert_eq!(declares("pub(in crate::canvas) fn drag_began(&mut self) {"), Some("drag_began"));
assert!(takes_the_router("let r = &view.router;") && takes_the_router("keep(view.router, 1)"));
assert!(!takes_the_router("view.router.press_for(i)") && !takes_the_router("view.routers.len()"));
assert!(press_claiming_fn("fn holds(&self, page: usize) -> bool {").is_empty());
assert!(press_claiming_fn("let grabs = 1;").is_empty());
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),
@@ -777,13 +783,3 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() {
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);"));
assert!(!routed_press_handler("// view.router.owner(page)\nlet owner = None;"));
assert!(!routed_press_handler("let owner = view.router.owner(page);\nlet again = cx.hit(origin);"));
for call in PAGE_HIT_CALLS {
assert!(!routed_press_handler(&format!("let owner = view.router.owner(page);\n{call}origin);")), "{call}");
}
}
+91 -26
View File
@@ -6,10 +6,13 @@
//! 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. 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.
//! back halfway; an owner that declines the drag (text markup, a link with the Link tool) lets it
//! go as it starts, and the drag is nobody's. 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. Only the page view
//! reads the router: its methods are `canvas`'s alone.
use egui::CursorIcon;
use pdfcraft_engine::ObjectTarget;
use crate::QuickTool;
use crate::page_cursor::Layer;
@@ -45,10 +48,31 @@ pub(crate) enum Owner {
pub(crate) enum PicturePart {
/// The picture: a click selects it, a drag moves the selected one.
Body,
/// A picture lying over the selected one, within the selected one's frame: a click selects the
/// one on top, a drag moves the selected one.
OverSelected,
/// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints).
Corner(usize),
/// The selected picture's rotate knob: a drag turns it.
Knob,
/// The picture being cropped, which holds every press on its page.
Crop(CropPart),
}
/// What of a picture being cropped a hit is on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CropPart {
/// A grip of the box kept, in `crop_handles` order (four corners, then the top, right, bottom
/// and left edge midpoints): a drag moves that side or corner.
Grip(usize),
/// The box kept: a drag moves it over the picture.
Keep,
/// The picture outside the box kept, shown or cut away: a press there moves nothing, and a
/// right-click is the crop's, as on the box and its grips (#5835).
Cut,
/// The rest of the page, its Done and Cancel controls among it: a press there is crop mode's,
/// and moves nothing.
Page,
}
/// What of the objects a hit is on.
@@ -58,6 +82,15 @@ pub(crate) enum ObjectsPart {
Selection,
/// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it.
Frame,
/// A press with Shift or ⌘ (Ctrl): it adds the object under it to the objects selected
/// together, or takes it out of them, or on none starts an area selection that adds to them.
Toggle(Option<ObjectTarget>),
/// A press with Shift or ⌘ while text being typed is open (a new box, or a paragraph with
/// changes), which must be finished or discarded first: it is held until the button is up, and
/// selects nothing.
Held,
/// A press with Shift or ⌘ on a page whose objects can't be selected together: it says so.
Refused,
}
/// The part of a paragraph a hit is on.
@@ -102,7 +135,7 @@ impl Hit {
/// The hit that wins among `hits` with `tool`: the highest layer the tool reaches, the last of
/// equals (modules are asked bottom up, in paint order).
pub(crate) fn top(hits: impl IntoIterator<Item = Hit>, tool: QuickTool) -> Option<Hit> {
pub(in crate::canvas) fn top(hits: impl IntoIterator<Item = Hit>, tool: QuickTool) -> Option<Hit> {
hits.into_iter().filter(|h| h.layer.reached_by(tool)).fold(None, |best: Option<Hit>, h| match best {
Some(b) if b.layer > h.layer => Some(b),
_ => Some(h),
@@ -115,13 +148,15 @@ pub(crate) enum PagePress {
/// 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 the page view does not hold is down (one that began
/// No button is down, or the press began on this page and nobody's hit won it, or its owner let
/// its drag go ([`PageRouter::drag_began`]): 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 (`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).
/// The press began on another page, or off the pages (between them, or beside one) where no
/// page has anything under it.
Elsewhere,
}
@@ -137,9 +172,12 @@ impl PagePress {
/// A press the pages hold, and who it belongs to.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Capture {
struct Capture {
pub owner: Owner,
pub declines_drag: bool,
/// What lay on top at the press with the objects selected together aside: a double-click on
/// them reaches it.
under_objects: Option<Owner>,
}
/// The press the pages hold, from its frame until the button is up.
@@ -153,25 +191,28 @@ pub(crate) struct PageRouter {
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) {
pub(in crate::canvas) 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).
/// A press on `page`, or past its edge where `hits` are under it (the selected picture's rotate
/// knob): `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<Item = Hit>, tool: QuickTool) {
pub(in crate::canvas) fn press(&mut self, page: usize, hits: impl IntoIterator<Item = Hit>, tool: QuickTool) {
if self.began != Some(None) {
return;
}
self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag });
let hits: Vec<Hit> = hits.into_iter().collect();
let under_objects = top(hits.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool).map(|h| h.owner);
self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag, under_objects });
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 {
pub(in crate::canvas) fn press_for(&self, page: usize) -> PagePress {
match (self.began, &self.capture) {
(None, _) => PagePress::Free,
(Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner),
@@ -180,19 +221,28 @@ impl PageRouter {
}
}
/// Who holds the press on `page`.
pub(crate) fn owner(&self, page: usize) -> Option<Owner> {
self.press_for(page).owner()
/// What lay on top at the press with the objects selected together aside.
pub(in crate::canvas) fn under_objects(&self) -> Option<Owner> {
self.capture.as_ref().and_then(|c| c.under_objects)
}
/// Whether the press is held by someone who keeps its drag: then the page's text is not
/// selected by it.
pub(crate) fn keeps_drag(&self) -> bool {
pub(in crate::canvas) fn keeps_drag(&self) -> bool {
self.capture.as_ref().is_some_and(|c| !c.declines_drag)
}
/// The press turned into a drag: an owner that declines its drag lets it go, and from then on
/// the press is nobody's (a drag across a highlight selects the text under it; with the Link
/// tool, a drag from a link draws a new one). Its click stayed the owner's.
pub(in crate::canvas) fn drag_began(&mut self) {
if self.capture.as_ref().is_some_and(|c| c.declines_drag) {
self.capture = None;
}
}
/// The press is over once the button is up and its release has been seen.
pub(crate) fn settle(&mut self, any_down: bool, edges: bool) {
pub(in crate::canvas) fn settle(&mut self, any_down: bool, edges: bool) {
if !any_down && !edges {
self.capture = None;
self.began = None;
@@ -201,7 +251,7 @@ impl PageRouter {
/// 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) {
pub(in crate::canvas) fn cancel(&mut self) {
self.capture = None;
self.began = None;
}
@@ -230,16 +280,16 @@ mod tests {
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");
assert_eq!(r.press_for(2).owner(), Some(hit(Layer::Comment, 7).owner));
assert_eq!(r.press_for(1).owner(), None, "only on its page");
assert!(r.keeps_drag());
r.settle(false, true);
assert!(r.owner(2).is_some(), "the release frame still belongs to the press");
assert!(r.press_for(2).owner().is_some(), "the release frame still belongs to the press");
r.settle(false, false);
assert_eq!(r.owner(2), None);
assert_eq!(r.press_for(2).owner(), None);
r.begin();
r.press(0, [], QuickTool::Select);
assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody");
assert_eq!(r.press_for(0).owner(), None, "a press nobody claims is held by nobody");
assert!(!r.keeps_drag());
}
@@ -281,8 +331,23 @@ mod tests {
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());
assert!(r.press_for(0).owner().is_some() && !r.keeps_drag());
r.cancel();
assert_eq!(r.owner(0), None);
assert_eq!(r.press_for(0).owner(), None);
}
/// A drag the owner declines is nobody's once it starts, on the page it began on; a drag the
/// owner keeps stays its own.
#[test]
fn a_declined_drag_is_nobodys_once_it_starts() {
let mut r = PageRouter::default();
r.begin();
r.press(1, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select);
r.drag_began();
assert_eq!((r.press_for(1), r.press_for(0)), (PagePress::Free, PagePress::Elsewhere));
r.begin();
r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select);
r.drag_began();
assert_eq!(r.press_for(1), PagePress::Mine(hit(Layer::Comment, 2).owner));
}
}
+16
View File
@@ -93,6 +93,22 @@ impl DocView {
Some(s.text(t)).filter(|x| !x.is_empty())
}
/// The selection's page, its text layer and the selected glyphs in copy order, for Copy with
/// formatting. `None` after Select All of the document and for a column selection, which
/// copy plain text (a column's rows and tab-separated cells are not paragraphs).
pub(crate) fn selected_glyphs(&self) -> Option<(usize, Arc<PageText>, Vec<usize>)> {
if self.all_text {
return None;
}
let s = self.selection?;
let t = self.texts.get(&s.page)?;
let glyphs = match s.span {
Span::Run { anchor, head } => Selection::range(anchor, head).take(t.glyphs.len()).collect(),
Span::Column { .. } => return None,
};
Some((s.page, t.clone(), glyphs))
}
/// Select every page without navigating; shared by Organize and the Pages sidebar.
pub fn select_all_pages(&mut self) -> bool {
self.selected = (0..self.page_count).collect();
+63
View File
@@ -391,3 +391,66 @@ fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alo
assert!(app.save_active(SaveTarget::InPlace));
let _ = std::fs::remove_dir_all(&dir);
}
/// #6440: recolouring gray or CMYK text in its open box drops the ink at once. Finishing the box
/// then adds no empty Undo step (the draft equals the item the page reads back), picking the
/// ink's own look again in the same box paints RGB rather than bringing the ink back, and new
/// boxes don't take the ink.
#[test]
fn recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step() {
// 100 K text, open in its box.
let open = || {
let mut app = crate::PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("a.pdf", None, one_page_pdf()).unwrap();
let mut text = pdfcraft_engine::AddedText { rect: [20.0, 200.0, 180.0, 220.0], text: "Proof".into(), ..Default::default() };
text.set_ink(Some(pdfcraft_engine::TextInk::Cmyk([0.0, 0.0, 0.0, 1.0])));
assert!(app.apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
let stored = item(&app);
assert!(stored.painted_ink().is_some());
app.views[0].content.draft = Some(crate::content_ui::TextDraft {
page: 0,
index: Some(0),
rect: stored.rect,
text: stored.text.clone(),
style: stored,
focus: true,
selection: None,
typing_from: None,
});
app
};
fn item(app: &crate::PdfCraftApp) -> pdfcraft_engine::AddedText {
match &app.session.get(app.views[0].id).unwrap().added()[0].content {
pdfcraft_engine::AddedContent::Text(t) => t.clone(),
other => panic!("{other:?}"),
}
}
// The box's style with another colour, set as the Format panel hands it over.
let recolour = |app: &mut crate::PdfCraftApp, color: [f64; 3]| {
let style = app.views[0].content.draft.as_ref().map(|d| d.style.clone()).unwrap();
app.restyle_draft(0, pdfcraft_engine::AddedText { color, ..style });
};
let finish = |app: &mut crate::PdfCraftApp| {
let added = app.session.get(app.views[0].id).unwrap().added().to_vec();
app.views[0].content.finish_draft(&added)
};
// Red, then Done.
let mut app = open();
recolour(&mut app, [1.0, 0.0, 0.0]);
assert_eq!(item(&app).ink, None, "the page paints red in RGB");
let finished = finish(&mut app);
assert!(finished.is_none(), "an empty Undo step: {finished:?}");
assert_eq!(app.text_style.ink, None, "new boxes don't take the ink");
// Red, then black in the same box: RGB black, not the 100 K back.
let mut app = open();
recolour(&mut app, [1.0, 0.0, 0.0]);
recolour(&mut app, [0.0; 3]);
let now = item(&app);
assert_eq!((now.ink, now.color), (None, [0.0; 3]));
assert_eq!(app.text_style.ink, None);
let finished = finish(&mut app);
assert!(finished.is_none(), "an empty Undo step: {finished:?}");
}
+11
View File
@@ -254,6 +254,10 @@ struct PendingOpen {
display_override: Option<(canvas::PageLayout, bool)>,
}
/// Puts `(html, plain_text)` on a clipboard; `false` when it couldn't, and plain-text Copy is
/// used instead.
pub type HtmlClipboard = Box<dyn FnMut(&str, &str) -> bool>;
pub struct PdfCraftApp {
/// Tools side and pane widths; which panes are open is `left_open` and `right`.
pub side_panels: side_panels::SidePanels,
@@ -592,6 +596,13 @@ pub struct PdfCraftApp {
/// system clipboard as a file when `system_clipboard` is on.
pub last_copied_file: Option<(String, Vec<u8>)>,
pub system_clipboard: bool,
/// Where Copy with formatting puts its HTML and the plain alternative instead of the system
/// clipboard, when `system_clipboard` is on (tests).
pub html_clipboard_override: Option<HtmlClipboard>,
/// The system clipboard Copy with formatting writes to, kept open so that X11 goes on
/// serving the copy after the frame.
#[cfg(not(target_arch = "wasm32"))]
os_clipboard: Option<arboard::Clipboard>,
/// What ⌘V reads instead of the system clipboard (tests).
pub clipboard_override: Option<create_ui::Clip>,
/// Attach file: the file to attach instead of asking (tests, automation).
+15
View File
@@ -17,6 +17,21 @@ pub(crate) fn with<T>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboa
f(&mut clipboard)
}
/// [`with`] on the clipboard kept in `kept`, created on first use, instead of a fresh one. On
/// X11 the copied data is served only while its `Clipboard` lives, so a copy made through a
/// kept clipboard stays pasteable after the call returns.
pub(crate) fn with_kept<T>(
kept: &mut Option<arboard::Clipboard>,
f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> Result<T, arboard::Error> {
let _turn = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
let clipboard = match kept {
Some(clipboard) => clipboard,
None => kept.insert(arboard::Clipboard::new()?),
};
f(clipboard)
}
/// Nonempty text supersedes the app's cached image, even if another clipboard format also exists.
pub(crate) fn has_text() -> bool {
with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty())
+5 -1
View File
@@ -406,7 +406,11 @@ impl PdfCraftApp {
/// style at once and stays open. Text already on the page is updated with what has been typed,
/// as one undoable change; new text waits until it is finished. Text typed after this styling
/// starts from it too.
pub(crate) fn restyle_draft(&mut self, index: usize, style: pdfcraft_engine::AddedText) {
pub(crate) fn restyle_draft(&mut self, index: usize, mut style: pdfcraft_engine::AddedText) {
// A gray or CMYK ink the colour no longer shows goes now (#6440): kept, the draft would
// differ from the item the page reads back without it, and a later pick of the ink's
// look would bring it back.
style.drop_stale_ink();
let Some(view) = self.views.get_mut(index) else { return };
// Text the standard fonts can't draw stays in the box, unwritten, until it is corrected (#125).
let blocked = view.content.blocked().is_some();
+2 -1
View File
@@ -266,7 +266,8 @@ pub(crate) fn panel(
let swatch = colour_button(ui, t, look.color);
egui::Popup::menu(&swatch).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) {
look.color = picked;
// Drops a gray or CMYK ink the new colour isn't the look of (#6440).
look.set_color(picked);
ui.close();
}
});
+2 -2
View File
@@ -102,14 +102,14 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe
// 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())
if (ptr.drag_started || resp.clicked())
&& press == PagePress::Free
&& 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));
}
if resp.drag_started()
if ptr.drag_started
&& let PagePress::Mine(Owner::CameraCorner { corner }) = press
&& let Some(q) = view.camera.as_mut().filter(|q| q.page == page)
{
+18 -9
View File
@@ -12,8 +12,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress};
/// 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. 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; the Select tool's click there still unselects. Returns `true` when
/// (`taken`: Prepare, added content), the commenting tools leave 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);
@@ -227,7 +227,7 @@ fn select_input(
};
let cv = &mut view.comments;
let mut consumed = false;
if resp.drag_started()
if cx.ptr.drag_started
&& let (Some((a, part)), Some(o)) = (owner, origin)
{
match part {
@@ -272,9 +272,17 @@ fn select_input(
CommentPart::Body => {}
}
}
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) {
let live =
matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page);
if live {
consumed = true;
}
// A move, resize or reshape ends where its drag stops. One whose release the pages missed (the
// button is up, and no drag stopped here) ends with nothing applied: where it came up is not
// known, and the pointer may be anywhere by now.
if live && !resp.drag_stopped() && !cx.ptr.primary_down {
cv.gesture = None;
}
if resp.drag_stopped()
&& let Some(p) = pointer
{
@@ -374,7 +382,8 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p:
/// Text asks for the replacement.
pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {
let QuickTool::Comment(tool) = cx.tool else { return };
if tool == CommentTool::ReplaceText && cx.allowed && (resp.drag_stopped() || resp.double_clicked()) {
let finished = resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked();
if tool == CommentTool::ReplaceText && cx.allowed && finished {
if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) {
view.clear_selection();
let last = quads.last().copied().unwrap_or_default();
@@ -384,7 +393,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut
}
return;
}
if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) {
if tool.markup().is_none() || !cx.allowed || !finished {
return;
}
if let Some(kind) = tool.markup()
@@ -429,7 +438,7 @@ impl Gestures<'_> {
if over_page {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
if cx.ptr.drag_started
&& pressed_here
&& let Some(o) = origin
{
@@ -567,7 +576,7 @@ impl Gestures<'_> {
if over_page {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
if cx.ptr.drag_started
&& pressed_here
&& let Some(o) = origin
{
@@ -648,7 +657,7 @@ impl Gestures<'_> {
// A text box also takes a drag, as in Acrobat: the box is drawn at that size and its
// text wraps inside it (#2669).
if tool == CommentTool::TextBox {
if resp.drag_started()
if cx.ptr.drag_started
&& pressed_here
&& let Some(o) = origin
{
+86 -38
View File
@@ -11,7 +11,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;
use crate::widgets;
@@ -108,6 +108,10 @@ pub struct ContentView {
/// Select the item an add creates (the page's item count before it).
pub select_added: Option<(usize, usize)>,
grab: Option<(usize, Grab, Pos2)>,
/// Where the press nobody holds began, on which page: Add text's click starts its new box there.
pressed_at: Option<(usize, Pos2)>,
/// A button is down on the pages this frame (for [`keys`], which runs after them).
held: bool,
undrawable: UndrawableMemo,
layout: Option<(AddedText, f32, f64, [f64; 4])>,
}
@@ -116,6 +120,12 @@ impl ContentView {
/// Forget an in-progress pointer grab for content editing.
pub(crate) fn cancel_pointer_gesture(&mut self) {
self.grab = None;
self.pressed_at = None;
}
/// An added item is being moved or resized on `page`.
pub(crate) fn moving_on(&self, page: usize) -> bool {
self.grab.is_some_and(|(p, ..)| p == page)
}
pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option<Edit> {
@@ -372,17 +382,34 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added],
Some(Hit::new(Owner::Added { index: *index, corner: None }, Layer::Placed, egui::CursorIcon::Move))
}
/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it
/// used the input.
pub(crate) fn page_input(
cx: PageToolContext<'_>,
added: &[Added],
adding_text: bool,
edit_on_click: bool,
style: &AddedText,
view: &mut DocView,
) -> bool {
/// What added content is told on one page.
#[derive(Clone, Copy)]
pub(crate) struct ContentCx<'a> {
pub added: &'a [Added],
/// What the press the pages hold is to this page. With the Edit panel open, a press on added
/// text is its own with the tools that pick it up ([`hit`]).
pub press: PagePress,
/// Edit text's input is open: no dialog, palette, other window or other tab has it.
pub gate: bool,
/// The tool picks added content up (Select, Add text, Edit text): a drag moves it or resizes it.
pub picks_up: bool,
/// The Add text tool: a click nobody holds starts a new box where it was pressed.
pub adding_text: bool,
/// A click on added text opens it for typing (Add text, Edit text); otherwise a double-click does.
pub edit_on_click: bool,
/// The style of new text.
pub style: &'a AddedText,
}
/// Pointer input on one page in Edit mode, on the presses that are added content's ([`hit`]) and,
/// with Add text, the clicks nobody holds. `true` when it used the input.
pub(crate) fn page_input(cx: PageToolContext<'_>, ccx: ContentCx<'_>, view: &mut DocView) -> bool {
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let ContentCx { added, press, gate, picks_up, adding_text, edit_on_click, style } = ccx;
view.content.held = ptr.any_down;
if !gate {
return false;
}
let mut consumed = false;
let pointer = ptr.pos;
let cv = &mut view.content;
@@ -412,19 +439,28 @@ pub(crate) fn page_input(
}
return consumed;
}
// Where a press nobody holds begins on this page: Add text's click starts its box there.
if let Some(o) = ptr.pressed().filter(|_| press != PagePress::Elsewhere) {
cv.pressed_at = (press == PagePress::Free).then_some((page, o));
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed };
let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a));
let selected_rect = cv
.selected
.filter(|s| s.0 == page)
.and_then(|(_, si)| items.iter().find(|(i, _)| *i == si))
.map(|(_, a)| screen_rect(xf, info, page, a.content.rect()));
let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0));
if adding_text && hit.is_none() {
// What the press was on ([`hit`]): an item, or a corner of the selected one.
let owned = match press {
PagePress::Mine(Owner::Added { index, corner }) => Some((index, corner)),
_ => None,
};
// The tools that don't pick added content up have no hit on it: with the Edit panel open their
// clicks on added text still select it and their presses there are its, by this test at the
// pointer, as before.
let under = (!picks_up)
.then(|| items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, _)| *i))
.flatten();
let target = if picks_up { owned } else { under.map(|i| (i, None)) };
if adding_text {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
// A new box where nothing drawn over the page holds the press.
if resp.clicked() && view.router.owner(page).is_none() {
if resp.clicked() && press == PagePress::Free {
// Text that can't be drawn stays where it is being typed (#125).
if cv.hold_blocked() {
return true;
@@ -434,7 +470,9 @@ pub(crate) fn page_input(
let typed = cv.draft.take().and_then(|d| finish(cv, d, added));
// The Format panel styles the new text, not the box just kept.
(cv.selected, cv.select_added) = (None, None);
let at = to_display(xf, info, page, p);
// Where the click was pressed, as every owner acts where its press was.
let start = cv.pressed_at.take().filter(|(pg, _)| *pg == page).map_or(p, |(_, o)| o);
let at = to_display(xf, info, page, start);
let mut style = style.clone();
style.text.clear();
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
@@ -445,29 +483,31 @@ pub(crate) fn page_input(
if typed.is_some() {
view.pending_edit = typed;
}
return consumed;
}
return consumed;
}
// A drag on what the press was on ([`hit`]): a corner resizes the selected item, the item
// itself moves.
if resp.drag_started() {
// A drag on what the press was on: a corner resizes the selected item, the item itself moves,
// with the tools that pick added content up.
if ptr.drag_started {
let origin = ptr.origin.unwrap_or(p);
match view.router.owner(page) {
Some(Owner::Added { corner: Some((left, top)), .. }) => {
match owned.filter(|_| picks_up) {
Some((_, Some((left, top)))) => {
cv.grab = Some((page, Grab::Corner(left, top), origin));
consumed = true;
}
Some(Owner::Added { index, corner: None }) => {
Some((index, None)) => {
cv.selected = Some((page, index));
cv.grab = Some((page, Grab::Move, origin));
consumed = true;
}
_ => {}
None => {}
}
return consumed;
return consumed || owned.is_some();
}
if (resp.double_clicked() || edit_on_click && resp.clicked() && corner.is_none())
&& let Some((i, a)) = hit
// A double-click, or with `edit_on_click` a click, opens the text the press was on for typing.
if (resp.double_clicked() || edit_on_click && resp.clicked())
&& let Some((i, None)) = target
&& let Some((_, a)) = items.iter().find(|(index, _)| *index == i)
&& let AddedContent::Text(t) = &a.content
{
if cv.hold_blocked() {
@@ -493,17 +533,21 @@ pub(crate) fn page_input(
consumed = true;
return consumed;
}
// A click selects the item its press was on, keeps the selected one when on a corner of it, and
// on anything else on this page (but the objects selected together, which take the selection
// themselves) leaves it unselected.
if resp.clicked() {
match hit {
Some((i, _)) => {
match (target, press) {
(Some((i, None)), _) => {
cv.selected = Some((page, i));
consumed = true;
}
None if corner.is_none() => cv.selected = None,
None => consumed = true,
(Some((_, Some(_))), _) => consumed = true,
(None, PagePress::Elsewhere | PagePress::Mine(Owner::Objects { .. })) => {}
(None, _) => cv.selected = None,
}
}
consumed || hit.is_some() && resp.is_pointer_button_down_on()
consumed || owned.is_some() || under.is_some() && resp.is_pointer_button_down_on()
}
/// Outlines of added items (hover and selection with handles) and the box being dragged, the
@@ -593,7 +637,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_act
return;
}
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
if del && let Some((page, index)) = view.content.selected.take() {
// Not while a button is down: the press held would then name another item.
if del
&& !view.content.held
&& let Some((page, index)) = view.content.selected.take()
{
view.pending_edit = Some(Edit::DeleteContent { page, index });
} else if esc {
view.content.selected = None;
+57 -21
View File
@@ -14,17 +14,17 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::page_router::{Hit, Owner, ParagraphPart};
use crate::page_router::{Hit, Owner, PagePress, ParagraphPart};
mod background;
mod image;
mod layout;
pub(crate) use background::TextBackground;
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id};
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection};
pub(crate) use image::{
ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_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, picture_context,
picture_keys, quad_contains, user_to_screen,
};
#[cfg(test)]
use image::{rotate_knob, turn_degrees, turn_degrees_delta};
@@ -659,10 +659,26 @@ fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], bo
});
}
/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns
/// `true` when the pointer was used.
pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, allow_interaction: bool) -> bool {
/// What Edit text's paragraphs are told on one page.
#[derive(Clone, Copy)]
pub(crate) struct ParagraphCx<'a> {
/// What the press the pages hold is to this page.
pub press: PagePress,
/// Edit text's input is open: no dialog, palette, other window or other tab has it.
pub gate: bool,
/// What a press under the pointer would take (the top hit there): the paragraph outlined.
pub hover: Option<&'a Hit>,
/// With Add text, the page's added content: a click on a paragraph finishes the text being
/// typed before it opens the paragraph.
pub adding: Option<&'a [pdfcraft_engine::Added]>,
}
/// Lines and their screen boxes for a page: the paragraph under the pointer is outlined, and the
/// press that is a paragraph's (`press`) moves it, rewraps it or, a click, opens it in the editor.
/// Returns `true` when the pointer was used.
pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], pcx: ParagraphCx<'_>, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx;
let ParagraphCx { press, gate, hover, adding } = pcx;
let info = &doc.info;
if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) {
view.block_drag = None;
@@ -679,7 +695,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
}
// A box a switching click asked for opens now, once the edit queued with that click has
// applied and `lines`/`boxes` are the document's new inventory (#2022).
if allow_interaction
if gate
&& active.is_none()
&& let Some((open_page, key)) = view.pending_text_open.clone()
&& open_page == page
@@ -691,7 +707,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
None => view.pending_text_open = None,
}
}
if !allow_interaction {
if !gate {
return false;
}
let upright = has_edges(info, page);
@@ -750,12 +766,15 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
// What it grabs is what the press was on ([`hit`]), not what is under the pointer now: egui
// only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a
// quick flick has left the edge by then.
let owner = match view.router.owner(page) {
Some(Owner::Paragraph { block, part }) => Some((block, part)),
let owner = match press {
PagePress::Mine(Owner::Paragraph { block, part }) => Some((block, part)),
_ => None,
};
if resp.drag_started()
// Add text holds text that can't be drawn where it is being typed (#125).
let held_draft = adding.is_some() && view.content.blocked().is_some();
if ptr.drag_started
&& active.is_none()
&& !held_draft
&& let Some(o) = ptr.origin
&& let Some((block, part)) = owner
{
@@ -767,18 +786,35 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip });
return true;
}
let Some(p) = ptr.within(xf.rect) else { return false };
// The paragraph under the pointer: outlined, with its width handles when closed.
let Some((hovered, _)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hovered) {
painter.rect_stroke(boxes[hovered], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
if upright && active.is_none() {
draw_handles(boxes[hovered]);
// The paragraph under the pointer, where a press would take it: outlined, with its width
// handles when closed. Not while the objects selected together or added content being moved
// hold the page.
let hovered = match hover.map(|h| h.owner) {
Some(Owner::Paragraph { block, .. }) => Some(block),
_ => None,
};
if let Some(b) = hovered.filter(|_| !view.objects.holds(page) && !view.content.moving_on(page)).and_then(|h| boxes.get(h).map(|b| (h, *b))) {
if active != Some(b.0) {
painter.rect_stroke(b.1, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
if upright && active.is_none() {
draw_handles(b.1);
}
}
// A click opens the paragraph the press was on.
let Some((hit, _)) = owner else { return true };
let Some((hit, _)) = owner else { return hovered.is_some() };
if resp.clicked() {
// Add text: text that can't be drawn stays where it is being typed (#125), and text being
// typed is finished first.
if let Some(added) = adding {
if view.content.hold_blocked() {
return true;
}
if let Some(edit) = view.content.finish_draft(added) {
view.pending_edit = Some(edit);
}
view.content.selected = None;
}
view.image_selection = None;
// A click that moves the editor to another paragraph applies the open one first, so its
// typed text is not dropped with the switched-away editor (#2022). A click on the open
+199 -197
View File
@@ -2,7 +2,7 @@ use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES,
use crate::canvas::{ImageObb, PainterExt, image_screen_rect};
use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner, PicturePart};
use crate::page_router::{CropPart, Hit, Owner, PagePress, PicturePart};
/// What the pointer and the page are, for the image tools on one page.
#[derive(Clone, Copy)]
@@ -13,7 +13,10 @@ pub(crate) struct ImageCx<'a> {
pub ptr: &'a PagePointer,
pub page: usize,
pub info: &'a DocInfo,
pub allow_interaction: bool,
/// What the press the pages hold is to this page.
pub press: PagePress,
/// Edit text's input is open: no dialog, palette, other window or other tab has it.
pub gate: bool,
}
/// The 8 resize handles on an image (4 corners + 4 edge centers).
@@ -116,14 +119,29 @@ pub struct CropMode {
pub keep: [f64; 4],
/// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then.
pub(crate) drag: Option<(usize, Pos2, [f64; 4])>,
/// Where its Done, Cancel and Remove Crop controls were drawn last (screen): the hand shows
/// over them ([`crop_hit`]).
pub(crate) controls: Vec<Rect>,
}
impl CropMode {
pub fn new(keep: [f64; 4]) -> Self {
Self { keep, drag: None }
Self { keep, drag: None, controls: Vec::new() }
}
}
/// How far from a crop grip a press still takes it, and the pointer shows its cursor (screen).
const CROP_GRIP_REACH: f32 = 10.0;
/// The picture `image` being cropped to `keep`, on screen: its whole frame, and the box kept
/// within it. `None` when either has no area.
fn crop_frames(xf: &PageXform, info: &DocInfo, page: usize, image: &pdfcraft_engine::PageImage, keep: [f64; 4]) -> Option<(Rect, Rect)> {
let full = xf.user_rect(info, page, image.rect.map(|v| v as f32));
let b = if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) };
let k = xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
(b.is_finite() && b.is_positive() && b.size().is_finite() && k.is_finite() && k.is_positive()).then_some((b, k))
}
pub(crate) use pdfcraft_content::Matrix;
/// The displayed crop frame, expressed in page user space, rather than the full image placement.
@@ -246,50 +264,31 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool {
a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
}
fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
let full = xf.user_rect(info, page, image.rect.map(|v| v as f32));
if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }
}
/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove
/// Crop pills are widgets of their own, so they take their clicks before the canvas does.
pub(crate) fn crop_controls_contain(
image: &pdfcraft_engine::PageImage,
mode: &CropMode,
xf: &PageXform,
info: &DocInfo,
page: usize,
p: Pos2,
) -> bool {
let b = crop_bounds(image, xf, info, page);
let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b);
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
return false;
}
k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0)
}
/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true`
/// (the pointer and keys are the crop's while it lasts).
/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]).
#[allow(clippy::cognitive_complexity)]
fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool {
let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx;
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
let painter = ui.painter();
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
let hover_pos = ptr.pos;
let b = crop_bounds(image, xf, info, page);
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
let k = keep_screen(mode.keep);
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
let Some((b, k)) = crop_frames(xf, info, page, image, mode.keep) else {
mode.drag = None;
return true;
}
};
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
if allow_interaction
&& resp.drag_started()
// A drag takes what its press was on ([`crop_hit`]).
if gate
&& ptr.drag_started
&& let Some(o) = ptr.origin
&& let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press
{
let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8));
let grip = match part {
CropPart::Grip(g) => Some(g),
CropPart::Keep => Some(8),
CropPart::Cut | CropPart::Page => None,
};
if let Some(g) = grip {
mode.drag = Some((g, o, mode.keep));
}
@@ -352,7 +351,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
// Crop handles
for (idx, h) in crop_handles(k).iter().enumerate() {
let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx);
let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0);
let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < CROP_GRIP_REACH);
draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT);
}
@@ -385,6 +384,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0));
let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0));
let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0)));
mode.controls = [Some(done), Some(cancel), reset].into_iter().flatten().collect();
let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i);
let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i);
@@ -393,10 +393,10 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
let done_resp = ui.interact(done, done_id, egui::Sense::click());
let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click());
let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click()));
done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Done")));
cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Cancel")));
done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Done")));
cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Cancel")));
if let Some(response) = &reset_resp {
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Remove Crop")));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Remove Crop")));
}
let t = crate::theme::Tokens::get(ui.ctx());
@@ -430,56 +430,83 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
neutral_pill(r, response, tl!("Remove Crop"));
}
if let Some(p) = hover_pos {
let icon = if done_resp.hovered()
|| cancel_resp.hovered()
|| reset_resp.as_ref().is_some_and(|r| r.hovered())
|| done.contains(p)
|| cancel.contains(p)
|| reset.is_some_and(|r| r.contains(p))
{
egui::CursorIcon::PointingHand
} else if let Some(&g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0).and_then(|g| GRIPS.get(g)) {
g.cursor_icon()
} else if k.contains(p) {
if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
} else {
egui::CursorIcon::Default
};
view.cursor.claim(Layer::Editor, icon);
// The cursor is the hit's ([`crop_hit`]), and a grip's or the box's being dragged.
if let Some((grip, ..)) = mode.drag {
view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon()));
}
let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input();
let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p))));
let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p))));
let reset_clicked = allow_interaction
let done_clicked = gate && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p))));
let cancel_clicked = gate && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p))));
let reset_clicked = gate
&& reset
.is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p))));
if reset_clicked {
mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep };
mode.drag = None;
} else if done_clicked || keys(egui::Key::Enter) {
let keep = mode.keep;
if let Some(s) = view.image_selection.as_mut() {
s.crop = None;
}
let is_full = same_rect(keep, image.rect);
if is_full {
if image.has_editor_crop() {
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop });
}
} else if image.visible_rect != Some(keep) {
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) });
}
} else if (cancel_clicked || keys(egui::Key::Escape))
&& let Some(s) = view.image_selection.as_mut()
{
} else if done_clicked {
apply_crop_mode(view, image);
} else if cancel_clicked && let Some(s) = view.image_selection.as_mut() {
s.crop = None;
}
true
}
/// Done in crop mode: it ends, and the picture `image` of the selection keeps the box kept (or
/// all of itself again).
fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) {
let Some(s) = view.image_selection.as_mut() else { return };
let Some(keep) = s.crop.take().map(|mode| mode.keep) else { return };
let (page, index) = (s.page, s.index);
if same_rect(keep, image.rect) {
if image.has_editor_crop() {
view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::RemoveCrop });
}
} else if image.visible_rect != Some(keep) {
view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Crop(keep) });
}
}
/// The keys of the picture selected on `page`, a page being drawn, so the page grid and the Pages
/// panel have had theirs; nothing while the page is off the screen, as pages near it are drawn too.
/// Delete or Backspace removes it, wherever on the pages the pointer is, unless it is being cropped or
/// a button is down: the press held would then name another picture. In crop mode
/// Enter applies the crop and Escape leaves it, though not while a button is down (`held`): Escape
/// then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode stays.
/// Nothing while a text field has the keyboard.
pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) {
if ctx.egui_wants_keyboard_input() || view.page_screen_rect(page).is_none() {
return;
}
let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) else { return };
let index = s.index;
let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k));
let image = images.get(index);
if s.crop.is_some() {
if key(egui::Key::Enter) {
if let Some(image) = image {
apply_crop_mode(view, image);
}
} else if !held
&& key(egui::Key::Escape)
&& let Some(s) = view.image_selection.as_mut()
{
s.crop = None;
}
} else if !held && !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image.is_some() {
// The key is the picture's: added content's own Delete runs later this frame.
ctx.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, egui::Key::Delete);
i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace);
});
view.image_selection = None;
// Added content's selection follows the picture's (`content_ui::sync_image_selection`), and
// goes with it: once the picture is gone it would be the next added item's.
view.content.selected = None;
view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete });
}
}
/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where
/// the screen may still show the picture).
fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) {
@@ -541,29 +568,63 @@ 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<Hit> {
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 being cropped on `page`, under `p`: crop mode holds every press on its page, 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. A grip of the box kept (within
/// [`CROP_GRIP_REACH`], past the page's edge too), the box itself, the picture outside it (its frame
/// shown or its whole frame), or the rest of the page; and the controls past the page's edge, where
/// they show the hand. `None` when no picture on `page` is being cropped, or `p` is off the page,
/// its grips and its controls: the box and the picture stop at the page's edge, as a clipped
/// artwork's whole frame can reach over the next pages.
pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option<Hit> {
let s = view.image_selection.as_ref().filter(|s| s.page == page)?;
let mode = s.crop.as_ref()?;
let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon));
if mode.controls.iter().any(|r| r.contains(p)) {
return crop(CropPart::Page, egui::CursorIcon::PointingHand);
}
let image = images.get(s.index);
let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k);
// The grips reach past the page's edge, where those of a picture filling its page lie half.
if let Some(g) = kept.and_then(|k| crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH)) {
return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon()));
}
if !xf.rect.contains(p) {
return None;
}
if kept.is_some_and(|k| k.contains(p)) {
return crop(CropPart::Keep, egui::CursorIcon::Grab);
}
let on_picture = image.is_some_and(|image| {
let shown = ImageObb::from_image(image, xf, info, page);
let whole = ImageObb::from_matrix(image.matrix, xf, info, page);
quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p)
});
crop(if on_picture { CropPart::Cut } else { CropPart::Page }, 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.
/// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it
/// included): 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.
pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option<Hit> {
if !xf.rect.contains(p) {
return None;
}
let (x, y) = xf.screen_to_view(p);
let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from);
let index = images
.iter()
.rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?;
let on_picture = |im: &pdfcraft_engine::PageImage| {
im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user)
};
// The selected picture's frame is its own, a hole in it and a picture over it included.
let framed =
view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none()).map(|s| s.index).filter(|i| {
images.get(*i).is_some_and(|image| on_picture(image) || quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p))
});
let index = images.iter().rposition(on_picture).or(framed)?;
let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index);
let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand };
Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon))
let part = if framed.is_some_and(|i| i != index) { PicturePart::OverSelected } else { PicturePart::Body };
Some(Hit::new(Owner::Picture { index, part }, Layer::Text, icon))
}
/// What a right-click on a selected image asks for.
@@ -620,15 +681,12 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap)
}
#[doc(hidden)]
pub fn image_menu_id() -> egui::Id {
egui::Id::unique("image-menu")
}
/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used.
/// Images on a page: the press that is a picture's (`press`, [`body_hit`] and [`handle_hit`])
/// selects it, a drag of the selected one moves, resizes or turns it. Returns `true` when the
/// pointer was used.
#[allow(clippy::cognitive_complexity)]
pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool {
let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx;
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
let boxes: Vec<Rect> = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect();
let painter = ui.painter();
let hover_pos = ptr.pos;
@@ -724,56 +782,25 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
}
}
let mut handle_drag = None;
for (h, h_pos) in obb.handles() {
let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0));
let handle_resp = ui.interact(handle_rect, ui.scope_id().with("img_handle").with(h), egui::Sense::drag());
if allow_interaction && handle_resp.drag_started() {
handle_drag = Some(h);
}
}
let rot_id = ui.scope_id().with("img_handle").with(8);
let rot_rect = Rect::from_center_size(shown_knob, egui::vec2(12.0, 12.0));
let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag());
let rot_drag = allow_interaction && rot_resp.drag_started();
if allow_interaction && let Some(p) = hover_pos {
if shown_knob.distance(p) < 8.0 {
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
} else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) {
let icon = obb.handle_cursor(*h);
view.cursor.claim(Layer::Handle, icon);
}
}
let origin = ptr.origin;
if allow_interaction {
if let Some(h) = handle_drag {
if let Some(s) = view.image_selection.as_mut() {
let o = origin.unwrap_or_else(|| obb.handle_pos(h));
s.drag = Some(ImageDrag {
start: o,
target: DragTarget::Handle(h),
initial_rect: b,
initial_matrix: resize_frame(&obb, xf, info, page).0,
});
}
} else if rot_drag {
if let Some(s) = view.image_selection.as_mut() {
let o = origin.unwrap_or(shown_knob);
// A drag of the selected picture takes what its press was on ([`body_hit`], [`handle_hit`]):
// the rotate knob turns it, a grip resizes it, the picture itself moves. The knob and the
// grips take it from the press, so a move too short to be a drag of the page counts; the
// picture waits for that, as a click selects.
let at_press = |part| matches!(part, PicturePart::Knob | PicturePart::Corner(_));
if gate
&& let Some(o) = ptr.origin
&& let PagePress::Mine(Owner::Picture { index, part }) = press
&& (index == i || part == PicturePart::OverSelected)
&& (ptr.drag_started || at_press(part) && ptr.pressed().is_some())
&& let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none())
{
match part {
PicturePart::Knob => {
s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix });
s.turning = true;
}
} else if resp.drag_started()
&& let Some(o) = origin
{
if shown_knob.distance(o) < 8.0 {
if let Some(s) = view.image_selection.as_mut() {
s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix });
s.turning = true;
}
} else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) {
if let Some(s) = view.image_selection.as_mut() {
PicturePart::Corner(grip) => {
if let Some((h, _)) = obb.handles().get(grip) {
s.drag = Some(ImageDrag {
start: o,
target: DragTarget::Handle(*h),
@@ -781,11 +808,11 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
initial_matrix: resize_frame(&obb, xf, info, page).0,
});
}
} else if (image_contains(i, o) || quad_contains(obb.corners(), o))
&& let Some(s) = view.image_selection.as_mut()
{
}
PicturePart::Body | PicturePart::OverSelected => {
s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix });
}
PicturePart::Crop(_) => {}
}
}
@@ -949,59 +976,35 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
view.cursor.claim(Layer::Drag, cursor_icon);
return true;
}
if allow_interaction
&& ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
{
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
return true;
}
// A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this
// page leaves it unselected (where the page's objects can be selected together, the area
// selection the press starts has done so already).
let owner = match press {
PagePress::Mine(Owner::Picture { index, .. }) => Some(index),
_ => None,
};
if gate && resp.clicked() {
match owner {
Some(index) => view.image_selection = Some(ImageSelection::at(page, index)),
None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None,
None => {}
}
}
if !allow_interaction {
return false;
}
let menu_key = image_menu_id();
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| {
b.is_positive() && images.get(i).is_some_and(|img| ImageObb::from_image(img, xf, info, page).contains(*b, p)) && image_contains(i, p)
}) else {
if resp.secondary_clicked() || resp.clicked() {
view.image_selection = None;
ui.ctx().data_mut(|d| d.insert_temp(menu_key, false));
}
return false;
};
let Some(hit_img) = images.get(hit) else { return false };
let hit_obb = ImageObb::from_image(hit_img, xf, info, page);
if selected != Some(hit) {
if hit_obb.is_rotated {
let pts = hit_obb.corners();
for k in 0..4 {
painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7)));
}
} else if let Some(b) = boxes.get(hit).copied() {
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
owner.is_some()
}
/// A right-click on `page` that went to its picture `index` (on it or a grip of it) selects it for
/// its menu, keeping it as it is if it was selected (crop mode included); one that went to anything
/// else (`None`) unselects the picture selected.
pub(crate) fn picture_context(page: usize, index: Option<usize>, view: &mut DocView) {
match index {
Some(index) if view.image_selection.as_ref().is_none_or(|s| (s.page, s.index) != (page, index)) => {
view.image_selection = Some(ImageSelection::at(page, index));
}
Some(_) => {}
None => view.image_selection = None,
}
let grip = hit_obb.handles().into_iter().find(|(_, point)| point.distance(p) < 10.0);
let cursor = if selected == Some(hit) {
grip.map_or(egui::CursorIcon::Grab, |(handle, _)| hit_obb.handle_cursor(handle))
} else {
egui::CursorIcon::PointingHand
};
view.cursor.claim(Layer::Text, cursor);
let clicked = match view.router.owner(page) {
Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index),
_ => (resp.clicked() || resp.secondary_clicked()).then_some(hit),
};
if let Some(index) = clicked {
view.image_selection = Some(ImageSelection::at(page, index));
}
if resp.secondary_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
}
true
}
/// The right-click context menu for a selected image.
@@ -1025,7 +1028,6 @@ pub(crate) fn image_context_menu(
let picked = |ui: &mut egui::Ui, entry: egui::Response| {
let clicked = entry.clicked();
if clicked {
ui.ctx().data_mut(|d| d.insert_temp(image_menu_id(), false));
egui::Popup::close_all(ui.ctx());
ui.close();
}
+7 -6
View File
@@ -95,7 +95,8 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String {
/// 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.
/// on. A drag nobody holds draws a new link: a link lets its drag go. Returns `true` when the
/// gesture belongs to the tool.
pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option<usize>, context: Option<usize>, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let pointer = ptr.pos;
@@ -114,11 +115,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option<usiz
}
return true;
}
// A drag that nobody holds, or from a link (whose hit lets the drag go), draws a new link from
// where its press began on this page. egui may see the drag only once the pointer is past the
// page's edge.
if resp.drag_started()
&& (free || owner.is_some())
// A drag that nobody holds (a link's hit lets its drag go, `PageRouter::drag_began`) draws a
// new link from where its press began on this page. egui may see the drag only once the pointer
// is past the page's edge.
if ptr.drag_started
&& free
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o))
{
lv.drag = Some((page, origin));
+145 -92
View File
@@ -9,7 +9,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, ObjectsPart, Owner};
use crate::page_router::{Hit, ObjectsPart, Owner, PagePress};
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -24,7 +24,6 @@ pub(crate) struct ObjectSelection {
pending: Option<Vec<EditableObject>>,
pub notice: Option<String>,
pub hold_editor: bool,
blocked_page: Option<usize>,
input_blocked: bool,
/// A paragraph (page, block) to select once the document is at hand: Esc in the inline
/// editor asks for it, and the next `prepare` selects it.
@@ -40,23 +39,32 @@ struct Gesture {
}
impl ObjectSelection {
/// Clear any object-selection gesture and the page it was blocking.
/// Clear any object-selection gesture.
pub(crate) fn cancel_pointer_gesture(&mut self) {
self.gesture = None;
self.blocked_page = None;
}
pub fn count(&self) -> usize {
self.selected.len()
}
/// Edit text's input is blocked: a dialog, the palette, another window or another tab has it.
pub(crate) fn input_blocked(&self) -> bool {
self.input_blocked
}
/// The objects selected together are on `page`, or a gesture of theirs runs there: the hover
/// outlines of what lies under them wait.
pub(crate) fn holds(&self, page: usize) -> bool {
self.page == Some(page) && (!self.selected.is_empty() || self.gesture.is_some())
}
/// A preview belongs to the active, focused document. Returning from another tab or a
/// modal must not turn a release that happened elsewhere into a document edit.
pub fn set_input_blocked(&mut self, blocked: bool) {
self.input_blocked = blocked;
if blocked {
self.gesture = None;
self.blocked_page = None;
}
}
@@ -65,7 +73,6 @@ impl ObjectSelection {
self.selected.clear();
self.gesture = None;
self.pending = None;
self.blocked_page = None;
self.select_paragraph = None;
}
@@ -139,7 +146,9 @@ impl ObjectSelection {
Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset }
}
fn inventory(&mut self, doc: &Document, page: usize) -> Result<Vec<EditableObject>, String> {
/// The objects of `page` that can be selected together, as `doc.editable_objects` lists them, or
/// why they can't be.
pub(crate) fn inventory(&mut self, doc: &Document, page: usize) -> Result<Vec<EditableObject>, String> {
if self.cache.len() >= 8 && !self.cache.contains_key(&page) {
self.cache.clear();
}
@@ -151,18 +160,15 @@ fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject)
xf.user_rect(info, page, object.rect.map(|v| v as f32))
}
fn modified(ui: &egui::Ui) -> bool {
ui.input(|i| {
let modifiers = i
.events
.iter()
.find_map(|e| match e {
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers),
_ => None,
})
.unwrap_or(i.modifiers);
modifiers.shift || modifiers.ctrl || modifiers.command
})
/// A menu is open (the page's, or another popup): the objects selected together leave the pointer
/// alone, and a press with Shift or ⌘ is not theirs.
pub(crate) fn menu_open(ui: &egui::Ui, resp: &egui::Response) -> bool {
resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx())
}
/// Whether `modifiers` make a press add to the objects selected together, or take from them.
pub(crate) fn adds(modifiers: egui::Modifiers) -> bool {
modifiers.shift || modifiers.ctrl || modifiers.command
}
fn single(view: &DocView, page: usize) -> Option<ObjectTarget> {
@@ -187,6 +193,10 @@ fn leave_single(view: &mut DocView) {
/// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the
/// caret.
pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, open: Option<Rect>, p: Pos2) -> Option<Hit> {
// Past the page's edge is no object's: the next page lies there.
if !xf.rect.contains(p) {
return None;
}
let s = &view.objects;
if s.page == Some(page) && !s.selected.is_empty() && s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))).contains(p)
{
@@ -195,27 +205,75 @@ pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, o
open.filter(|r| on_frame(r.shrink(2.0), p)).map(|_| Hit::new(Owner::Objects { part: ObjectsPart::Frame }, Layer::Handle, egui::CursorIcon::Move))
}
/// Returns true while group selection owns the interaction. Ordinary clicks still reach the
/// single-object text/resize tools. A text draft is finished or discarded through its editor.
pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, .. } = cx;
/// The object under `p` among the page's `objects`: added content over text over pictures, so a
/// page-sized background picture does not hide editable labels or added notes, and within each
/// kind the last, as the single-item tools take it.
fn object_at(xf: &PageXform, info: &DocInfo, page: usize, objects: &[EditableObject], p: Pos2) -> Option<ObjectTarget> {
[ObjectKind::Added, ObjectKind::Text, ObjectKind::Image]
.into_iter()
.find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p)))
.map(|o| o.target)
}
/// What a press with Shift or ⌘ at `p` takes on `page` in Edit text, whatever lies there: the
/// object under it among the page's `objects` ([`ObjectSelection::inventory`]), which it adds to
/// the objects selected together or takes out of them, or none, where it starts an area selection
/// that adds to them. Held while text being typed is open, and refused on a page whose objects
/// can't be selected together. `None` off the page and outside the pages' visible area (`clip`).
pub(crate) fn modified_hit(
view: &DocView,
xf: &PageXform,
info: &DocInfo,
page: usize,
objects: &Result<Vec<EditableObject>, String>,
clip: Rect,
p: Pos2,
) -> Option<Hit> {
if !xf.rect.contains(p) || !clip.contains(p) {
return None;
}
let part = if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
ObjectsPart::Held
} else {
match objects {
Ok(objects) => ObjectsPart::Toggle(object_at(xf, info, page, objects, p)),
Err(_) => ObjectsPart::Refused,
}
};
Some(Hit::new(Owner::Objects { part }, Layer::Editor, egui::CursorIcon::Default))
}
/// The objects selected together, on one page, told what the press is to it (`press`). Returns
/// true while they own the interaction: while input is blocked, while a press with Shift or ⌘ is
/// held ([`modified_hit`]), and while objects on this page are selected or a gesture of theirs
/// runs. Ordinary presses on a paragraph, a picture or added content still reach their own tools.
/// A text draft is finished or discarded through its editor.
pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, ptr, page, .. } = cx;
if view.objects.input_blocked {
return true;
}
if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) {
if menu_open(ui, resp) {
return false;
}
if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
let blocked = view.objects.blocked_page == Some(page);
let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p));
if blocked || modifier_press {
match press {
// Text being typed keeps the keyboard, and its editor stays open, until the button is up.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Held }) => {
view.objects.hold_editor = true;
view.objects.blocked_page = ptr.primary_down.then_some(page);
if modifier_press {
if ptr.pressed().is_some() {
view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into());
}
return true;
}
PagePress::Mine(Owner::Objects { part: ObjectsPart::Refused }) => {
if ptr.pressed().is_some() {
view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into());
}
return true;
}
_ => {}
}
if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
return false;
}
// Delete or Backspace removes the selected objects on this page as one edit,
@@ -235,12 +293,12 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool {
}
}
let mut selection = std::mem::take(&mut view.objects);
let result = input(&mut selection, cx, view);
let result = input(&mut selection, cx, press, view);
view.objects = selection;
result
}
fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -> bool {
fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx;
let pointer = ptr.pos;
let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down);
@@ -298,73 +356,68 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else {
return s.page == Some(page) && !s.selected.is_empty();
};
if !pressed {
// Nor a press another page holds, on the row of pixels pages share with the gaps hidden: it may
// have just selected something there.
if !pressed || press == PagePress::Elsewhere {
return s.page == Some(page) && !s.selected.is_empty();
}
let objects = match s.inventory(doc, page) {
Ok(objects) => objects,
Err(_) => {
if modified(ui) {
s.notice = Some(tl!("This page contains objects that cannot be selected together.").into());
let Ok(objects) = s.inventory(doc, page) else { return false };
// What the press was on ([`hit`], [`modified_hit`]).
match press {
// With Shift or ⌘: the object it was on joins the selection or leaves it, and on none an
// area selection adds to it.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Toggle(target) }) => {
if s.page != Some(page) {
s.clear();
s.page = Some(page);
if let Some(single) = single(view, page)
&& let Some(object) = objects.iter().find(|o| o.target == single)
{
s.selected.push(object.clone());
}
}
match target.and_then(|t| objects.iter().find(|o| o.target == t)) {
Some(object) => {
if let Some(index) = s.selected.iter().position(|o| o.target == object.target) {
s.selected.remove(index);
} else if s.selected.len() < MAX_MOVE_OBJECTS {
s.selected.push(object.clone());
} else {
s.notice = Some(tl!("Select at most 1000 objects at a time.").into());
}
}
None => s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }),
}
leave_single(view);
view.current = page;
return true;
}
// The selection moves.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) => {
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
return true;
}
// The frame of the paragraph open for typing selects its box (for the arrow keys) and
// moves it.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Frame }) => {
if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block)
&& let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block })
{
s.clear();
s.page = Some(page);
s.selected.push(object.clone());
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
leave_single(view);
view.current = page;
return true;
}
return false;
}
};
let additive = modified(ui);
// A page-sized background image must not hide editable labels or added notes.
// Within each kind, prefer the last object as the ordinary single-item tools do.
let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image]
.into_iter()
.find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p)));
if additive {
if s.page != Some(page) {
s.clear();
s.page = Some(page);
if let Some(target) = single(view, page)
&& let Some(object) = objects.iter().find(|o| o.target == target)
{
s.selected.push(object.clone());
}
}
if let Some(object) = hit {
if let Some(index) = s.selected.iter().position(|o| o.target == object.target) {
s.selected.remove(index);
} else if s.selected.len() < MAX_MOVE_OBJECTS {
s.selected.push(object.clone());
} else {
s.notice = Some(tl!("Select at most 1000 objects at a time.").into());
}
} else {
s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true });
}
leave_single(view);
view.current = page;
return true;
}
// What the press was on ([`hit`]): the selection moves; the frame of the paragraph open for
// typing selects its box (for the arrow keys) and moves it.
let owner = view.router.owner(page);
if owner == Some(Owner::Objects { part: ObjectsPart::Selection }) && !resp.double_clicked() {
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
return true;
}
if owner == Some(Owner::Objects { part: ObjectsPart::Frame })
&& let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block)
&& let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block })
{
s.clear();
s.page = Some(page);
s.selected.push(object.clone());
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
leave_single(view);
view.current = page;
return true;
_ => {}
}
s.clear();
// A press on something (a paragraph, its edge reaching past its box #2276, a picture, added
// content) is its own; only one on the empty page starts an area selection.
if hit.is_some() || view.router.owner(page).is_some() {
// content) is its own; only one nobody holds starts an area selection.
if press != PagePress::Free {
return false;
}
leave_single(view);
@@ -589,7 +642,7 @@ mod tests {
},
|ui| {
let resp = ui.interact(ui.available_rect_before_wrap(), egui::Id::unique("page"), egui::Sense::click());
page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, &mut view);
page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, PagePress::Free, &mut view);
},
);
output.textures_delta.clear();
+3 -2
View File
@@ -380,7 +380,8 @@ pub(crate) fn page_input(
// The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody
// holds draws a selection rectangle, or a field with a field tool. A press held by something
// drawn over the fields (a comment) is not Prepare's.
// drawn over the fields (a comment) is not Prepare's, though its drag is once that something
// lets it go (a highlight's, `PageRouter::drag_began`).
let free = press == PagePress::Free;
let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. }));
let hit = match press {
@@ -390,7 +391,7 @@ pub(crate) fn page_input(
// 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()
&& ptr.drag_started
&& (held || free)
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o))
{
+1 -1
View File
@@ -269,7 +269,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo
/// A finished text selection with the Redact tool becomes a mark.
pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, view: &mut DocView) {
if !(resp.drag_stopped() || resp.double_clicked()) || view.redact_drag.is_some() {
if !(resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked()) || view.redact_drag.is_some() {
return;
}
if let Some((p, quads)) = view.selection_quads(info).filter(|(p, _)| *p == page) {
+22
View File
@@ -146,3 +146,25 @@ fn alt_drag_with_the_highlighter_marks_only_the_column() {
let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect();
assert_eq!(marks, [("Highlight", 4)], "Name, Qty, Apple and 12, each its own quad");
}
/// A column's rows and tab-separated cells are not paragraphs, so Copy puts the plain text on
/// the clipboard and no HTML, even where the clipboard would take it.
#[test]
fn a_column_selection_copies_plain_text_only() {
let offered = std::sync::Arc::new(std::sync::Mutex::new(0usize));
let seen = offered.clone();
let mut h = harness(move |app| {
app.system_clipboard = true;
app.html_clipboard_override = Some(Box::new(move |_, _| {
*seen.lock().unwrap() += 1;
true
}));
});
let (from, to) = top_two_rows(&h);
drag(&mut h, from, to, Modifiers::ALT);
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert_eq!(*offered.lock().unwrap(), 0, "no HTML for a column selection");
let copied = h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Name\tQty\nApple\t12"));
assert!(copied, "the plain text goes out the usual way");
}
+9 -1
View File
@@ -717,14 +717,22 @@ fn form_widget_keeps_priority_over_highlighted_link() {
assert_eq!(h.state().last_opened_url, None);
}
/// A comment's gesture keeps the page's input until its button is up: a press held through it and
/// released on a highlighted link follows no link. (A gesture left over once the button is up
/// ends: `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.)
#[test]
fn active_comment_gesture_keeps_highlighted_link_click() {
let _gpu = gpu();
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
let r = h.state().views[0].page_screen_rect(0).unwrap();
let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0));
h.hover_at(at);
h.run_steps(2);
h.drag_at(at);
h.run_steps(1);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at });
click_at(&mut h, at);
h.drop_at(at);
h.run_steps(4);
assert_eq!(h.state().pending_link, None);
assert_eq!(h.state().last_opened_url, None);
}
+881 -7
View File
@@ -2588,7 +2588,7 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p
}
/// `bytes` rewritten encrypted.
fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
pub(crate) fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm,
@@ -6503,6 +6503,15 @@ fn clipped_artwork_with_labels() -> Vec<u8> {
/// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049).
fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec<u8> {
artwork_drawn(if own_clip {
&b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
} else {
&b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
})
}
/// [`fixture`]'s two pages, each drawn by `content`, with a one-pixel picture as `/Im0`.
fn artwork_drawn(content: &[u8]) -> Vec<u8> {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap();
let mut image = Dict::new();
@@ -6514,12 +6523,7 @@ fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec<u8> {
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245])));
for page in pdfcraft_model::pages(&doc) {
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(),
if own_clip {
&b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
} else {
&b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
})));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut xo = Dict::new();
xo.set(b"Im0".to_vec(), Object::Ref(image));
@@ -7230,6 +7234,848 @@ fn cropping_a_picture_with_its_handles() {
assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none()));
}
/// Both pages of [`clipped_artwork_with_labels`] at 100% in Edit text, the picture on page 0
/// selected (and being cropped, with `crop`).
fn picture_pages(crop: bool) -> Harness<'static, PdfCraftApp> {
picture_pages_of(clipped_artwork_with_labels(), crop)
}
/// [`picture_pages`], of `pdf`.
fn picture_pages_of(pdf: Vec<u8>, crop: bool) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("clipped.pdf", None, pdf).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(3);
let id = h.state().views[0].id;
let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = crop.then(|| pdfcraft_ui_egui::CropMode::new(keep));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(3);
h
}
/// The screen point at PDF (x, y) of `page` of [`picture_pages`].
fn page_at(h: &Harness<'static, PdfCraftApp>, page: usize, x: f32, y: f32) -> egui::Pos2 {
let r = h.state().views[0].page_screen_rect(page).expect("both pages on screen");
egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height())
}
fn cursor_over(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon {
h.hover_at(p);
h.run_steps(2);
h.output().platform_output.cursor_icon
}
/// Crop mode holds every press on its page, and its cursor stays there: the grips show theirs and
/// the box kept the hand that drags it, and on the next page the pointer shows what a press there
/// takes (a paragraph's move arrow). Crop mode's cursor used to win on every page.
#[test]
fn crop_modes_cursor_stays_on_its_page() {
let mut h = picture_pages(true);
// The visible picture is x 30..160, y 170..250.
for (page, x, y, cursor, what) in [
(0, 160.0, 170.0, egui::CursorIcon::ResizeNwSe, "a corner grip"),
(0, 95.0, 210.0, egui::CursorIcon::Grab, "the box kept"),
(0, 65.0, 134.0, egui::CursorIcon::Default, "crop mode holds its page's paragraphs"),
(1, 65.0, 134.0, egui::CursorIcon::Move, "the next page's paragraph"),
] {
let p = page_at(&h, page, x, y);
assert_eq!(cursor_over(&mut h, p), cursor, "{what}");
}
}
/// A press on a crop grip a little outside the picture drags that grip, and Escape in the middle
/// of the drag cancels it: the box goes back and crop mode stays. Escape once the button is up
/// leaves crop mode.
#[test]
fn escape_during_a_crop_drag_keeps_crop_mode() {
let mut h = picture_pages(true);
let before = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep;
let grip = page_at(&h, 0, 160.0, 170.0) + egui::vec2(5.0, 5.0);
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip - egui::vec2(10.0, 10.0) * k as f32);
h.run_steps(1);
}
let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep);
assert!(keep(&h).is_some_and(|k| k[2] < before[2] - 5.0 && k[1] > before[1] + 5.0), "the grip outside the picture is dragged");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(keep(&h), Some(before), "Escape cancels the drag, and crop mode stays");
h.drop_at(grip - egui::vec2(40.0, 40.0));
h.run_steps(2);
assert_eq!(keep(&h), Some(before), "the cancelled drag's release crops nothing");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(keep(&h), None, "Escape leaves crop mode");
assert!(!dirty(&h));
}
/// With Edit text, a right-click on text lying over a picture is the picture's, which is selected
/// for its menu: paragraphs have no menu of their own (the user's ruling).
#[test]
fn a_right_click_on_text_over_a_picture_selects_the_picture() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
h.run_steps(2);
// "Overlay" lies on the picture, at x 40, y 220.
let overlay = page_at(&h, 0, 50.0, 224.0);
right_click_at(&mut h, overlay);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture under the text is selected for its menu");
}
/// With Edit text, a right-click on added text over a picture is the picture's, which is selected
/// for its menu: added text has no menu of its own.
#[test]
fn a_right_click_on_added_text_over_a_picture_selects_the_picture() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [60.0, 190.0, 150.0, 205.0], text: "Added".into(), ..Default::default() },
}));
h.run_steps(3);
let on_it = page_at(&h, 0, 100.0, 198.0);
right_click_at(&mut h, on_it);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture under the added text is selected for its menu");
assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens");
}
/// With Edit text, a right-click on the frame of a paragraph open for typing over a picture is the
/// picture's too.
#[test]
fn a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
h.run_steps(2);
// "Overlay" lies on the picture, at x 40, y 220.
let overlay = page_at(&h, 0, 50.0, 224.0);
slipping_click(&mut h, overlay, overlay);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing");
let id = h.state().views[0].id;
let blocks = h.state().session.get(id).unwrap().text_blocks(0);
let block =
blocks.iter().find(|b| b.rect[0] < 50.0 && 50.0 < b.rect[2] && b.rect[1] < 224.0 && 224.0 < b.rect[3]).expect("the paragraph opened").clone();
// Its frame runs just outside its box.
let frame = page_at(&h, 0, block.rect[0] as f32, ((block.rect[1] + block.rect[3]) / 2.0) as f32) - egui::vec2(3.0, 0.0);
right_click_at(&mut h, frame);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture under the frame is selected for its menu");
assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens");
}
/// A drag from the selected picture moves it, though another picture lies over it there; a click
/// there selects the one on top.
#[test]
fn a_drag_from_the_selected_picture_under_another_moves_the_selected_one() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("two.pdf", None, two_image_fixture()).unwrap();
app
});
h.run_steps(4);
// The second picture lies over the right half of the first.
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 1,
change: pdfcraft_engine::ImageEdit::Move([110.0, 100.0, 130.0, 120.0]),
});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(2);
let id = h.state().views[0].id;
let rects = |h: &Harness<'static, PdfCraftApp>| {
let images = h.state().session.get(id).unwrap().page_images(0);
(images[0].rect, images[1].rect)
};
let before = rects(&h);
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let images = doc.page_images(0);
let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0);
let overlap = frame(0).intersect(frame(1)).center();
h.hover_at(overlap);
h.run_steps(1);
h.drag_at(overlap);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(overlap + egui::vec2(8.0 * k as f32, 0.0));
h.run_steps(1);
}
h.drop_at(overlap + egui::vec2(32.0, 0.0));
h.run_steps(3);
let after = rects(&h);
assert_ne!(after.0, before.0, "the selected picture moves");
assert_eq!(after.1, before.1, "the one over it stays");
let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index);
assert_eq!(selected, Some(0), "and stays selected");
// After the move the two still overlap, a little further along.
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let images = doc.page_images(0);
let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0);
let overlap = frame(0).intersect(frame(1)).center();
slipping_click(&mut h, overlap, overlap);
let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index);
assert_eq!(selected, Some(1), "a click selects the one on top");
}
/// A move too short to be a drag of the page still resizes the selected picture from its grip, as
/// the grip takes its drag from the press.
#[test]
fn a_short_drag_from_a_picture_grip_resizes_it() {
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].matrix;
// The picture shows at x 30..160, y 170..250: its lower right grip, moved 3 px.
let grip = page_at(&h, 0, 160.0, 170.0);
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
h.hover_at(grip + egui::vec2(3.0, 0.0));
h.run_steps(1);
h.drop_at(grip + egui::vec2(3.0, 0.0));
h.run_steps(3);
assert_ne!(h.state().session.get(id).unwrap().page_images(0)[0].matrix, before, "the picture is resized");
}
/// A click on a selected picture's grip that doesn't move resizes nothing: no edit, and the picture
/// stays selected.
#[test]
fn a_click_on_a_picture_grip_changes_nothing() {
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().edit_generation();
let grip = page_at(&h, 0, 160.0, 170.0);
slipping_click(&mut h, grip, grip);
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), before, "no edit");
assert!(h.state().views[0].image_selection.is_some(), "the picture stays selected");
}
/// With Add text holding text that can't be drawn, a drag from a paragraph moves nothing: the draft
/// stays where it is being typed (#125), as a click there leaves it.
#[test]
fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() {
if crate::support::skip_without_glyphs("add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw", "en", "Hello 世界") {
return;
}
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText);
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
let blank = point_on(&h, block.rect[0], block.rect[3] + 12.0);
slipping_click(&mut h, blank, blank);
h.event(egui::Event::Text("Hello 世界".into()));
h.run_steps(2);
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Hello 世界"), "the draft holds the text");
let rect = h.state().views[0].content.draft.as_ref().map(|d| d.rect).unwrap();
let on_text = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0);
h.hover_at(on_text);
h.run_steps(1);
h.drag_at(on_text);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(on_text + egui::vec2(10.0 * k as f32, 0.0));
h.run_steps(1);
}
h.drop_at(on_text + egui::vec2(40.0, 0.0));
h.run_steps(3);
assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, block.rect, "the paragraph stays");
let draft = h.state().views[0].content.draft.as_ref().map(|d| (d.text.clone(), d.rect));
assert_eq!(draft, Some(("Hello 世界".to_string(), rect)), "and the draft stays where it is typed");
}
/// With Edit text, a right-click on a comment over a picture opens the comment's menu, and the
/// picture is not selected for its own.
#[test]
fn a_right_click_on_a_comment_over_a_picture_opens_its_menu() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
let shape = pdfcraft_engine::Shape::Rectangle { rect: [60.0, 190.0, 150.0, 230.0] };
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: "Reviewed".into(),
author: "Tester".into(),
})));
h.state_mut().views[0].comments.selected = None;
h.run_steps(3);
let on_it = page_at(&h, 0, 100.0, 210.0);
right_click_at(&mut h, on_it);
assert!(h.state().views[0].comments.selected.is_some(), "the comment is selected");
assert!(h.state().views[0].image_selection.is_none(), "the picture is not");
assert!(h.query_by_label("Properties…").is_some(), "the comment's menu opens");
}
/// In crop mode, a right-click on the next page goes to what lies there, though a clipped artwork's
/// whole frame reaches over it: crop mode ends and the page's menu opens.
#[test]
fn a_right_click_on_the_next_page_leaves_crop_mode() {
let mut h = picture_pages(true);
let on_two = page_at(&h, 1, 100.0, 40.0);
right_click_at(&mut h, on_two);
assert!(h.state().views[0].image_selection.is_none(), "crop mode ends");
assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu opens");
}
/// A crop grip takes a press as far off as its cursor shows: 9 px from the grip, outside the box.
#[test]
fn a_crop_grip_takes_a_press_within_its_reach() {
let mut h = picture_pages(true);
let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep);
let before = keep(&h);
// The box kept is the picture's frame shown, x 30..160, y 170..250: its upper left corner.
let press = page_at(&h, 0, 30.0, 250.0) - egui::vec2(9.0, 0.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(press + egui::vec2(8.0 * k as f32, 8.0 * k as f32));
h.run_steps(1);
}
h.drop_at(press + egui::vec2(32.0, 32.0));
h.run_steps(2);
assert_ne!(keep(&h), before, "the grip takes the drag");
}
/// With Add text, a click on another paragraph keeps both what was typed in the open paragraph and
/// the text being added: neither edit takes the other's place.
#[test]
fn add_text_keeps_a_changed_paragraph_and_a_draft_when_it_moves_on() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
let id = h.state().views[0].id;
let blocks = h.state().session.get(id).unwrap().text_blocks(0);
assert!(blocks.len() >= 2, "two paragraphs: {blocks:?}");
let centre = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (ax, ay) = centre(blocks[0].rect);
let (bx, by) = centre(blocks[1].rect);
let a = page_at(&h, 0, ax as f32, ay as f32);
let b = page_at(&h, 0, bx as f32, by as f32);
slipping_click(&mut h, a, a);
assert!(h.state().views[0].line_editor.is_some(), "the first paragraph is open");
h.event(egui::Event::Text("X".into()));
h.run_steps(3);
// A click on the page's empty part, below the picture, starts a box.
let blank = page_at(&h, 0, 180.0, 20.0);
slipping_click(&mut h, blank, blank);
assert!(h.state().views[0].line_editor.is_none(), "that click applied the paragraph");
h.event(egui::Event::Text("Added words".into()));
h.run_steps(3);
slipping_click(&mut h, b, b);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
let texts: Vec<String> = doc.text_blocks(0).iter().map(|b| b.text.clone()).collect();
let added: Vec<String> = doc
.added()
.iter()
.filter_map(|a| match &a.content {
pdfcraft_engine::AddedContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect();
assert!(added.iter().any(|t| t == "Added words"), "the added text is kept: {added:?}");
assert!(texts.iter().any(|t| t.contains('X')), "the paragraph's typing is kept: {texts:?}");
}
/// In the page grid, Delete deletes the page chosen there. The picture selected before the grid
/// opened is not on screen, and stays.
#[test]
fn delete_in_the_page_grid_deletes_the_page_and_not_the_hidden_picture() {
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let pictures = h.state().session.get(id).unwrap().page_images(0).len();
let pages = h.state().session.get(id).unwrap().info.pages.len();
assert!(h.state_mut().execute("page.organize"));
h.run_steps(4);
h.get_by_label("Select page 2").click();
h.run_steps(2);
h.key_press(egui::Key::Delete);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.info.pages.len(), pages - 1, "the page chosen in the grid is deleted");
assert_eq!(doc.page_images(0).len(), pictures, "the picture stays");
}
/// With the picture's page not on screen, its keys do nothing: Delete removes no picture the reader
/// can't see, and Enter applies no crop.
#[test]
fn a_selected_pictures_keys_wait_for_its_page_to_be_on_screen() {
use pdfcraft_ui_egui::canvas::PageLayout;
for (crop, key) in [(false, egui::Key::Delete), (true, egui::Key::Enter)] {
let mut h = picture_pages(crop);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0);
if crop {
let keep = before[0].visible_rect.unwrap();
let half = [keep[0], keep[1], (keep[0] + keep[2]) / 2.0, keep[3]];
h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = half;
}
h.state_mut().views[0].layout = PageLayout::Single;
h.state_mut().views[0].continuous = false;
h.state_mut().views[0].go_to_page(1);
h.run_steps(4);
assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen");
h.key_press(key);
h.run_steps(4);
let after = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(after.len(), before.len(), "the picture stays");
assert_eq!(after[0].visible_rect, before[0].visible_rect, "and is not cropped");
}
// A page just off the screen, in a continuous view: it is still run for what lies near it.
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let pictures = h.state().session.get(id).unwrap().page_images(0).len();
h.set_size(egui::vec2(1400.0, 400.0));
h.state_mut().views[0].go_to_page(1);
h.run_steps(6);
// Going to a page leaves a margin above it: a little further, past page 1's foot.
let centre = h.state().views[0].viewport_rect().center();
h.hover_at(centre);
h.run_steps(1);
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Trackpad,
});
h.run_steps(6);
assert!(h.state().views[0].continuous, "a continuous view");
assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen");
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays");
}
/// Delete waits for the button to come up: the press held would then name another item, whose drag or
/// click would follow.
#[test]
fn delete_waits_for_the_button_to_come_up() {
let hold_and_delete = |h: &mut Harness<'static, PdfCraftApp>, on: egui::Pos2| {
h.hover_at(on);
h.run_steps(1);
h.drag_at(on);
h.run_steps(1);
h.key_press(egui::Key::Delete);
h.run_steps(2);
h.drop_at(on);
h.run_steps(3);
};
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let pictures = h.state().session.get(id).unwrap().page_images(0).len();
let on_picture = page_at(&h, 0, 100.0, 180.0);
hold_and_delete(&mut h, on_picture);
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays");
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() },
}));
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(3);
let on_added = point_on(&h, 90.0, 258.0);
slipping_click(&mut h, on_added, on_added);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text is selected");
hold_and_delete(&mut h, on_added);
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().added().len(), 1, "the added text stays");
}
/// A drag the page it is over first sees lets the picked picture go, whichever page it began on.
#[test]
fn a_drag_first_seen_on_the_next_page_lets_the_picked_picture_go() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(3);
let on_picture = page_at(&h, 0, 100.0, 180.0);
slipping_click(&mut h, on_picture, on_picture);
assert!(h.state().views[0].picked_image.is_some(), "the picture is picked");
let press = page_at(&h, 0, 180.0, 20.0);
let release = page_at(&h, 1, 180.0, 280.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);
assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go");
}
/// With the pointer on the Pages panel, Delete is the panel's: a page goes, though a picture is
/// selected on the pages.
#[test]
fn delete_with_the_pointer_on_the_pages_panel_deletes_a_page() {
let mut h = picture_pages(false);
h.state_mut().set_option("panel", "pages").unwrap();
h.run_steps(4);
let id = h.state().views[0].id;
let pages = h.state().session.get(id).unwrap().info.pages.len();
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
assert!(h.state().views[0].page_screen_rect(0).is_some(), "on a page on screen");
let viewport = h.state().views[0].viewport_rect();
assert!(viewport.right() < 1300.0, "the panel lies right of the pages: {viewport:?}");
h.hover_at(egui::pos2(viewport.right() + 60.0, viewport.center().y));
h.run_steps(2);
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), pages - 1, "a page goes");
}
/// In crop mode, a right-click on another picture selects it for its menu, as without crop mode. It
/// went to crop mode's whole-page hit, which unselected every picture.
#[test]
fn in_crop_mode_a_right_click_on_another_picture_selects_it() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("two.pdf", None, two_image_fixture()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
let id = h.state().views[0].id;
let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect;
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(2);
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let other = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[1], &xf, &doc.info, 0);
crop_secondary_click(&mut h, other.center());
let selection = h.state().views[0].image_selection.clone();
assert!(selection.as_ref().is_some_and(|s| s.index == 1 && s.crop.is_none()), "the other picture is selected: {selection:?}");
assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens");
}
/// A drag from a hole in the selected picture (drawn through a clip with a hole in it) moves the
/// picture: its frame is its own.
#[test]
fn a_drag_from_a_hole_in_the_selected_picture_moves_it() {
// The picture shows at x 30..160, y 170..250, around a hole at x 70..120, y 190..230.
let mut h = picture_pages_of(artwork_drawn(b"q 30 170 130 80 re 70 190 50 40 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q"), false);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].rect;
let hole = page_at(&h, 0, 95.0, 210.0);
h.hover_at(hole);
h.run_steps(1);
h.drag_at(hole);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(hole + egui::vec2(5.0 * k as f32, 0.0));
h.run_steps(1);
}
h.drop_at(hole + egui::vec2(20.0, 0.0));
h.run_steps(4);
let after = h.state().session.get(id).unwrap().page_images(0)[0].rect;
assert!(after[0] - before[0] > 5.0, "the picture moved right: {before:?} → {after:?}");
}
/// A right-click on a crop grip past the page's edge, where those of a picture filling its page lie
/// half, is the crop's, as on the page: crop mode stays and no menu opens (#5835).
#[test]
fn a_right_click_on_a_crop_grip_past_the_page_edge_keeps_crop_mode() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(keep));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
crop_secondary_click(&mut h, r.right_bottom() + egui::vec2(4.0, 4.0));
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode stays");
for label in ["Flip horizontal", "Rotate view clockwise"] {
assert!(h.query_all_by_label(label).next().is_none(), "no menu: {label}");
}
}
/// Crop mode's grips reach past the page's edge, where those of a picture filling its page lie
/// half: a drag from just outside the page's corner trims the picture.
#[test]
fn a_crop_grip_past_the_page_edge_drags() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(before));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let outside = r.right_bottom() + egui::vec2(4.0, 4.0);
let to = r.right_bottom() - egui::vec2(r.width() / 4.0, r.height() / 4.0);
h.hover_at(outside);
h.run_steps(1);
h.drag_at(outside);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(outside + (to - outside) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(2);
let keep = h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep);
assert!(keep.is_some_and(|k| k[2] < before[2] - 1.0 && k[1] > before[1] + 1.0), "trimmed from the corner: {keep:?} of {before:?}");
}
/// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the
/// app's, not the pointer's.
#[test]
fn delete_removes_the_selected_picture_with_the_pointer_on_a_paragraph() {
let mut h = picture_pages(false);
let caption = page_at(&h, 0, 65.0, 134.0);
assert_eq!(cursor_over(&mut h, caption), egui::CursorIcon::Move, "on the caption's paragraph");
h.key_press(Key::Delete);
h.run_steps(3);
let id = h.state().views[0].id;
assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is deleted");
assert!(h.state().views[0].image_selection.is_none());
}
/// Delete on a selected added picture deletes it alone: the added text placed after it stays. The
/// key's second reader, added content's own Delete, took the text that the picture's index then
/// named.
#[test]
fn delete_on_a_selected_added_picture_deletes_it_alone() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText);
let mut png = Vec::new();
image::RgbImage::from_pixel(30, 20, image::Rgb([20, 90, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
h.state_mut().add_image("logo.png".into(), png);
h.run_steps(3);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [20.0, 20.0, 120.0, 35.0], text: "Caption".into(), ..Default::default() },
}));
h.run_steps(3);
let id = h.state().views[0].id;
let images = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(images, 1, "the added picture is the page's picture");
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
h.key_press(Key::Delete);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert!(doc.page_images(0).is_empty(), "the picture is deleted");
let texts: Vec<&str> = doc
.added()
.iter()
.filter_map(|a| if let pdfcraft_engine::AddedContent::Text(t) = &a.content { Some(t.text.as_str()) } else { None })
.collect();
assert_eq!(texts, ["Caption"], "and the text placed after it stays");
}
/// With a dialog open, Delete and Backspace are the dialog's: the selected picture stays.
#[test]
fn a_dialog_keeps_its_delete_while_a_picture_is_selected() {
let mut h = picture_pages(false);
let on_picture = page_at(&h, 0, 95.0, 210.0);
h.hover_at(on_picture);
h.run_steps(1);
assert!(h.state_mut().execute("app.preferences"));
h.run_steps(3);
for key in [Key::Delete, Key::Backspace] {
h.key_press(key);
h.run_steps(3);
}
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 1, "the picture stays under the dialog");
assert!(h.state().views[0].image_selection.is_some());
assert_eq!(h.state().dialog, Some(Dialog::Preferences));
}
/// A click that slips a few pixels between its press and its release, within egui's click distance.
fn slipping_click(h: &mut Harness<'static, PdfCraftApp>, press: egui::Pos2, release: egui::Pos2) {
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.drop_at(release);
h.run_steps(4);
}
/// [`harness`]'s page 1 in Edit text (`tool`), and the screen point at PDF (x, y) on it.
fn edit_page(tool: pdfcraft_ui_egui::QuickTool) -> Harness<'static, PdfCraftApp> {
let mut h = harness(2, |_| {});
h.state_mut().quick_tool = tool;
h.run_steps(3);
h
}
fn point_on(h: &Harness<'static, PdfCraftApp>, x: f64, y: f64) -> egui::Pos2 {
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k)
}
/// A click on a paragraph opens it where it was pressed, though the button came up just below its
/// box: an owner acts where its press was. It opened nothing.
#[test]
fn a_click_on_a_paragraph_released_just_below_it_opens_it() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
// The box reaches 2 px below the text: pressed 1 px inside it, released 3 px below it.
let bottom = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, block.rect[1]) + egui::vec2(0.0, 2.0);
slipping_click(&mut h, bottom - egui::vec2(0.0, 1.0), bottom + egui::vec2(0.0, 3.0));
assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing");
}
/// With the Select tool and the Edit panel open, a click on added text selects it where it was
/// pressed, though the button came up just beside it. It left nothing selected.
#[test]
fn a_click_on_added_text_released_just_beside_it_selects_it() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() },
}));
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(3);
// The item takes presses 2 px past its box: pressed 1 px inside that, released 3 px past it.
let edge = point_on(&h, 140.0, 258.0) + egui::vec2(2.0, 0.0);
slipping_click(&mut h, edge - egui::vec2(1.0, 0.0), edge + egui::vec2(3.0, 0.0));
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text pressed is selected");
}
/// With Add text, a click pressed beside a paragraph and released inside its box starts a new box
/// where it was pressed. It finished the text being typed and placed nothing.
#[test]
fn an_add_text_click_pressed_beside_a_paragraph_starts_a_box_there() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
// The box reaches 2 px past the text: pressed 2 px left of it, released 2 px inside it.
let left = point_on(&h, block.rect[0], (block.rect[1] + block.rect[3]) / 2.0) - egui::vec2(2.0, 0.0);
slipping_click(&mut h, left - egui::vec2(2.0, 0.0), left + egui::vec2(2.0, 0.0));
let draft = h.state().views[0].content.draft.as_ref().map(|d| d.rect);
let x = draft.map(|r| r[0]);
assert!(x.is_some_and(|x| x < block.rect[0]), "a new box at the press, left of the paragraph: {x:?} of {:?}", block.rect);
assert!(h.state().views[0].line_editor.is_none(), "the paragraph stays closed");
}
/// A paragraph drag in Edit text ends when Edit text's input is blocked (the window loses focus), as
/// a drag of the objects selected together does: nothing moves once the input comes back. It
/// waited, and moved the paragraph to wherever the pointer then was.
#[test]
fn a_paragraph_drag_ends_when_the_window_loses_focus() {
for tool in [pdfcraft_ui_egui::QuickTool::EditText, pdfcraft_ui_egui::QuickTool::AddText] {
let mut h = edit_page(tool);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
let from = point_on(&h, (before[0] + before[2]) / 2.0, (before[1] + before[3]) / 2.0);
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + egui::vec2(0.0, 10.0 * k as f32));
h.run_steps(1);
}
assert!(h.state().views[0].block_drag.is_some(), "the paragraph is being moved");
h.input_mut().focused = false;
h.run_steps(2);
h.drop_at(from + egui::vec2(0.0, 40.0));
h.run_steps(2);
h.input_mut().focused = true;
h.hover_at(from + egui::vec2(0.0, 80.0));
h.run_steps(4);
assert!(h.state().views[0].block_drag.is_none(), "the drag is over");
assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, before, "nothing moved");
assert!(!dirty(&h));
}
}
/// The rotate knob of a picture that fills its page lies past the page's edge, and a drag there
/// still turns the picture: the press is the page's whose knob it is.
#[test]
fn dragging_the_rotate_knob_past_the_page_edge_turns_the_picture() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// Above the top edge, or below the bottom one when the view has no room above.
let knob = [egui::pos2(r.center().x, r.top() - 22.0), egui::pos2(r.center().x, r.bottom() + 22.0)]
.into_iter()
.find(|p| cursor_over(&mut h, *p) == egui::CursorIcon::Grab)
.expect("the knob past the page's edge");
assert!(!r.contains(knob));
let to = egui::pos2(r.right() + 60.0, r.center().y);
h.hover_at(knob);
h.run_steps(1);
h.drag_at(knob);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(knob + (to - knob) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Rotate image"));
}
/// Organize pages has Acrobat's panel whenever the grid shows, however it was opened, and the
/// panel's Close ends Organize: the grid closes and All tools comes back.
#[test]
@@ -8021,6 +8867,34 @@ fn test_image_context_menu_crop_overlapping_image_and_handle() {
}
}
/// A right-click on the part of a picture its crop cuts away is the crop's, as one on the box kept:
/// crop mode stays and no menu opens (#5835).
#[test]
fn a_right_click_on_the_part_a_crop_cuts_away_keeps_crop_mode() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("cut.pdf", None, two_image_fixture()).unwrap();
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect;
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
// Keep the picture's left half.
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new([rect[0], rect[1], (rect[0] + rect[2]) / 2.0, rect[3]]));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(2);
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let b = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0);
assert!(b.width() > 60.0, "the picture is wide enough for a point clear of the grips: {b:?}");
crop_secondary_click(&mut h, b.lerp_inside(egui::vec2(0.8, 0.5)));
assert_crop_context_preserved(&h);
}
#[test]
fn test_image_context_menu_crop_page_edge_pills() {
for rotation in [0, 90, 180, 270] {
+119
View File
@@ -385,3 +385,122 @@ fn modifier_selection_picks_foreground_text_and_added_items_over_background_artw
assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}");
}
}
/// A press with Shift while a paragraph with changes is open is held until the button is up: it
/// says why, the paragraph keeps its text, and nothing under the press is moved or selected, not
/// even once the press turns into a drag.
#[test]
fn a_shift_click_with_a_changed_paragraph_open_shows_the_notice_and_moves_nothing() {
let mut h = harness();
let before = objects(&h);
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into();
let (a, b) = (at(&h, 60.0, 145.0), at(&h, 120.0, 200.0));
h.hover_at(a);
h.run_steps(2);
h.event_modifiers(
egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT },
Modifiers::SHIFT,
);
h.run_steps(2);
let notice = "Finish or discard the text being edited before selecting several objects.";
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains(notice)), "the notice says why");
for k in 1..=3 {
h.hover_at(a + (b - a) * (k as f32 / 3.0));
h.run_steps(2);
}
h.drop_at(b);
h.run_steps(4);
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("A draft still being edited"));
assert_eq!(objects(&h), before, "nothing moved");
assert!(h.state().views[0].image_selection.is_none() && h.state().views[0].content.selected.is_none(), "nothing selected");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
}
/// A press beside a picture, within the reach the objects selected together are found in but
/// outside the picture itself, is nobody's: it starts an area selection. It used to start nothing.
#[test]
fn a_press_just_outside_a_picture_starts_an_area_selection() {
let mut h = harness();
// The figure is x 40..100, y 130..160 from the page's top; the added note x 130..230, y 130..144.
let (a, b) = (at(&h, 38.5, 140.0), at(&h, 235.0, 175.0));
h.hover_at(a);
h.run_steps(2);
h.drag_at(a);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(4);
h.get_by_label("2 objects selected");
}
/// A double-click on one of the objects selected together leaves the group and reaches that one:
/// added text opens for typing.
#[test]
fn a_double_click_on_a_grouped_object_reaches_it() {
let mut h = harness();
marquee(&mut h);
// The added note is x 130..230, y 130..144 from the page's top.
let note = at(&h, 180.0, 137.0);
crate::support::input::double_click(&mut h, note);
h.run_steps(2);
assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "the added note opens for typing");
}
/// A double-click on a paragraph selected with others opens the one its press was on, though the
/// button came up just off it.
#[test]
fn a_double_click_on_a_grouped_paragraph_released_just_off_it_opens_it() {
let mut h = harness();
marquee(&mut h);
let (inside, outside) = {
let app = h.state();
let view = &app.views[0];
let xf = view.page_xform(0).unwrap();
let doc = app.session.get(view.id).unwrap();
let block = doc.text_blocks(0).into_iter().find(|b| b.text.contains("Group headline")).expect("the headline");
let frame = xf.user_rect(&doc.info, 0, block.rect.map(|v| v as f32));
(egui::pos2(frame.center().x, frame.top() + 1.0), egui::pos2(frame.center().x, frame.top() - 4.0))
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.hover_at(inside);
h.run_steps(1);
crate::support::input::one_frame(&mut h, [button(inside, true), button(inside, false)]);
crate::support::input::one_frame(&mut h, [button(inside, true), egui::Event::PointerMoved(outside), button(outside, false)]);
h.run_steps(2);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing");
}
/// A press counts its own modifiers, whatever the frame's are: Shift in the press event adds to the
/// selection, though the frame's modifiers show none.
#[test]
fn a_press_counts_the_modifiers_of_its_own_event() {
let mut h = harness();
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
assert!(h.state().views[0].line_editor.is_some());
let p = at(&h, 60.0, 145.0);
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT };
h.hover_at(p);
h.run_steps(2);
crate::support::input::one_frame(&mut h, [button(true), button(false)]);
h.run_steps(4);
h.get_by_label("2 objects selected");
}
/// A double-click on a paragraph selected with others opens it for typing (the user's ruling).
#[test]
fn a_double_click_on_a_grouped_paragraph_opens_it() {
let mut h = harness();
marquee(&mut h);
// "Group headline" is at x 40, its baseline y 430 of 500.
let headline = at(&h, 80.0, 65.0);
crate::support::input::double_click(&mut h, headline);
h.run_steps(2);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing");
}
+452 -30
View File
@@ -65,6 +65,47 @@ fn linked_text_pdf() -> Vec<u8> {
])
}
/// A 300×400 page with a picture at x 50..250, y 250..350 and a web link over its middle, at
/// x 100..200, y 280..320.
fn linked_picture_pdf() -> Vec<u8> {
let picture =
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream";
let content = stream("q 200 0 0 100 50 250 cm /Im0 Do Q");
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Im0 6 0 R >> >> /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [100 280 200 320] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
content.as_str(),
picture,
])
}
/// Two 300×400 pages, the first with a line of text at its bottom edge, at y 4, the second with one
/// near its top, at y 380.
fn bottom_line_pages_pdf() -> Vec<u8> {
let bottom = stream("BT /F1 12 Tf 40 4 Td (Bottom line) Tj ET");
let top = stream("BT /F1 12 Tf 40 380 Td (Top line) Tj ET");
pdf(&[
CATALOG,
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
bottom.as_str(),
top.as_str(),
HELVETICA,
])
}
/// A 300×400 page with a picture at x 50..250, y 250..350, and graphics states nested deeper than
/// the objects selected together are read through: they can't be selected together on this page.
fn ungroupable_picture_pdf() -> Vec<u8> {
let picture =
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream";
let content = stream(&format!("q 200 0 0 100 50 250 cm /Im0 Do Q {}{}", "q ".repeat(1030), "Q ".repeat(1030)));
pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", content.as_str(), picture])
}
/// A 300×400 page with a line of text at y≈300 and a rectangle comment over it.
fn comment_on_text_pdf() -> Vec<u8> {
let text = stream(LINE);
@@ -229,16 +270,13 @@ fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool {
}
/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from
/// worker threads), and returns the last cursor seen.
fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
/// worker threads, `support::step_until`), and returns the last cursor seen.
fn wait_for_cursor(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
let mut cursor = egui::CursorIcon::Default;
for _ in 0..50 {
let _ = crate::support::step_until(h, |h| {
cursor = cursor_at(h, p);
if want(cursor) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
want(cursor)
});
cursor
}
@@ -344,6 +382,35 @@ fn a_drag_from_text_onto_a_link_selects_the_text() {
assert!(selected.contains("example.org"), "the drag selected across the link: {selected:?}");
}
/// With the Select tool a click on a link over a picture follows the link and picks no picture; one
/// on the picture beside the link picks it. The link's click used to pick the picture under it too.
#[test]
fn a_click_on_a_link_over_a_picture_follows_it_and_picks_no_picture() {
let mut h = open(linked_picture_pdf(), "150", false);
let beside = at(&h, 70.0, 300.0);
mouse_click(&mut h, beside);
assert!(h.state().views[0].picked_image.is_some(), "the picture beside the link is picked");
h.state_mut().views[0].picked_image = None;
let on_link = at(&h, 150.0, 300.0);
mouse_click(&mut h, on_link);
assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the link is followed");
assert!(h.state().views[0].picked_image.is_none(), "and no picture is picked");
}
/// The I-beam drops over a link only where a press would go to it: with the Select tool the link's
/// hand shows there, and with the highlighter, whose drag selects the link's text, the I-beam. The
/// highlighter's I-beam used to drop over every link.
#[test]
fn the_i_beam_drops_over_a_link_only_where_the_press_goes_to_it() {
let mut h = open(linked_text_pdf(), "150", false);
let (plain, linked) = (at(&h, 200.0, 303.0), at(&h, 100.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::PointingHand, "the Select tool follows the link");
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight);
h.run_steps(2);
assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::Text, "the highlighter selects its text");
}
/// Over two comments the tip names the one drawn on top (#3620), and a click should select that
/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in
/// inspection's order, which is by top edge, and selects the one underneath.
@@ -363,7 +430,7 @@ fn a_comments_tip_shows_with_every_tool_beside_its_cursor() {
let mut h = open(comment_on_text_pdf(), "150", false);
// Inside the rectangle comment by "Reviewer": where there is no text, and over a word.
let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0));
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
let cursor = wait_for_cursor(h, p, |c| c == want);
@@ -1396,6 +1463,114 @@ fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() {
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
}
/// A right-button drag from `a` to `b`, in frames of their own.
fn right_drag(h: &mut Harness<'_, PdfCraftApp>, a: egui::Pos2, b: egui::Pos2) {
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE };
h.hover_at(a);
h.run_steps(1);
h.event(button(a, true));
h.run_steps(1);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.run_steps(1);
}
h.event(button(b, false));
h.run_steps(3);
}
// A right-button drag starts nothing a left-button drag does. egui's drag start counts every
// button, and each of these started on it.
/// With the Rectangle tool, a right-button drag draws no rectangle.
#[test]
fn a_right_button_drag_draws_no_rectangle() {
let mut h = open(text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
h.run_steps(4);
let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0));
right_drag(&mut h, a, b);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no rectangle is drawn");
}
/// With Prepare open, a right-button drag around a field selects it by no selection rectangle.
#[test]
fn a_right_button_drag_selects_no_field() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
let (a, b) = (at(&h, 60.0, 250.0), at(&h, 140.0, 180.0));
right_drag(&mut h, a, b);
assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected");
}
/// With the Link tool, a right-button drag draws no link.
#[test]
fn a_right_button_drag_draws_no_link() {
let mut h = open(text_pdf(), "150", false);
link_tool(&mut h);
let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0));
right_drag(&mut h, a, b);
assert!(h.state().link_draft.is_none() && h.state().dialog != Some(Dialog::LinkProps), "no link is drawn");
}
/// With the camera, a right-button drag on another page leaves the quadrilateral where it is.
#[test]
fn a_right_button_drag_leaves_the_camera_quadrilateral() {
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.as_ref().map(|q| q.page), Some(0));
let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
right_drag(&mut h, two.min + egui::vec2(40.0, 40.0), two.min + egui::vec2(120.0, 100.0));
assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0), "the quadrilateral stays on page 1");
}
/// A text selection, then `tool` and a right-button drag across more of the line. The harness, and
/// the text selected before the drag.
fn right_drag_over_a_selection(tool: QuickTool) -> (Harness<'static, PdfCraftApp>, Option<String>) {
let mut h = open(text_pdf(), "150", false);
let (a, b) = (at(&h, 45.0, 303.0), at(&h, 120.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
mouse_drag(&mut h, a, b - a);
let selected = h.state().views[0].selected_text();
assert!(selected.is_some(), "some text is selected");
h.state_mut().quick_tool = tool;
h.run_steps(2);
let (c, d) = (at(&h, 150.0, 303.0), at(&h, 230.0, 303.0));
right_drag(&mut h, c, d);
(h, selected)
}
/// With the Select tool, a right-button drag leaves the text selected as it is. It moved the
/// selection's end to the pointer.
#[test]
fn a_right_button_drag_leaves_a_text_selection_alone() {
let (h, selected) = right_drag_over_a_selection(QuickTool::Select);
assert_eq!(h.state().views[0].selected_text(), selected, "the selection stays as it was");
}
/// A right-button drag with a markup tool or Redact turns no selection left from another tool into
/// a comment: its end finished the selection whichever button it was.
#[test]
fn a_right_button_drag_makes_no_mark_of_a_text_selection() {
for tool in [QuickTool::Comment(CommentTool::Highlight), QuickTool::Redact] {
let (h, _) = right_drag_over_a_selection(tool);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no mark with {tool:?}");
}
}
/// With the Select tool, a right-button drag across text selects none.
#[test]
fn a_right_button_drag_selects_no_text() {
let mut h = open(text_pdf(), "150", false);
let (a, b) = (at(&h, 45.0, 303.0), at(&h, 200.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
right_drag(&mut h, a, b);
assert!(h.state().views[0].selected_text().is_none(), "no text is selected");
}
/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350.
fn edge_link_pdf() -> Vec<u8> {
pdf(&[
@@ -1418,19 +1593,42 @@ fn the_link_tools_hand_stops_at_the_pages_edge() {
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. 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.
/// With Edit text a right-click goes to what lies on top under it, as with every tool: on a
/// paragraph under a comment, the comment, selected for its menu; on the paragraph beside it, the
/// paragraph, and the comment is unselected. The page's menu is anchored there either way (Paste on
/// the picture menu, Delete page). Edit text used to keep the right-clicks on its paragraphs, and
/// left the comment over them unselected.
#[test]
fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() {
fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_the_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);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment over the paragraph is selected");
assert!(h.query_by_label("Properties…").is_some(), "its menu opens");
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page");
// The rectangle ends at x 200; the line goes on.
let beside = at(&h, 230.0, 303.0);
right_click(&mut h, beside);
assert_eq!(h.state().views[0].comments.selected, None, "the paragraph beside the comment leaves it unselected");
}
/// With Edit text, a right-click on added text over a comment selects no comment: what has no menu
/// gives way to a picture under it and to nothing else, as before.
#[test]
fn a_right_click_with_edit_text_on_added_text_over_a_comment_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().execute("edit.edit_text"));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [110.0, 296.0, 190.0, 311.0], text: "Added".into(), ..Default::default() },
}));
h.run_steps(4);
let on_added = at(&h, 150.0, 303.0);
right_click(&mut h, on_added);
assert_eq!(h.state().views[0].comments.selected, None, "the comment under the added text is not selected");
}
/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not
@@ -1518,6 +1716,29 @@ fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() {
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected");
}
/// A comment's move ends once its button is up, though the pages missed its release, and applies
/// nothing: where it came up is not known, and the pointer has moved on. A drag elsewhere later moves
/// nothing either. The move waited for the next drag's end on the page, and took the comment there.
#[test]
fn a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up() {
let mut h = open(stacked_comments_pdf(), "150", false);
let rect = |h: &Harness<'_, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.index == 0).map(|a| a.rect)
};
let before = rect(&h);
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 elsewhere = at(&h, 250.0, 100.0);
h.hover_at(elsewhere);
h.run_steps(2);
assert!(h.state().views[0].comments.gesture.is_none(), "the move is over");
assert_eq!(rect(&h), before, "and moved nothing to where the pointer turned up");
let blank = at(&h, 250.0, 60.0);
mouse_drag(&mut h, blank, egui::vec2(30.0, 0.0));
assert_eq!(rect(&h), before, "nothing moved the comment");
}
/// 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]
@@ -1591,8 +1812,8 @@ 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
/// its own paragraphs and pictures): the selected comment is unselected.
/// With Edit text a right-click on added text reaches comments, as before: 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);
@@ -1673,6 +1894,198 @@ fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() {
assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected");
}
/// With Prepare open, a drag from a highlight draws the selection rectangle: a highlight lets a drag
/// go, so nobody holds it and it is Prepare's.
#[test]
fn with_prepare_a_drag_from_a_highlight_draws_the_selection_rectangle() {
let mut h = open(highlighted_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
// On the highlight, beside the box; the drag ends past the box's other corner.
let (from, to) = (at(&h, 97.0, 197.0), at(&h, 130.0, 230.0));
mouse_drag(&mut h, from, to - from);
assert!(h.state().views[0].prepare.selected.is_some(), "the rectangle selects the field inside it");
}
/// A right-click while the primary button is held opens the menu of the comment it went to, when that
/// is the one selected, and leaves the selection as it is.
#[test]
fn a_right_click_with_the_button_held_opens_the_selected_comments_menu() {
let mut h = open(comment_on_text_pdf(), "150", false);
// On the rectangle's left edge: an unfilled one takes a press on its border.
let edge = at(&h, 30.0, 303.0);
wait_for_cursor(&mut h, edge, |c| c != egui::CursorIcon::Default);
mouse_click(&mut h, edge);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
let moved = edge + egui::vec2(20.0, 0.0);
h.hover_at(moved);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: moved, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
assert!(h.query_by_label("Properties…").is_some(), "the comment's menu");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the selection stays");
h.drop_at(moved);
h.run_steps(2);
}
/// With the page gaps hidden, a Shift-click on the row two pages share is the first page's: what it
/// selects there stays selected, and the second page leaves it alone.
#[test]
fn a_shift_click_where_two_pages_touch_keeps_what_it_selected() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
assert!(h.state_mut().execute("edit.edit_text"));
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"))
};
// "Bottom line" runs from x 40, a few points above page 1's foot.
let seam = egui::pos2(one.min.x + 70.0, one.max.y);
assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}");
h.hover_at(seam);
h.run_steps(2);
for pressed in [true, false] {
h.event_modifiers(
egui::Event::PointerButton { pos: seam, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT },
egui::Modifiers::SHIFT,
);
}
h.run_steps(4);
assert!(h.query_by_label_contains("1 object").is_some(), "the line is selected");
}
/// With Prepare open on a document that can't be changed, a right-click on a field selects nothing
/// and opens the page's menu, whose field entries would only be refused.
#[test]
fn with_prepare_on_a_locked_document_a_right_click_on_a_field_selects_nothing() {
let locked = crate::editing::encrypted(comment_and_checkbox_pdf(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1 ^ 8);
let mut h = open(locked, "150", false);
// Prepare doesn't open on such a document; it stays open on a tab switched to one.
h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("form");
h.state_mut().left_open = true;
h.run_steps(4);
assert!(h.state().is_preparing(), "Prepare is open");
let id = h.state().views[0].id;
assert!(!h.state().session.get(id).unwrap().allows_modification(), "the document can't be changed");
let on_box = at(&h, 110.0, 210.0);
right_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected");
assert!(h.query_by_label("Duplicate…").is_none(), "no field menu");
}
/// With Prepare open and a field selected, a right-click on the empty page opens the page's menu, and
/// one on the field the field's: the menu is what the right-click went to. The field's menu opened
/// wherever the page was right-clicked.
#[test]
fn with_prepare_a_right_click_beside_the_selected_field_opens_the_pages_menu() {
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 blank = at(&h, 60.0, 300.0);
right_click(&mut h, blank);
assert!(h.query_by_label("Duplicate…").is_none(), "no field menu on the empty page");
assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu");
h.key_press(egui::Key::Escape);
h.run_steps(3);
right_click(&mut h, on_box);
assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu on the field");
}
/// With a Prepare field tool picked, a right-click on a field selects it and opens its menu, as with
/// Prepare's Select tool: the tool's hit leaves fields out (it draws new ones over them), but a
/// right-click reaches them.
#[test]
fn with_a_field_tool_a_right_click_on_a_field_opens_its_menu() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
assert!(h.state_mut().execute("form.add.text"));
h.run_steps(4);
let on_box = at(&h, 110.0, 210.0);
right_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected");
assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu");
}
/// With Edit text, a right-click on a picture under a link is the picture's (Edit text doesn't
/// follow links): it is selected for its menu.
#[test]
fn with_edit_text_a_right_click_on_a_picture_under_a_link_selects_the_picture() {
let mut h = open(linked_picture_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let on_link = at(&h, 150.0, 300.0);
right_click(&mut h, on_link);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture is selected for its menu");
}
/// With the gaps between pages hidden, a drag from the top of page 2 under page 1's open paragraph is
/// page 2's: its area selection takes the line there. The paragraph's frame, which reaches past
/// page 1's edge, took the press and did nothing with it.
#[test]
fn a_drag_past_a_pages_edge_under_its_open_paragraph_is_the_next_pages() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
h.state_mut().quick_tool = QuickTool::EditText;
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 line = egui::pos2(one.min.x + 60.0, one.max.y - 8.0);
mouse_click(&mut h, line);
assert!(h.state().views[0].line_editor.is_some(), "the bottom line is open");
let below = egui::pos2(one.min.x + 60.0, two.min.y + 3.0);
mouse_drag(&mut h, below, egui::vec2(200.0, 30.0));
// What the area selection took, Delete removes.
h.key_press(egui::Key::Delete);
h.run_steps(4);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().text_blocks(1).is_empty(), "page 2's area selection took its line");
}
/// With Edit text, a click on the empty page leaves the selected picture unselected, on a page whose
/// objects can't be selected together too, where no area selection starts to do it. It stayed.
#[test]
fn a_click_beside_the_picture_unselects_it_where_objects_cant_be_grouped() {
let mut h = open(ungroupable_picture_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.page_images(0).len(), 1, "the picture");
assert!(doc.editable_objects(0).is_err(), "and no objects to group");
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(2);
let blank = at(&h, 150.0, 100.0);
mouse_click(&mut h, blank);
assert!(h.state().views[0].image_selection.is_none(), "the picture is unselected");
}
/// With the Select tool a drag that starts on a link lets the picked picture go, as a drag elsewhere
/// does, though the link's click picks none.
#[test]
fn a_drag_from_a_link_lets_the_picked_picture_go() {
let mut h = open(linked_picture_pdf(), "150", false);
let beside = at(&h, 70.0, 300.0);
mouse_click(&mut h, beside);
assert!(h.state().views[0].picked_image.is_some(), "the picture is picked");
let on_link = at(&h, 150.0, 300.0);
mouse_drag(&mut h, on_link, egui::vec2(30.0, 0.0));
assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go");
}
/// 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]
@@ -1691,8 +2104,9 @@ fn a_note_tool_click_on_added_text_starts_no_note() {
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.
/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a press held
/// through its move and released places no new mark. (A move left over once the button is up ends:
/// `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.)
#[test]
fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() {
let mut h = open(comment_on_text_pdf(), "150", false);
@@ -1704,8 +2118,13 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() {
};
let before = marks(&h);
let blank = at(&h, 150.0, 100.0);
h.hover_at(blank);
h.run_steps(1);
h.drag_at(blank);
h.run_steps(1);
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);
h.drop_at(blank);
h.run_steps(4);
assert_eq!(marks(&h), before, "no mark is placed");
}
@@ -1757,11 +2176,12 @@ fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments
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.
/// Where pages touch, a right-click on the row they share is page 1's, whatever lies there on page 2:
/// page 2's picture across its top edge opens no menu, and page 1's menu pastes on page 1, where it
/// opened. The page view's own picture scan used to open page 2's picture's menu there, whose Paste
/// centred 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() {
fn paste_from_a_right_click_where_two_pages_touch_keeps_to_the_first_page() {
let (mut h, seam) = touching_pages(|app| {
app.system_clipboard = false;
app.quick_tool = QuickTool::EditText;
@@ -1774,16 +2194,18 @@ fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_poi
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();
assert!(h.state().views[0].image_selection.is_none(), "page 2's picture is not selected for its menu");
h.get_by_label_contains("Paste").click();
h.run_steps(3);
let pasted = {
let images = |page| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().page_images(1)
s.session.get(s.views[0].id).unwrap().page_images(page)
};
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:?}");
assert_eq!(images(1).len(), 1, "nothing is pasted on page 2");
let pasted = images(0);
assert_eq!(pasted.len(), 1, "the image is pasted on page 1");
let r = pasted[0].rect;
assert!((r[1] + r[3]) / 2.0 < 100.0, "at page 1's foot, where its menu opened: {r:?}");
}
/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it
+101 -2
View File
@@ -193,7 +193,7 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool {
#[test]
fn selected_text_copy_edit_and_context_menus() {
use egui_kittest::kittest::Queryable;
let (mut h, at) = harness(|_| {});
let (mut h, at) = harness(|app| app.system_clipboard = false);
click(&mut h, at);
click(&mut h, at);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand;
@@ -245,7 +245,7 @@ fn escape_closes_an_open_menu_before_it_clears_the_selection() {
#[test]
fn selected_text_copy_command_requires_selection() {
let (mut h, at) = harness(|_| {});
let (mut h, at) = harness(|app| app.system_clipboard = false);
assert!(!h.state_mut().execute("edit.copy"));
click(&mut h, at);
click(&mut h, at);
@@ -277,6 +277,7 @@ fn selected_text_copy_floating_button_honours_permissions() {
app.open_bytes("no-copy.pdf", None, bytes).unwrap();
app.submit_password(Some(password.into()));
app.set_option("left", "closed").unwrap();
app.system_clipboard = false;
app
});
let mut loaded = false;
@@ -301,6 +302,104 @@ fn selected_text_copy_floating_button_honours_permissions() {
}
}
/// What the stand-in for the system clipboard was given: `(html, plain text)` per copy.
type HtmlSets = std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>;
/// The system clipboard on, through a stand-in that takes or refuses Copy with formatting.
fn html_clipboard(app: &mut PdfCraftApp, takes: bool) -> HtmlSets {
let sets = HtmlSets::default();
let seen = sets.clone();
app.system_clipboard = true;
app.html_clipboard_override = Some(Box::new(move |html, text| {
seen.lock().unwrap().push((html.to_owned(), text.to_owned()));
takes
}));
sets
}
fn any_copy_text(h: &Harness<'static, PdfCraftApp>) -> bool {
h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_)))
}
/// Copy with formatting: Copy puts the selection as HTML (font, size) and its plain text on the
/// system clipboard in one set, and doesn't also copy the text through egui, which would
/// replace the HTML. A new copy replaces it.
#[test]
fn selected_text_copy_keeps_the_formatting_as_html() {
let (mut h, at) = harness(|_| {});
let sets = html_clipboard(h.state_mut(), true);
click(&mut h, at);
click(&mut h, at);
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert_eq!(
sets.lock().unwrap().as_slice(),
[("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:14pt\">quick</span></p>\n".to_owned(), "quick".to_owned())]
);
assert!(!any_copy_text(&h), "egui's own text copy would replace the HTML");
// Three clicks: the line, a new copy.
h.run_steps(60);
for _ in 0..3 {
click(&mut h, at);
}
assert!(h.state_mut().execute("edit.copy"));
let last = sets.lock().unwrap().last().cloned().unwrap_or_default();
assert!(last.0.contains(">The quick brown fox</span>"), "{last:?}");
assert_eq!(last.1, "The quick brown fox");
}
/// When the system clipboard refuses the HTML, Copy still copies the plain text.
#[test]
fn selected_text_copy_falls_back_to_plain_text_when_html_is_refused() {
let (mut h, at) = harness(|_| {});
let sets = html_clipboard(h.state_mut(), false);
click(&mut h, at);
click(&mut h, at);
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert_eq!(sets.lock().unwrap().len(), 1, "the HTML was offered");
assert!(copied(&h, "quick"), "the plain text goes out the usual way");
}
/// With the system clipboard off, no HTML is built and the plain text goes out as before.
#[test]
fn selected_text_copy_without_system_clipboard_builds_no_html() {
let (mut h, at) = harness(|_| {});
let sets = html_clipboard(h.state_mut(), true);
h.state_mut().system_clipboard = false;
click(&mut h, at);
click(&mut h, at);
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert!(sets.lock().unwrap().is_empty());
assert!(copied(&h, "quick"));
}
/// A document that withholds copying gives neither the text nor its HTML.
#[test]
fn selected_text_copy_with_formatting_honours_permissions() {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(two_lines())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "pw",
owner_password: "owner",
permissions: -1 ^ 0b1_0000,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
let (mut h, at) = harness_with(bytes, |app| app.submit_password(Some("pw".into())));
let sets = html_clipboard(h.state_mut(), true);
click(&mut h, at);
click(&mut h, at);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
assert!(!h.state_mut().execute("edit.copy"));
h.step();
assert!(!any_copy_text(&h));
assert!(sets.lock().unwrap().is_empty());
}
#[test]
fn selected_text_copy_preserves_unicode_and_page_clipboard() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
+12
View File
@@ -136,6 +136,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
### Driving it from the page (`window.pdfcraft`)
`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped.
```js
const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog
await pdfcraft.request("ui.set", { key: "show_page_edge_shadows", value: "false" });
const { data } = await pdfcraft.screenshot(); // the window as PNG (base64)
```
Opening a PDF, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
## Renderer selection and fallback flags
PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
+9 -9
View File
@@ -70,8 +70,8 @@ milestone = "M1"
status = "shipped"
commands = []
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/tests/it/generations.rs::contradictory_xref_generation_recovers_the_matching_header_and_records_repair", "crates/cos/tests/it/generations.rs::generation_repair_diagnostics_are_bounded_without_disabling_recovery", "crates/cos/tests/it/generations.rs::retained_generation_disagreements_are_reported_in_materialized_mode_too", "crates/cos/tests/it/generations.rs::hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them", "crates/cos/tests/it/generations.rs::discarded_xref_repair_notes_do_not_survive_object_header_reconstruction"]
notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them. COS records bounded live xref/header generation disagreements on open, including retained materialized and hybrid rows before folding; this follow-up validates the COS policy, without a fresh GUI claim."
evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/tests/it/generations.rs::contradictory_xref_generation_recovers_the_matching_header_and_records_repair", "crates/cos/tests/it/generations.rs::generation_repair_diagnostics_are_bounded_without_disabling_recovery", "crates/cos/tests/it/generations.rs::retained_generation_disagreements_are_reported_in_materialized_mode_too", "crates/cos/tests/it/generations.rs::hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them", "crates/cos/tests/it/generations.rs::discarded_xref_repair_notes_do_not_survive_object_header_reconstruction", "crates/cos/src/document/tests.rs::unreadable_objects_are_listed_with_the_reason", "crates/automation/tests/it/unreadable_objects.rs::doc_info_lists_an_object_that_could_not_be_read"]
notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them, and lists apart (unreadable_objects) each object that could not be read and reads as null, with its number and why: only objects read so far, including any object a scan, save or audit read, at most 100; a stream whose own data fails to decode is not listed, but the objects of an object stream that cannot be decoded are. COS records bounded live xref/header generation disagreements on open, including retained materialized and hybrid rows before folding; this follow-up validates the COS policy, without a fresh GUI claim."
[[feature]]
id = "core.xref-streams"
@@ -1571,11 +1571,11 @@ title = "Copy text keeping layout"
area = "B"
tier = "P2"
milestone = "M3"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["edit.copy"]
tools = ["text_extract"]
evidence = ["crates/export/src/clip.rs::heading_body_and_coloured_span_keep_their_formatting", "crates/export/src/clip.rs::special_characters_are_escaped_and_cannot_leave_the_style", "crates/export/src/clip.rs::empty_and_huge_selections_are_bounded", "crates/export/src/clip.rs::invisible_controls_and_bidi_overrides_are_dropped", "crates/export/src/clip.rs::font_sizes_stay_within_range", "crates/engine/src/rich_copy.rs::copy_keeps_headings_body_and_colour_as_html", "crates/engine/src/rich_copy.rs::copy_with_formatting_is_refused_when_copying_is_withheld", "crates/engine/src/rich_copy.rs::running_out_of_match_steps_copies_the_rest_unstyled", "crates/engine/src/rich_copy.rs::a_style_change_at_a_line_end_stays_in_its_paragraph", "crates/engine/src/rich_copy.rs::a_style_change_into_the_next_column_starts_a_paragraph", "crates/engine/src/rich_copy.rs::a_styled_word_inside_a_paragraph_keeps_its_style", "crates/engine/src/rich_copy.rs::a_capped_copy_is_marked_truncated", "crates/engine/src/rich_copy.rs::without_an_editor_the_styles_are_incomplete", "crates/ui-egui/tests/it/column_select.rs::a_column_selection_copies_plain_text_only", "crates/automation/tests/it/automation.rs::text_extract_as_html_keeps_the_formatting", "crates/automation/src/tests.rs::text_extract_html_has_one_budget_across_pages", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_the_formatting_as_html", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_falls_back_to_plain_text_when_html_is_refused", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_with_formatting_honours_permissions"]
notes = "Copy of a page-text selection also puts HTML on the system clipboard (paragraphs, bold, italic, size, colour, font family; plain text as the alternative), and text_extract format html returns the same. Missing: RTF, underline and strike-through, tables, Select All of the whole document (plain text only), the web build (plain text only), styles for glyphs outside the page's reading blocks (copied unstyled), and HTML for a column selection (plain text only; text_extract with rect gives its HTML in line order, not as the column's rows)"
[[feature]]
id = "view.snapshot"
@@ -2047,8 +2047,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.text"]
tools = ["page_add_text", "content_update"]
evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/edit/src/tests.rs::gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured", "crates/automation/tests/it/automation.rs::added_text_takes_a_cmyk_or_gray_ink_through_tools", "crates/ui-egui/src/editing/tests.rs::recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step"]
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. The colour is RGB, or for print files gray or CMYK (#6440: page_add_text / content_update gray or cmyk, painted with g / k so black can be 100 K, kept through save and reopen; choosing another colour later, also from the Format panel, drops the ink for good and paints in RGB, while choosing the colour the ink already shows (black on 100 K) keeps it; content_update color always paints in RGB). Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
[[feature]]
id = "edit.find-replace"