diff --git a/ROADMAP.md b/ROADMAP.md index 2f1d6417..864dcd9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted. - **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged. - **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. - **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 0ccc71c8..6764f773 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -89,6 +89,11 @@ const WATERMARK_MAX_OFFSET: f64 = 20_000.0; const BOOKMARK_PAGE: usize = 100; const MAX_BOOKMARK_PAGE: usize = 1000; +/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a +/// match near the start returns without touching the rest of a long document (#971). +const FIND_FIRST_BATCH: usize = 8; +const FIND_MAX_BATCH: usize = 128; + /// Page texts of one document version: (the working bytes, one slot per page). type TextCache = (Arc>, Vec>>); @@ -1573,22 +1578,46 @@ impl Automation { Ok(json!({ "pages": out })) } + /// Matches in document order, extracting page text in growing batches and stopping as soon as + /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document + /// (#971). One match past the limit is looked for, so `complete` is true only when no more + /// exist; otherwise `next` holds the arguments that continue the search. fn text_find(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, n) = (doc.id, doc.info.pages.len()); let query = a.str("query")?; - let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; - let texts = self.page_texts(id, &(0..n).collect::>())?; + let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); + let start = match a.opt_int("start_page")? { + None => 0, + Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), + Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), + }; + let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); let mut matches = Vec::new(); - 'pages: for (p, text) in texts.iter().enumerate() { - for r in text.find(query) { - if matches.len() == limit { - break 'pages; + let mut next = None; + let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + 'search: while first < n { + let end = first.saturating_add(batch).min(n); + let pages: Vec = (first..end).collect(); + let texts = self.page_texts(id, &pages)?; + for (p, text) in pages.iter().zip(&texts) { + let skip_here = if *p == start { skip } else { 0 }; + for (k, r) in text.find(query).into_iter().enumerate().skip(skip_here) { + if matches.len() == limit { + next = Some(json!({ "start_page": p + 1, "skip": k })); + break 'search; + } + matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); } - matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); } + first = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); } - Ok(json!({ "query": query, "count": matches.len(), "matches": matches })) + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none(), "matches": matches }); + if let (Some(next), Some(o)) = (next, out.as_object_mut()) { + o.insert("next".into(), next); + } + Ok(out) } fn command_list(&self, a: &Args) -> Result { @@ -2036,6 +2065,85 @@ mod tests { use super::*; use pdfcraft_platform::staging::STAGING_ATTEMPTS; + /// `n` pages, page i showing "Page i". + fn numbered_pages(n: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(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 + } + + fn json(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args).map(|mut c| c.remove(0)) { + Ok(Content::Json(v)) => v, + other => panic!("{tool} {args}: {other:?}"), + } + } + + #[test] + fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone(); + let extracted = |a: &Automation| a.texts.values().map(|(_, pages)| pages.iter().filter(|t| t.is_some()).count()).sum::(); + + // #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages + // aren't extracted or cached. + let first = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 1", "limit": 1 })); + assert_eq!(first["count"], 1); + assert_eq!(first["matches"][0]["page"], 1); + assert_eq!(first["complete"], false, "{first}"); + assert_eq!(first["next"], json!({ "start_page": 10, "skip": 0 })); + assert!(extracted(&a) <= 3 * FIND_FIRST_BATCH, "{} pages extracted for one match", extracted(&a)); + + // Following `next` returns every match exactly once, in order, and then says it's complete. + // "1" appears twice on pages like 11 and 101, so continuing has to skip within a page. + let all = json(&mut a, "text_find", json!({ "doc": doc, "query": "1", "limit": 10_000 })); + assert_eq!(all["complete"], true); + assert!(all.get("next").is_none()); + let mut pieced = Vec::new(); + let mut args = json!({ "doc": doc, "query": "1", "limit": 3 }); + loop { + let found = json(&mut a, "text_find", args.clone()); + pieced.extend(found["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone()))); + if found["complete"] == true { + break; + } + args["start_page"] = found["next"]["start_page"].clone(); + args["skip"] = found["next"]["skip"].clone(); + } + let expected: Vec<_> = all["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())).collect(); + assert_eq!(all["count"], expected.len()); + assert_eq!(pieced, expected); + + // A search that runs to the end below its limit is complete; a start page past the end is refused. + let few = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 200", "start_page": 150 })); + assert_eq!((few["count"].clone(), few["complete"].clone()), (json!(1), json!(true))); + assert!(a.call("text_find", &json!({ "doc": doc, "query": "x", "start_page": 201 })).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { let base = std::env::temp_dir(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d63522fe..b401f821 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -168,10 +168,12 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match with its page and line rectangles in points (origin top-left).") + t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match, in page order, with its page and line rectangles in points (origin top-left). Pages are read only until the limit is reached.") .ro() .cmd("edit.find") - .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500)." } }), &["doc", "query"])), + .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). The search stops once it has this many; `complete` says whether every match was returned, and when it is false `next` holds the start_page and skip that continue the search." }, + "start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." }, + "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])), t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.") .cmd("page.rotate") .with(schema(