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:
parent
be94112c8e
commit
4582f48ec1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue