diff --git a/crates/automation/README.md b/crates/automation/README.md index 2752c635..24bca916 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -28,6 +28,35 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. +## Text search and cache limits + +Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned +text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector +capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the +renderer result instead of copying glyphs. Oversized pages remain usable by the current call but +are not cached; evicted pages can be extracted again. A changed document version invalidates its +cached text on the next text request, and closing a document removes its entries. Cache entries +do not keep an old PDF byte buffer alive. + +This bounds retained text-cache data, not total process memory: active extraction batches, +renderers, document buffers and the requested output have separate lifetimes. Search still reads +growing batches (8 to 128 pages), with one lookahead match to determine continuation. + +`text_find` keeps matches from readable pages when another reached page fails extraction. It +returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and +`warnings_truncated`. At most 100 warnings and 1,024 UTF-8 bytes per error are returned. Failures +are retried on later calls; they are not cached as empty pages. Warnings describe pages visited in +search order, up to the continuation point, not failures in unvisited later pages. + +`complete: true` means the requested search was exhausted with no page failures and no further +matches. `next: {start_page, skip}` exists only when another match would exceed `limit`. A search +that reaches the end with warnings therefore has `complete: false` **without** `next`. Callers +should follow `next` when present and separately inspect warnings. A capped search can return +both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged. + +`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed +semantics; they never silently act on partial text. + ## Shared field properties `form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names): diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5f5eaebb..2f9d439a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -25,6 +25,7 @@ mod printing; mod reading; mod redact; mod signing; +mod text_cache; mod tools; use std::collections::HashMap; @@ -96,8 +97,11 @@ const MAX_BOOKMARK_PAGE: usize = 1000; 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>>); +use text_cache::TextCache; + +type PageTexts = Vec<(usize, std::result::Result, String>)>; +const FIND_MAX_WARNINGS: usize = 100; +const FIND_MAX_WARNING_BYTES: usize = 1024; /// A headless PdfCraft session driven by tool calls. pub struct Automation { @@ -105,8 +109,8 @@ pub struct Automation { root: Option, /// Synchronous renderers, rebuilt when a document's working bytes change. renderers: HashMap>, PageRenderer)>, - /// Extracted page text per document version (the working bytes it was taken from). - texts: HashMap, + /// Extracted page text, under one memory budget across all open documents. + texts: TextCache, } impl Default for Automation { @@ -117,7 +121,7 @@ impl Default for Automation { impl Automation { pub fn new() -> Self { - Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: HashMap::new() } + Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() } } /// Confine every path the tools read or write to `root` (relative paths resolve inside it). @@ -1704,23 +1708,47 @@ impl Automation { /// The text of `pages` (0-based), from the cache or extracted in parallel. fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { + self.page_text_results(id, pages, &mut extract_parallel)? + .into_iter() + .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) + .collect() + } + + /// Keep per-page failures until the caller chooses strict or partial-result semantics. + fn page_text_results( + &mut self, + id: DocId, + pages: &[usize], + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); let password: Option> = doc.password.as_deref().map(Arc::from); - let entry = self.texts.entry(id).or_insert_with(|| (bytes.clone(), Vec::new())); - if !Arc::ptr_eq(&entry.0, &bytes) || entry.1.len() != n { - *entry = (bytes.clone(), vec![None; n]); - } - let mut missing: Vec = pages.iter().copied().filter(|p| entry.1.get(*p).is_some_and(Option::is_none)).collect(); - missing.sort_unstable(); - missing.dedup(); - for (p, text) in extract_parallel(&bytes, password, &missing) { - let text = Arc::new(text.map_err(|e| failed(format!("page {}: {e}", p + 1)))?); - if let Some(slot) = entry.1.get_mut(p) { - *slot = Some(text); + self.texts.prepare(id, &bytes); + let mut results = HashMap::new(); + let mut missing = Vec::new(); + for &p in pages { + if p >= n { + results.insert(p, Err("page is out of range".to_owned())); + } else if let Some(text) = self.texts.get(id, p) { + results.insert(p, Ok(text)); + } else { + missing.push(p); } } - pages.iter().map(|p| entry.1.get(*p).cloned().flatten().ok_or_else(|| failed(format!("page {} has no text", p + 1)))).collect() + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + for (p, text) in extract(&bytes, password, &missing) { + if let Ok(text) = &text { + self.texts.insert(id, p, &bytes, text.clone()); + } + results.insert(p, text); + } + } + // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A + // worker that fails to return a page is an explicit failure, never an empty success. + Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) } fn text_extract(&mut self, a: &Args) -> Result { @@ -1743,9 +1771,13 @@ impl Automation { /// 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. + /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported + /// as bounded warnings; `complete` requires both no continuation and no failed pages. fn text_find(&mut self, a: &Args) -> Result { + self.text_find_with(a, &mut extract_parallel) + } + + fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts) -> Result { let doc = self.doc(a)?; let (id, n) = (doc.id, doc.info.pages.len()); let query = a.str("query")?; @@ -1758,13 +1790,28 @@ impl Automation { let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); let mut matches = Vec::new(); let mut next = None; + let mut warnings = Vec::new(); + let mut warning_count = 0usize; 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 }; + let texts = self.page_text_results(id, &pages, extract)?; + for (p, text) in texts { + let text = match text { + Ok(text) => text, + Err(mut error) => { + warning_count += 1; + if warnings.len() < FIND_MAX_WARNINGS { + let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); + let truncated = end < error.len(); + error.truncate(end); + warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); + } + continue; + } + }; + let skip_here = if p == start { skip } else { 0 }; // One extra match proves that the caller needs a continuation. Include skipped // matches on the starting page when bounding the temporary per-page result. let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); @@ -1779,7 +1826,8 @@ impl Automation { first = end; batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); } - let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none(), "matches": matches }); + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, + "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); if let (Some(next), Some(o)) = (next, out.as_object_mut()) { o.insert("next".into(), next); } @@ -2195,12 +2243,12 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> } /// Extract the text of `pages` (0-based) with one renderer per worker thread. -fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> Vec<(usize, std::result::Result)> { +fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { let extract = |r: &mut PageRenderer, p: usize| { let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); match out.error { Some(e) => Err(e), - None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()), + None => Ok(out.text.unwrap_or_default()), } }; let config = RenderConfig { password, ..Default::default() }; @@ -2211,7 +2259,7 @@ fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[u return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); } let next = std::sync::atomic::AtomicUsize::new(0); - let mut out: Vec<(usize, std::result::Result)> = std::thread::scope(|s| { + let mut out: PageTexts = std::thread::scope(|s| { let handles: Vec<_> = (0..workers) .map(|_| { s.spawn(|| { @@ -2323,6 +2371,118 @@ mod tests { } } + fn search_document(a: &mut Automation, pages: usize) -> DocId { + a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap() + } + + #[test] + fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() { + let mut a = Automation::new(); + a.texts = TextCache::new(16 * 1024); + let first = search_document(&mut a, 200); + let second = search_document(&mut a, 200); + for id in [first, second] { + let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" })); + assert_eq!(result["complete"], true); + assert!(a.texts.bytes() <= 16 * 1024); + assert!(a.texts.len() < 200, "an exhaustive search must not retain every page"); + let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 })); + assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches"); + eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes()); + } + assert!(a.texts.get(first, 0).is_none()); + let text = a.page_texts(first, &[0]).unwrap().remove(0); + assert_eq!(text.plain_text(), "Page 1"); + let again = a.page_texts(first, &[0]).unwrap().remove(0); + assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage"); + assert!(a.texts.bytes() <= 16 * 1024); + json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] })); + let edited = a.page_texts(first, &[0]).unwrap().remove(0); + assert_eq!(edited.plain_text(), "Page 2"); + assert!(!Arc::ptr_eq(&text, &edited)); + json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true })); + json(&mut a, "doc_close", json!({ "doc": second.0 })); + assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0)); + } + + #[test] + fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() { + let mut a = Automation::new(); + let id = search_document(&mut a, 3); + let error = a.page_texts(id, &[0, 3, 1]).unwrap_err(); + assert!(error.to_string().contains("page 4: page is out of range")); + assert!(a.texts.get(id, 0).is_some()); + assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained"); + let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap(); + assert!(results[0].1.is_err(), "a missing worker result must not become an empty page"); + } + + #[test] + fn search_keeps_matches_around_failed_pages_and_continuations() { + let mut a = Automation::new(); + let id = search_document(&mut a, 16); + let mut extract = |bytes: &Arc>, password, pages: &[usize]| { + extract_parallel(bytes, password, pages) + .into_iter() + .map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result })) + .collect() + }; + let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap(); + assert_eq!(all["count"], 14); + assert_eq!(all["complete"], false); + assert_eq!(all["warning_count"], 2); + assert_eq!(all["warnings"][0]["page"], 3); + assert_eq!(all["warnings"][1]["page"], 7); + assert!(all.get("next").is_none()); + assert_eq!(all["matches"][13]["page"], 16); + assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached"); + let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 }); + let mut matches = Vec::new(); + let mut warned = Vec::new(); + loop { + let part = a.text_find_with(&Args(&args), &mut extract).unwrap(); + matches.extend(part["matches"].as_array().unwrap().iter().cloned()); + warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone())); + if !part["warnings"].as_array().unwrap().is_empty() { + assert_eq!(part["complete"], false); + } + let Some(next) = part.get("next") else { break }; + args["start_page"] = next["start_page"].clone(); + args["skip"] = next["skip"].clone(); + } + assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated"); + assert_eq!(warned, vec![json!(3), json!(7)]); + } + + #[test] + fn search_bounds_warning_volume_and_does_not_reach_distant_failures() { + let mut a = Automation::new(); + let id = search_document(&mut a, 200); + let first = a + .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| { + assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end"); + extract_parallel(bytes, password, pages) + }) + .unwrap(); + assert_eq!(first["count"], 1); + assert_eq!(first["warning_count"], 0); + assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 })); + a.texts.remove(&id); + let failures = a + .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| { + pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect() + }) + .unwrap(); + assert_eq!(failures["count"], 0); + assert_eq!(failures["complete"], false); + assert!(failures.get("next").is_none()); + assert_eq!(failures["warning_count"], 200); + assert_eq!(failures["warnings_truncated"], true); + let warnings = failures["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), FIND_MAX_WARNINGS); + assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true)); + } + #[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())); @@ -2331,7 +2491,7 @@ mod tests { 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::(); + let extracted = |a: &Automation| a.texts.len(); // #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. diff --git a/crates/automation/src/text_cache.rs b/crates/automation/src/text_cache.rs new file mode 100644 index 00000000..a23259bd --- /dev/null +++ b/crates/automation/src/text_cache.rs @@ -0,0 +1,177 @@ +//! Bounded, session-wide page-text reuse. Active tool results own their Arcs independently. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Weak}; + +use pdfcraft_engine::DocId; +use pdfcraft_render::{PageText, TextGlyph}; + +const BYTE_BUDGET: usize = 64 * 1024 * 1024; +// Also bound bookkeeping for documents with thousands of empty pages. +const PAGE_BUDGET: usize = 4096; +type Key = (DocId, usize); + +struct Cached { + version: Weak>, + text: Arc, + bytes: usize, + stamp: u64, +} + +pub(super) struct TextCache { + pages: HashMap, + order: BTreeMap, + clock: u64, + bytes: usize, + budget: usize, +} + +impl Default for TextCache { + fn default() -> Self { + Self::new(BYTE_BUDGET) + } +} + +impl TextCache { + pub(super) fn new(budget: usize) -> Self { + Self { pages: HashMap::new(), order: BTreeMap::new(), clock: 0, bytes: 0, budget } + } + + pub(super) fn prepare(&mut self, id: DocId, bytes: &Arc>) { + let version = Arc::downgrade(bytes); + // At most PAGE_BUDGET entries, independent of the document's page count. Weak pointers + // distinguish revisions without keeping an obsolete PDF byte buffer alive. + let stale: Vec<_> = self.pages.iter().filter(|(key, page)| key.0 == id && !page.version.ptr_eq(&version)).map(|(key, _)| *key).collect(); + for key in stale { + self.remove_page(key); + } + } + + pub(super) fn remove(&mut self, id: &DocId) { + let keys: Vec<_> = self.pages.keys().filter(|key| key.0 == *id).copied().collect(); + for key in keys { + self.remove_page(key); + } + } + + fn remove_page(&mut self, key: Key) { + if let Some(page) = self.pages.remove(&key) { + self.order.remove(&page.stamp); + self.bytes -= page.bytes; + } + } + + fn stamp(&mut self) -> u64 { + if self.clock == u64::MAX { + self.pages.clear(); + self.order.clear(); + self.bytes = 0; + self.clock = 0; + } + self.clock += 1; + self.clock + } + + pub(super) fn get(&mut self, id: DocId, page: usize) -> Option> { + let stamp = self.stamp(); + let entry = self.pages.get_mut(&(id, page))?; + self.order.remove(&entry.stamp); + entry.stamp = stamp; + self.order.insert(stamp, (id, page)); + Some(entry.text.clone()) + } + + pub(super) fn insert(&mut self, id: DocId, page: usize, version: &Arc>, text: Arc) { + let bytes = retained_bytes(&text); + self.remove_page((id, page)); + // An oversized page is usable by this call, but never retained by the cache. + if bytes > self.budget { + return; + } + while self.bytes > self.budget - bytes || self.pages.len() >= PAGE_BUDGET { + let Some((_, key)) = self.order.first_key_value() else { break }; + self.remove_page(*key); + } + let stamp = self.stamp(); + self.pages.insert((id, page), Cached { version: Arc::downgrade(version), text, bytes, stamp }); + self.order.insert(stamp, (id, page)); + self.bytes += bytes; + } + + #[cfg(test)] + pub(super) fn len(&self) -> usize { + self.pages.len() + } + + #[cfg(test)] + pub(super) fn bytes(&self) -> usize { + self.bytes + } +} + +// Estimate owned allocations, including spare capacity; bookkeeping has its own page-count cap. +fn retained_bytes(text: &PageText) -> usize { + let arrays = std::mem::size_of::() + .saturating_add(2 * std::mem::size_of::()) + .saturating_add(text.glyphs.capacity().saturating_mul(std::mem::size_of::())) + .saturating_add(text.line_of.capacity().saturating_mul(std::mem::size_of::())) + .saturating_add(text.space_before.capacity().saturating_mul(std::mem::size_of::())); + text.glyphs.iter().fold(arrays, |sum, glyph| sum.saturating_add(glyph.text.capacity())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn budget_counts_owned_capacity_and_lru_is_shared_across_documents() { + let mut text = PageText::default(); + text.glyphs.reserve(32); + text.line_of.reserve(48); + text.space_before.reserve(64); + let mut word = String::with_capacity(1000); + word.push('a'); + text.glyphs.push(TextGlyph { text: word, rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }); + let cost = retained_bytes(&text); + assert!(cost >= 32 * std::mem::size_of::() + 48 * 4 + 64 + 1000); + let text = Arc::new(text); + let version = Arc::new(vec![1]); + let mut cache = TextCache::new(cost * 2); + cache.insert(DocId(1), 0, &version, text.clone()); + cache.insert(DocId(2), 0, &version, text.clone()); + assert!(Arc::ptr_eq(&cache.get(DocId(1), 0).unwrap(), &text)); + cache.insert(DocId(3), 0, &version, text.clone()); + assert!(cache.get(DocId(2), 0).is_none(), "least recently used page in another document is evicted"); + assert_eq!(cache.bytes, 2 * cost); + cache.insert(DocId(1), 0, &version, text.clone()); + assert_eq!(cache.bytes, 2 * cost, "replacement does not double-count"); + let next_version = Arc::new(vec![1]); + cache.prepare(DocId(1), &next_version); + assert!(cache.get(DocId(1), 0).is_none(), "equal bytes in a new Arc are a new version"); + assert_eq!(cache.bytes, cost); + cache.remove(&DocId(3)); + assert_eq!((cache.bytes, cache.len(), cache.order.len()), (0, 0, 0)); + assert_eq!(Arc::strong_count(&version), 1, "the cache never retains a PDF byte buffer"); + } + + #[test] + fn oversized_pages_bypass_cache_and_empty_pages_have_a_bookkeeping_limit() { + let text = Arc::new(PageText::default()); + let version = Arc::new(vec![]); + let cost = retained_bytes(&text); + let mut tiny = TextCache::new(cost - 1); + tiny.insert(DocId(1), 0, &version, text.clone()); + assert_eq!((tiny.bytes, tiny.len()), (0, 0)); + let mut cache = TextCache::default(); + for page in 0..PAGE_BUDGET + 50 { + cache.insert(DocId(1), page, &version, text.clone()); + } + assert_eq!(cache.len(), PAGE_BUDGET); + assert!(cache.get(DocId(1), 0).is_none()); + assert!(cache.get(DocId(1), PAGE_BUDGET + 49).is_some()); + assert_eq!(cache.bytes, PAGE_BUDGET * cost); + cache.clock = u64::MAX; + cache.insert(DocId(2), 0, &version, text); + assert_eq!((cache.bytes, cache.len()), (cost, 1), "recency counter rollover safely clears old state"); + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ee58063a..521009cc 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -181,10 +181,10 @@ pub fn tools() -> Vec { }), &["doc"], )), - 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.") + t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.") .ro() .cmd("edit.find") - .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." }, + .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." }, "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.")