mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-15 03:37:03 -04:00
M13: Bound cached page text and keep partial search results (#1492)
Use a session-wide byte-accounted LRU instead of retaining every visited page. Share the renderer's PageText Arc without cloning all glyphs and avoid retaining obsolete document bytes through the cache. Preserve per-page extraction results so text_find can continue after a failed page, reporting bounded warnings alongside readable matches and normal continuation cursors. Other text consumers retain strict errors. Add deterministic coverage for cache budgets across documents, eviction, revision changes, close, partial searches and bounded warnings. Fixes #1483. Co-authored-by: LabDennis <[email protected]>
This commit is contained in:
1 parent
9324afaa4f
commit
082768ac29
4 files changed
+395
-29
No files matched your search
@@ -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 (`<path> is outside the allowed directory <root>`), 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):
|
||||
|
||||
+187
-27
@@ -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<u8>>, Vec<Option<Arc<PageText>>>);
|
||||
use text_cache::TextCache;
|
||||
|
||||
type PageTexts = Vec<(usize, std::result::Result<Arc<PageText>, 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<PathBuf>,
|
||||
/// Synchronous renderers, rebuilt when a document's working bytes change.
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
|
||||
/// Extracted page text per document version (the working bytes it was taken from).
|
||||
texts: HashMap<DocId, TextCache>,
|
||||
/// 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<Vec<Arc<PageText>>> {
|
||||
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<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
|
||||
) -> Result<PageTexts> {
|
||||
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<Arc<str>> = 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<usize> = 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<Value> {
|
||||
@@ -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<Value> {
|
||||
self.text_find_with(a, &mut extract_parallel)
|
||||
}
|
||||
|
||||
fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts) -> Result<Value> {
|
||||
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<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 };
|
||||
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<Vec<u8>>
|
||||
}
|
||||
|
||||
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
|
||||
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> Vec<(usize, std::result::Result<PageText, String>)> {
|
||||
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, 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<Vec<u8>>, password: Option<Arc<str>>, 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<PageText, String>)> = 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<Vec<u8>>, 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::<usize>();
|
||||
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.
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
text: Arc<PageText>,
|
||||
bytes: usize,
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
pub(super) struct TextCache {
|
||||
pages: HashMap<Key, Cached>,
|
||||
order: BTreeMap<u64, Key>,
|
||||
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<Vec<u8>>) {
|
||||
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<Arc<PageText>> {
|
||||
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<Vec<u8>>, text: Arc<PageText>) {
|
||||
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::<PageText>()
|
||||
.saturating_add(2 * std::mem::size_of::<usize>())
|
||||
.saturating_add(text.glyphs.capacity().saturating_mul(std::mem::size_of::<TextGlyph>()))
|
||||
.saturating_add(text.line_of.capacity().saturating_mul(std::mem::size_of::<u32>()))
|
||||
.saturating_add(text.space_before.capacity().saturating_mul(std::mem::size_of::<bool>()));
|
||||
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::<TextGlyph>() + 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");
|
||||
}
|
||||
}
|
||||
@@ -181,10 +181,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["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.")
|
||||
|
||||
Reference in new issue
Block a user