automation: text_find stops reading pages once its limit is reached (#971) (#990)

text_find extracted (and cached) the text of every page before looking
for the first match, so `limit: 1` on a 9,000-page manual meant a whole
document extraction. It now reads pages in batches (8, doubling to 128)
and stops as soon as it has one match past the limit. The result says
`complete`, and a capped result carries `next` ({start_page, skip}),
which the new `start_page` and `skip` arguments accept to continue.

Fixes items 1 and 3 of #971 (and item 4 for pages after the limit).

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
DWAA1660 2026-10-11 11:24:58 -06:00 committed by GitHub
parent be94112c8e
commit 4582f48ec1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 121 additions and 10 deletions

View File

@ -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.

View File

@ -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<u8>>, Vec<Option<Arc<PageText>>>);
@ -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<Value> {
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::<Vec<_>>())?;
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<usize> = (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<Value> {
@ -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<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (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::<usize>();
// #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();

View File

@ -168,10 +168,12 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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(