imprimatur 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c343d074a7ed5e2ba382910345401adaaa92269eb915c901db443d97ed6f68a
4
- data.tar.gz: 00cfe2c403d09f50708469c6228813f8ab9361775b848ac23457389008e643ab
3
+ metadata.gz: 20a7d3843214018bf15d889668ad1d33ba8dfee183b86e041d56b5cc2db88832
4
+ data.tar.gz: 6699fc743a0a04fffbaf67d68551ca680c416cdc56f1fd71984ec2f7cba8e5b1
5
5
  SHA512:
6
- metadata.gz: e003c94d4f867c4d957a7921a8ef50eb36f0393196535064bfab45cfead076f881a39dbbc7d2e73aa15487e57046a18ea3df1483f8b493f926a8d6e362e6ee7c
7
- data.tar.gz: ac5657121fd5b657880fee907b3a4508087aff20a15ad057b1d43254796d767e908eec39952ac44a0aa65f814a55d36db8c9b56119effe647d8ea018bcb2e6b4
6
+ metadata.gz: ef0a7f9c8d545aa513881626e798702095c66a42a5665b663a56b4c6205e5a0c09596366d9dae05c6ac6539f46ba242744feb2ab99080e906b418c0af2550625
7
+ data.tar.gz: 38ccdff0d6b4d2bfcb516660de96f1274ac0d1cef01b9d97f2790b0223b041ec2fd5b747be21a053a368c0bf58e33e2f8b9c608ed1bd5d5ea2e1da7bfd218615
data/CHANGELOG.md CHANGED
@@ -11,6 +11,14 @@
11
11
  - Allow a bounded exit grace when macOS cannot inspect an exiting worker;
12
12
  persistent monitor failures still terminate and reap a live worker.
13
13
 
14
+ ## [0.8.1](https://github.com/seuros/imprimatur/compare/v0.8.0...v0.8.1) (2026-10-29)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **pdf:** recover timeout neighbours within the overall deadline ([#16](https://github.com/seuros/imprimatur/issues/16)) ([e86fbbf](https://github.com/seuros/imprimatur/commit/e86fbbfef85f2c645090c43359c0744962e951a1))
20
+ * **pdf:** step timeout recovery so a Ruby wakeup cannot restart it ([75c3894](https://github.com/seuros/imprimatur/commit/75c3894c8a791c5ef4af562dc99de4e745770ad3))
21
+
14
22
  ## [0.8.0](https://github.com/seuros/imprimatur/compare/v0.7.0...v0.8.0) (2026-10-27)
15
23
 
16
24
 
data/Cargo.lock CHANGED
@@ -513,7 +513,7 @@ dependencies = [
513
513
 
514
514
  [[package]]
515
515
  name = "imprimatur"
516
- version = "0.8.0"
516
+ version = "0.8.1"
517
517
  dependencies = [
518
518
  "aes",
519
519
  "crc32fast",
@@ -536,7 +536,7 @@ dependencies = [
536
536
 
537
537
  [[package]]
538
538
  name = "imprimatur-cli"
539
- version = "0.8.0"
539
+ version = "0.8.1"
540
540
  dependencies = [
541
541
  "imprimatur",
542
542
  "regex",
@@ -548,7 +548,7 @@ dependencies = [
548
548
 
549
549
  [[package]]
550
550
  name = "imprimatur_native"
551
- version = "0.8.0"
551
+ version = "0.8.1"
552
552
  dependencies = [
553
553
  "imprimatur",
554
554
  "magnus",
data/Cargo.toml CHANGED
@@ -15,7 +15,7 @@ crc32fast = "1.5.2"
15
15
  datamatrix = "0.3.3"
16
16
  font-test-data = "0.10.0"
17
17
  hayro = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
18
- imprimatur = { path = "ext/imprimatur_native/core", version = "0.8.0" }
18
+ imprimatur = { path = "ext/imprimatur_native/core", version = "0.8.1" }
19
19
  libproc = "0.14.11"
20
20
  magnus = { version = "0.9.2", default-features = false }
21
21
  md-5 = "0.11.0"
data/README.md CHANGED
@@ -384,6 +384,28 @@ after metadata was read preserves completed pages and explicitly marks the
384
384
  unreached pages; an expired overall deadline marks them `skipped`. Cancellation
385
385
  still raises rather than returning a partial successful call.
386
386
 
387
+ For timeout-neighbour recovery, opt in to a shorter worker-attempt budget:
388
+
389
+ ```ruby
390
+ Imprimatur.pdf_pages(bytes, timeout: 300, batch_timeout: 60)
391
+ ```
392
+
393
+ Here `timeout:` is the **overall** deadline, including recovery; `batch_timeout:`
394
+ caps the initial worker and each singleton retry. Completed pages survive a
395
+ batch timeout. Unread later neighbours are tried once before the first missing
396
+ page (which may have stalled the worker). Recovery is best effort, never an
397
+ extension of the overall deadline. Output-limit skips are not retried, and all
398
+ attempts share `max_output_bytes:`. Input, depth, process-memory and per-page
399
+ limits still apply; `max_inflated:` remains per worker/recovery attempt, not a
400
+ cumulative decompression allowance.
401
+
402
+ With `batch_timeout:` enabled, an explicit finite `timeout:` replaces the
403
+ ordinary 60-second process ceiling for this call; `timeout: nil` retains that
404
+ 60-second ceiling. Both budgets must be positive; zero overall time still fails
405
+ immediately. Omitting `batch_timeout:` preserves the existing behaviour. Rust
406
+ callers can use `pages_isolated_recovering` with an explicit attempt duration
407
+ and overall `Limits`/`ProcessLimits`.
408
+
387
409
  This is resource containment, **not a filesystem/network security sandbox**.
388
410
  The raw crate `info`, `pages` and `render_page` APIs remain in-process;
389
411
  `render_page` cannot interrupt Hayro.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.8.0"
3
+ version = "0.8.1"
4
4
  description = "Read PDF, XLSX, XLS, ODS and DOCX documents as Markdown or JSON, search them, extract their images."
5
5
  keywords = ["pdf", "xlsx", "docx", "markdown", "cli"]
6
6
  categories = ["command-line-utilities", "text-processing"]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.8.0"
3
+ version = "0.8.1"
4
4
  description = "XLSX, PDF and DOCX authoring, and PDF, XLSX, XLS, ODS and DOCX reading, with its own XML, zip and PDF code."
5
5
  keywords = ["xlsx", "excel", "pdf", "docx", "spreadsheet"]
6
6
  categories = ["encoding", "text-processing"]
@@ -45,6 +45,12 @@ impl Default for Limits {
45
45
  }
46
46
 
47
47
  impl Limits {
48
+ /// The configured overall read timeout, if enabled.
49
+ #[must_use]
50
+ pub const fn timeout_duration(&self) -> Option<Duration> {
51
+ self.timeout
52
+ }
53
+
48
54
  /// The largest document accepted, in bytes; 512 MiB by default.
49
55
  #[must_use]
50
56
  pub const fn max_input(mut self, bytes: usize) -> Self {
@@ -60,7 +60,7 @@ impl Request {
60
60
  if process.memory_bytes == 0 || process.memory_bytes >= i64::MAX as u64 {
61
61
  return Err(Error::InvalidPdfOption("memory limit must be positive and finite"));
62
62
  }
63
- let wall = limits.timeout.unwrap_or(process.wall_time).min(process.wall_time);
63
+ let wall = process.wall_time_for(limits);
64
64
  let cpu_seconds = wall
65
65
  .as_secs()
66
66
  .checked_add(u64::from(wall.subsec_nanos() != 0))
@@ -165,6 +165,7 @@ pub(super) fn failure(error: &Error) -> (u8, String) {
165
165
  let kind = match error {
166
166
  Error::PasswordRequired => 1,
167
167
  Error::UnsupportedDocument(_) => 2,
168
+ Error::LimitExceeded("time") => 6,
168
169
  Error::LimitExceeded(_) | Error::Cancelled => 3,
169
170
  Error::InvalidPdfOption(_) => 4,
170
171
  _ => 5,
@@ -184,6 +185,7 @@ pub(super) fn error(kind: u8, message: String) -> Error {
184
185
  2 => Error::UnsupportedDocument(message),
185
186
  3 => Error::LimitExceeded("PDF worker limit"),
186
187
  4 => Error::InvalidPdfOption("the PDF worker rejected the options"),
188
+ 6 => Error::LimitExceeded("time"),
187
189
  _ => Error::malformed("PDF worker", message),
188
190
  }
189
191
  }
@@ -0,0 +1,158 @@
1
+ //! Optional timeout-neighbour recovery under one overall supervisor deadline.
2
+
3
+ use std::iter;
4
+ use std::path::Path;
5
+ use std::time::{Duration, Instant};
6
+
7
+ use super::{ProcessLimits, invoke, protocol, stream};
8
+ use crate::read::Limits;
9
+ use crate::read::pdf::{Pages, ReadOptions, Status, bounded};
10
+ use crate::{Error, Result};
11
+
12
+ #[cfg(test)]
13
+ mod tests;
14
+
15
+ /// Reads with a shorter worker-attempt timeout, recovering unread neighbours.
16
+ ///
17
+ /// `limits.timeout` and `process.wall_time` bound the entire operation, not
18
+ /// each attempt. Completed records survive; timeout-unread pages are tried
19
+ /// once individually, later neighbours before the first missing page.
20
+ /// All workers retain the input, decompression, depth and process-memory caps.
21
+ /// Decompression is bounded per worker, as in ordinary page-local recovery;
22
+ /// retained text shares `options.max_output_bytes` across all attempts.
23
+ ///
24
+ /// # Errors
25
+ /// As [`super::pages_isolated`], or a zero attempt timeout.
26
+ pub fn pages_isolated_recovering(
27
+ worker: &Path,
28
+ data: &[u8],
29
+ options: &ReadOptions,
30
+ limits: &Limits,
31
+ process: &ProcessLimits,
32
+ attempt_timeout: Duration,
33
+ ) -> Result<Pages> {
34
+ let mut recovery = Recovery::new(options, attempt_timeout)?;
35
+ loop {
36
+ if let Some(pages) = recovery.step(worker, data, limits, process)? {
37
+ return Ok(pages);
38
+ }
39
+ }
40
+ }
41
+
42
+ /// [`pages_isolated_recovering`], one worker attempt per [`Recovery::step`].
43
+ ///
44
+ /// A step that fails keeps the progress before it, and the deadline set by
45
+ /// the first step: a caller woken spuriously can step again.
46
+ pub struct Recovery {
47
+ options: ReadOptions,
48
+ attempt_timeout: Duration,
49
+ deadline: Option<Instant>,
50
+ result: Option<Pages>,
51
+ pending: Vec<usize>,
52
+ remaining: usize,
53
+ }
54
+
55
+ impl Recovery {
56
+ /// # Errors
57
+ /// A zero attempt timeout.
58
+ pub fn new(options: &ReadOptions, attempt_timeout: Duration) -> Result<Self> {
59
+ if attempt_timeout.is_zero() {
60
+ return Err(Error::InvalidPdfOption("batch timeout must be positive"));
61
+ }
62
+ Ok(Self {
63
+ options: options.clone(),
64
+ attempt_timeout,
65
+ deadline: None,
66
+ result: None,
67
+ pending: Vec::new(),
68
+ remaining: options.max_output_bytes,
69
+ })
70
+ }
71
+
72
+ /// Runs the next worker attempt; the pages once recovery is over.
73
+ ///
74
+ /// # Errors
75
+ /// As [`pages_isolated_recovering`].
76
+ pub fn step(
77
+ &mut self,
78
+ worker: &Path,
79
+ data: &[u8],
80
+ limits: &Limits,
81
+ process: &ProcessLimits,
82
+ ) -> Result<Option<Pages>> {
83
+ let deadline = self.deadline(limits, process)?;
84
+ let attempt_timeout = self.attempt_timeout;
85
+ let attempt = |selection: &ReadOptions| {
86
+ let wall_time = deadline.saturating_duration_since(Instant::now()).min(attempt_timeout);
87
+ if wall_time.is_zero() {
88
+ return Err(Error::LimitExceeded("time"));
89
+ }
90
+ let process = ProcessLimits { wall_time, ..process.clone() };
91
+ let operation = protocol::Operation::Pages(selection.clone());
92
+ stream::decode_recoverable(
93
+ invoke(worker, data, operation, limits, &process)?,
94
+ selection,
95
+ )
96
+ };
97
+ let Some(result) = &mut self.result else {
98
+ let (pages, first_missing) = attempt(&self.options)?;
99
+ let Some(first_missing) = first_missing else { return Ok(Some(pages)) };
100
+ let count = pages.pages.len();
101
+ self.pending = (first_missing + 1..count)
102
+ .chain(iter::once(first_missing))
103
+ .filter(|&index| index < count)
104
+ .rev()
105
+ .collect();
106
+ self.remaining -= pages.pages.iter().map(|page| page.text.len()).sum::<usize>();
107
+ self.result = Some(pages);
108
+ return Ok(self.settle());
109
+ };
110
+ let Some(&index) = self.pending.last() else { return Ok(self.result.take()) };
111
+ let number = result.pages[index].number;
112
+ let selection = ReadOptions {
113
+ first_page: number,
114
+ last_page: number,
115
+ max_pages: 1,
116
+ max_output_bytes: self.remaining,
117
+ ..self.options.clone()
118
+ };
119
+ match attempt(&selection) {
120
+ Ok((mut single, _)) => {
121
+ if single.info != result.info || single.pages.len() != 1 {
122
+ return Err(Error::malformed("PDF worker", "inconsistent recovery metadata"));
123
+ }
124
+ let page = single.pages.remove(0);
125
+ self.remaining -= page.text.len();
126
+ result.pages[index] = page;
127
+ }
128
+ Err(error @ Error::LimitExceeded(reason)) if reason != "PDF worker output" => {
129
+ // A retry can expire while spooling, before metadata exists.
130
+ // Keep the prefix even then; no attempt renews the outer clock.
131
+ if Instant::now() >= deadline {
132
+ return Ok(self.result.take());
133
+ }
134
+ let status = if stream::expired(&error) { Status::Skipped } else { Status::Failed };
135
+ result.pages[index] = bounded::unread(number, status, error.to_string());
136
+ }
137
+ // Valid worker failures with metadata already decode to page
138
+ // records. Infrastructure/protocol errors are not page damage.
139
+ Err(error) => return Err(error),
140
+ }
141
+ self.pending.pop();
142
+ Ok(self.settle())
143
+ }
144
+
145
+ fn deadline(&mut self, limits: &Limits, process: &ProcessLimits) -> Result<Instant> {
146
+ if let Some(deadline) = self.deadline {
147
+ return Ok(deadline);
148
+ }
149
+ let deadline = Instant::now()
150
+ .checked_add(process.wall_time_for(limits))
151
+ .ok_or(Error::InvalidPdfOption("worker wall time is too large"))?;
152
+ Ok(*self.deadline.insert(deadline))
153
+ }
154
+
155
+ const fn settle(&mut self) -> Option<Pages> {
156
+ if self.pending.is_empty() || self.remaining == 0 { self.result.take() } else { None }
157
+ }
158
+ }
@@ -25,6 +25,15 @@ pub(super) fn write(out: &mut impl Write, frame: &Frame) -> Result<()> {
25
25
  }
26
26
 
27
27
  pub(super) fn decode(output: Output, options: &ReadOptions) -> Result<Pages> {
28
+ decode_recoverable(output, options).map(|(pages, _)| pages)
29
+ }
30
+
31
+ // The index comes from the supervisor/protocol failure, not human-readable
32
+ // page reasons: output-limit skips must never become recovery candidates.
33
+ pub(super) fn decode_recoverable(
34
+ output: Output,
35
+ options: &ReadOptions,
36
+ ) -> Result<(Pages, Option<usize>)> {
28
37
  let mut failure = output.failure;
29
38
  if output.data.len() < protocol::HEADER.len() {
30
39
  return Err(failure.unwrap_or_else(|| malformed("missing response")));
@@ -67,14 +76,12 @@ pub(super) fn decode(output: Output, options: &ReadOptions) -> Result<Pages> {
67
76
  info.ok_or_else(|| failure.take().unwrap_or_else(|| malformed("missing metadata")))?;
68
77
  let count = selected(info, options);
69
78
  if pages.len() == count && ended {
70
- return Ok(Pages { info, pages });
79
+ return Ok((Pages { info, pages }, None));
71
80
  }
72
81
  let error = failure.ok_or_else(|| malformed("incomplete response"))?;
73
82
  let reason = error.to_string();
74
- let timed_out = matches!(
75
- error,
76
- Error::LimitExceeded("time" | "render worker wall time" | "PDF worker limit")
77
- );
83
+ let recoverable = expired(&error);
84
+ let timed_out = recoverable || matches!(error, Error::LimitExceeded("PDF worker limit"));
78
85
  let first_missing = pages.len();
79
86
  for index in first_missing..count {
80
87
  let number =
@@ -83,7 +90,11 @@ pub(super) fn decode(output: Output, options: &ReadOptions) -> Result<Pages> {
83
90
  if timed_out || index > first_missing { Status::Skipped } else { Status::Failed };
84
91
  pages.push(bounded::unread(number, status, reason.clone()));
85
92
  }
86
- Ok(Pages { info, pages })
93
+ Ok((Pages { info, pages }, recoverable.then_some(first_missing)))
94
+ }
95
+
96
+ pub(super) fn expired(error: &Error) -> bool {
97
+ matches!(error, Error::LimitExceeded("time" | "render worker wall time"))
87
98
  }
88
99
 
89
100
  fn selected(info: Info, options: &ReadOptions) -> usize {
@@ -2,9 +2,12 @@
2
2
  //! occurs in the supervisor.
3
3
 
4
4
  mod protocol;
5
+ mod recovery;
5
6
  mod stream;
6
7
  mod system;
7
8
  #[cfg(test)]
9
+ mod test_support;
10
+ #[cfg(test)]
8
11
  mod tests;
9
12
  mod worker;
10
13
 
@@ -18,6 +21,7 @@ use std::time::{Duration, Instant};
18
21
  use super::{Info, Pages, ReadOptions, RenderOptions};
19
22
  use crate::read::{Budget, Image, Limits};
20
23
  use crate::{Error, Result};
24
+ pub use recovery::{Recovery, pages_isolated_recovering};
21
25
  pub use worker::worker_main;
22
26
 
23
27
  /// Inspects a PDF in the trusted, resource-limited worker.
@@ -71,6 +75,12 @@ impl Default for ProcessLimits {
71
75
  }
72
76
  }
73
77
 
78
+ impl ProcessLimits {
79
+ fn wall_time_for(&self, limits: &Limits) -> Duration {
80
+ limits.timeout.unwrap_or(self.wall_time).min(self.wall_time)
81
+ }
82
+ }
83
+
74
84
  /// Renders through the explicitly selected, trusted worker executable.
75
85
  ///
76
86
  /// The child is killed and reaped on timeout, cancellation, resource failure
@@ -0,0 +1,259 @@
1
+ use std::collections::HashMap;
2
+
3
+ use super::{Line, PageLines};
4
+ use crate::read::content::{Block, Bounds, Page, Placed, Text};
5
+
6
+ /// The document's pages, from their lines: blocks of the lines that are
7
+ /// not running headers or footers, and, with `drop_repeated_anywhere`, not
8
+ /// text repeated at the same spot on most pages anywhere on the page.
9
+ pub fn pages(
10
+ pages: &[PageLines],
11
+ labels: Option<&[Option<String>]>,
12
+ drop_repeated_anywhere: bool,
13
+ ) -> Vec<Page> {
14
+ let boilerplate = repeated_keys(pages, |line| line.margin);
15
+ let margin_kept =
16
+ |line: &&Line| line.margin == 0 || boilerplate.binary_search(&line.margin).is_err();
17
+ let repeated =
18
+ if drop_repeated_anywhere { repeated_keys(pages, |line| line.repeat) } else { Vec::new() };
19
+ let kept = |line: &&Line| {
20
+ margin_kept(line) && (repeated.is_empty() || repeated.binary_search(&line.repeat).is_err())
21
+ };
22
+ let body = body_size(pages, &margin_kept);
23
+ let levels = heading_sizes(pages, &margin_kept, body);
24
+ let mut out = Vec::with_capacity(pages.len());
25
+ let mut paragraph: Vec<&Line> = Vec::new();
26
+ // Each page's blocks, collected here, then moved out at their size.
27
+ let mut blocks = Vec::new();
28
+ for (index, (number, page)) in (1..).zip(pages).enumerate() {
29
+ paragraph.clear();
30
+ for line in page.lines.iter().filter(kept) {
31
+ if let Some(last) = paragraph.last()
32
+ && !continues(last, line)
33
+ {
34
+ blocks.push(block(page, &paragraph, body, &levels));
35
+ paragraph.clear();
36
+ }
37
+ paragraph.push(line);
38
+ }
39
+ if !paragraph.is_empty() {
40
+ blocks.push(block(page, &paragraph, body, &levels));
41
+ }
42
+ #[expect(
43
+ clippy::drain_collect,
44
+ clippy::iter_with_drain,
45
+ reason = "`blocks` keeps its buffer for the next page"
46
+ )]
47
+ let blocks = blocks.drain(..).collect();
48
+ let label = labels
49
+ .map_or_else(|| printed_label(page), |labels| labels.get(index).cloned().flatten());
50
+ out.push(Page { number, blocks, images: page.images, label });
51
+ }
52
+ out
53
+ }
54
+
55
+ /// The page's own printed label, from a single header or footer line that
56
+ /// looks like one: a bare page number or roman numeral ("`-i-`", "`7`"), or
57
+ /// the same after a short label word ("`Page 70`" -> "`70`"). Unlike the
58
+ /// boilerplate this document's other margin lines are checked against, a
59
+ /// running "`Page 70`", "`Page 71`", ... header is not noise here: each
60
+ /// instance *is* that page's label, not a repeated line to drop.
61
+ fn printed_label(page: &PageLines) -> Option<String> {
62
+ let mut candidates =
63
+ page.lines.iter().filter(|line: &&Line| line.margin != 0).filter_map(|line| {
64
+ let text = page.text(line).trim();
65
+ page_number_label(text)
66
+ });
67
+ let label = candidates.next()?;
68
+ candidates.next().is_none().then_some(label)
69
+ }
70
+
71
+ /// `text` as a page label, if it looks like one, stripped of a leading
72
+ /// label word such as "Page" when there is one.
73
+ fn page_number_label(text: &str) -> Option<String> {
74
+ if looks_like_page_number(text) {
75
+ return Some(text.to_owned());
76
+ }
77
+ let word_end = text.find(|ch: char| !ch.is_ascii_alphabetic())?;
78
+ if word_end == 0 {
79
+ return None;
80
+ }
81
+ let rest = text[word_end..].trim_start_matches(|ch: char| !ch.is_alphanumeric());
82
+ looks_like_page_number(rest).then(|| rest.to_owned())
83
+ }
84
+
85
+ fn looks_like_page_number(text: &str) -> bool {
86
+ let core = text.trim_matches(|ch: char| !ch.is_alphanumeric());
87
+ !core.is_empty()
88
+ && core.len() <= 8
89
+ && (core.chars().all(|ch| ch.is_ascii_digit())
90
+ || core.chars().all(|ch| {
91
+ matches!(ch.to_ascii_lowercase(), 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')
92
+ }))
93
+ }
94
+
95
+ /// Whether `next` carries on the paragraph `last` is in: same direction,
96
+ /// similar size, and no more than a line and a half below it.
97
+ fn continues(last: &Line, next: &Line) -> bool {
98
+ let size = last.size.max(next.size).max(1.0);
99
+ last.turns == next.turns
100
+ && (last.size - next.size).abs() <= 0.15 * size
101
+ && last.y - next.y > 0.0
102
+ && last.y - next.y <= 1.55 * size
103
+ && last.bold == next.bold
104
+ }
105
+
106
+ /// A paragraph of lines as a block: a heading when its few short lines
107
+ /// are set in a heading size, otherwise a paragraph with its lines on lines
108
+ /// of their own, words hyphenated across them joined again.
109
+ fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
110
+ let first = lines[0];
111
+ let length: usize = lines.iter().map(|line| line.span.len()).sum();
112
+ let short = lines.len() <= 3 && length <= 200;
113
+ let text = paragraph_text(page, lines);
114
+ let level =
115
+ if short { levels.iter().position(|&size| first.size >= size - 0.25) } else { None };
116
+ let sized = level
117
+ .filter(|_| first.size >= body * 1.15)
118
+ .map(|level| u8::try_from(level.min(2) + 1).unwrap_or(3));
119
+ let numbered = (short && first.bold && first.size >= body - 0.25)
120
+ .then(|| numbered_heading_level(&text))
121
+ .flatten();
122
+ let block = match sized.or(numbered) {
123
+ Some(level) => Block::Heading { level, text: Text::plain(text) },
124
+ None => Block::Paragraph { text: Text::plain(text) },
125
+ };
126
+ Placed { block, bounds: bounds(page, lines) }
127
+ }
128
+
129
+ fn paragraph_text(page: &PageLines, lines: &[&Line]) -> String {
130
+ let length: usize = lines.iter().map(|line| line.span.len()).sum();
131
+ let mut text = String::with_capacity(length + lines.len());
132
+ for (index, line) in lines.iter().enumerate() {
133
+ let line = page.text(line);
134
+ if index > 0 {
135
+ let joined = text.ends_with('-') && {
136
+ let mut before = text.chars().rev().skip(1);
137
+ before.next().is_some_and(char::is_lowercase)
138
+ && line.chars().next().is_some_and(char::is_lowercase)
139
+ };
140
+ if joined {
141
+ text.pop();
142
+ } else {
143
+ text.push('\n');
144
+ }
145
+ }
146
+ text.push_str(line);
147
+ }
148
+ text
149
+ }
150
+
151
+ fn numbered_heading_level(text: &str) -> Option<u8> {
152
+ let (depth, rest) = numbered_prefix(text)?;
153
+ if depth < 2 || !(rest.is_empty() || rest.starts_with(' ')) {
154
+ return None;
155
+ }
156
+ let title = rest.trim_start();
157
+ if title.contains("....") || title.starts_with(|ch: char| !ch.is_alphanumeric()) {
158
+ return None;
159
+ }
160
+ Some(u8::try_from(depth).unwrap_or(6).min(6))
161
+ }
162
+
163
+ fn numbered_prefix(text: &str) -> Option<(usize, &str)> {
164
+ let bytes = text.as_bytes();
165
+ let mut groups = 0_usize;
166
+ let mut pos = 0_usize;
167
+ loop {
168
+ let start = pos;
169
+ while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
170
+ pos += 1;
171
+ }
172
+ if pos == start {
173
+ break;
174
+ }
175
+ groups += 1;
176
+ if groups >= 4
177
+ || bytes.get(pos) != Some(&b'.')
178
+ || !bytes.get(pos + 1).is_some_and(u8::is_ascii_digit)
179
+ {
180
+ break;
181
+ }
182
+ pos += 1;
183
+ }
184
+ (groups > 0).then(|| (groups, &text[pos..]))
185
+ }
186
+
187
+ /// Where upright lines lie on the page, from its top-left corner: from the
188
+ /// top of the first line's capitals to below the last line's descenders.
189
+ fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
190
+ if lines.iter().any(|line| line.turns != 0) {
191
+ return None;
192
+ }
193
+ let (first, last) = (lines.first()?, lines.last()?);
194
+ let left = lines.iter().map(|line| line.left).fold(f64::MAX, f64::min);
195
+ let right = lines.iter().map(|line| line.right).fold(f64::MIN, f64::max);
196
+ let top = page.height - first.size.mul_add(0.8, first.y);
197
+ let bottom = page.height - last.size.mul_add(-0.25, last.y);
198
+ Some(Bounds { left, top, right, bottom })
199
+ }
200
+
201
+ /// The keys `key` returns for lines that repeat on enough pages to be
202
+ /// boilerplate rather than coincidence, sorted.
203
+ fn repeated_keys(pages: &[PageLines], key: impl Fn(&Line) -> u64) -> Vec<u64> {
204
+ if pages.len() < 3 {
205
+ return Vec::new();
206
+ }
207
+ let mut counts: HashMap<u64, usize> = HashMap::new();
208
+ let mut seen: Vec<u64> = Vec::new();
209
+ for page in pages {
210
+ seen.clear();
211
+ seen.extend(page.lines.iter().map(&key).filter(|&key| key != 0));
212
+ seen.sort_unstable();
213
+ seen.dedup();
214
+ for &key in &seen {
215
+ *counts.entry(key).or_default() += 1;
216
+ }
217
+ }
218
+ let threshold = (pages.len() * 2 / 5).max(3);
219
+ let mut repeated: Vec<u64> =
220
+ counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
221
+ repeated.sort_unstable();
222
+ repeated
223
+ }
224
+
225
+ /// Every page's lines that `kept` keeps.
226
+ fn kept_lines<'p>(
227
+ pages: &'p [PageLines],
228
+ kept: &'p impl Fn(&&Line) -> bool,
229
+ ) -> impl Iterator<Item = &'p Line> {
230
+ pages.iter().flat_map(|page| &page.lines).filter(kept)
231
+ }
232
+
233
+ /// The most common font size, by characters.
234
+ fn body_size(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool) -> f64 {
235
+ let mut counts: HashMap<i64, usize> = HashMap::new();
236
+ for line in kept_lines(pages, kept) {
237
+ #[expect(clippy::cast_possible_truncation, reason = "font sizes are small")]
238
+ let key = (line.size * 2.0).round() as i64;
239
+ *counts.entry(key).or_default() += line.span.len();
240
+ }
241
+ #[expect(clippy::cast_precision_loss, reason = "a small integer")]
242
+ counts
243
+ .into_iter()
244
+ .max_by_key(|&(size, count)| (count, -size))
245
+ .map_or(0.0, |(size, _)| size as f64 / 2.0)
246
+ }
247
+
248
+ /// The sizes of headings, largest first: distinct sizes clearly above the
249
+ /// body's, three at most.
250
+ fn heading_sizes(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool, body: f64) -> Vec<f64> {
251
+ let mut sizes: Vec<f64> = kept_lines(pages, kept)
252
+ .filter(|line| line.size >= body * 1.15 && line.span.len() <= 200)
253
+ .map(|line| (line.size * 2.0).round() / 2.0)
254
+ .collect();
255
+ sizes.sort_by(|a, b| b.total_cmp(a));
256
+ sizes.dedup();
257
+ sizes.truncate(3);
258
+ sizes
259
+ }
@@ -3,15 +3,15 @@
3
3
  //! the running headers and footers left out.
4
4
 
5
5
  mod bidi;
6
+ mod blocks;
6
7
  #[cfg(test)]
7
8
  mod tests;
8
9
 
9
- use std::collections::HashMap;
10
10
  use std::ops::Range;
11
11
 
12
12
  use self::bidi::{Scratch, is_mark};
13
+ pub use self::blocks::pages;
13
14
  use super::content::{Glyph, PageText};
14
- use crate::read::content::{Block, Bounds, Page, Placed, Text};
15
15
  use crate::read::span;
16
16
  use crate::script::Counts;
17
17
 
@@ -250,258 +250,3 @@ fn repeat_key(text: &str, left: f64, y: f64, turns: u8) -> u64 {
250
250
  }
251
251
  hash.max(1)
252
252
  }
253
-
254
- /// The document's pages, from their lines: blocks of the lines that are
255
- /// not running headers or footers, and, with `drop_repeated_anywhere`, not
256
- /// text repeated at the same spot on most pages anywhere on the page.
257
- pub fn pages(
258
- pages: &[PageLines],
259
- labels: Option<&[Option<String>]>,
260
- drop_repeated_anywhere: bool,
261
- ) -> Vec<Page> {
262
- let boilerplate = repeated_keys(pages, |line| line.margin);
263
- let margin_kept =
264
- |line: &&Line| line.margin == 0 || boilerplate.binary_search(&line.margin).is_err();
265
- let repeated =
266
- if drop_repeated_anywhere { repeated_keys(pages, |line| line.repeat) } else { Vec::new() };
267
- let kept = |line: &&Line| {
268
- margin_kept(line) && (repeated.is_empty() || repeated.binary_search(&line.repeat).is_err())
269
- };
270
- let body = body_size(pages, &margin_kept);
271
- let levels = heading_sizes(pages, &margin_kept, body);
272
- let mut out = Vec::with_capacity(pages.len());
273
- let mut paragraph: Vec<&Line> = Vec::new();
274
- // Each page's blocks, collected here, then moved out at their size.
275
- let mut blocks = Vec::new();
276
- for (index, (number, page)) in (1..).zip(pages).enumerate() {
277
- paragraph.clear();
278
- for line in page.lines.iter().filter(kept) {
279
- if let Some(last) = paragraph.last()
280
- && !continues(last, line)
281
- {
282
- blocks.push(block(page, &paragraph, body, &levels));
283
- paragraph.clear();
284
- }
285
- paragraph.push(line);
286
- }
287
- if !paragraph.is_empty() {
288
- blocks.push(block(page, &paragraph, body, &levels));
289
- }
290
- #[expect(
291
- clippy::drain_collect,
292
- clippy::iter_with_drain,
293
- reason = "`blocks` keeps its buffer for the next page"
294
- )]
295
- let blocks = blocks.drain(..).collect();
296
- let label = labels
297
- .map_or_else(|| printed_label(page), |labels| labels.get(index).cloned().flatten());
298
- out.push(Page { number, blocks, images: page.images, label });
299
- }
300
- out
301
- }
302
-
303
- /// The page's own printed label, from a single header or footer line that
304
- /// looks like one: a bare page number or roman numeral ("`-i-`", "`7`"), or
305
- /// the same after a short label word ("`Page 70`" -> "`70`"). Unlike the
306
- /// boilerplate this document's other margin lines are checked against, a
307
- /// running "`Page 70`", "`Page 71`", ... header is not noise here: each
308
- /// instance *is* that page's label, not a repeated line to drop.
309
- fn printed_label(page: &PageLines) -> Option<String> {
310
- let mut candidates =
311
- page.lines.iter().filter(|line: &&Line| line.margin != 0).filter_map(|line| {
312
- let text = page.text(line).trim();
313
- page_number_label(text)
314
- });
315
- let label = candidates.next()?;
316
- candidates.next().is_none().then_some(label)
317
- }
318
-
319
- /// `text` as a page label, if it looks like one, stripped of a leading
320
- /// label word such as "Page" when there is one.
321
- fn page_number_label(text: &str) -> Option<String> {
322
- if looks_like_page_number(text) {
323
- return Some(text.to_owned());
324
- }
325
- let word_end = text.find(|ch: char| !ch.is_ascii_alphabetic())?;
326
- if word_end == 0 {
327
- return None;
328
- }
329
- let rest = text[word_end..].trim_start_matches(|ch: char| !ch.is_alphanumeric());
330
- looks_like_page_number(rest).then(|| rest.to_owned())
331
- }
332
-
333
- fn looks_like_page_number(text: &str) -> bool {
334
- let core = text.trim_matches(|ch: char| !ch.is_alphanumeric());
335
- !core.is_empty()
336
- && core.len() <= 8
337
- && (core.chars().all(|ch| ch.is_ascii_digit())
338
- || core.chars().all(|ch| {
339
- matches!(ch.to_ascii_lowercase(), 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')
340
- }))
341
- }
342
-
343
- /// Whether `next` carries on the paragraph `last` is in: same direction,
344
- /// similar size, and no more than a line and a half below it.
345
- fn continues(last: &Line, next: &Line) -> bool {
346
- let size = last.size.max(next.size).max(1.0);
347
- last.turns == next.turns
348
- && (last.size - next.size).abs() <= 0.15 * size
349
- && last.y - next.y > 0.0
350
- && last.y - next.y <= 1.55 * size
351
- && last.bold == next.bold
352
- }
353
-
354
- /// A paragraph of lines as a block: a heading when its few short lines
355
- /// are set in a heading size, otherwise a paragraph with its lines on lines
356
- /// of their own, words hyphenated across them joined again.
357
- fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
358
- let first = lines[0];
359
- let length: usize = lines.iter().map(|line| line.span.len()).sum();
360
- let short = lines.len() <= 3 && length <= 200;
361
- let text = paragraph_text(page, lines);
362
- let level =
363
- if short { levels.iter().position(|&size| first.size >= size - 0.25) } else { None };
364
- let sized = level
365
- .filter(|_| first.size >= body * 1.15)
366
- .map(|level| u8::try_from(level.min(2) + 1).unwrap_or(3));
367
- let numbered = (short && first.bold && first.size >= body - 0.25)
368
- .then(|| numbered_heading_level(&text))
369
- .flatten();
370
- let block = match sized.or(numbered) {
371
- Some(level) => Block::Heading { level, text: Text::plain(text) },
372
- None => Block::Paragraph { text: Text::plain(text) },
373
- };
374
- Placed { block, bounds: bounds(page, lines) }
375
- }
376
-
377
- fn paragraph_text(page: &PageLines, lines: &[&Line]) -> String {
378
- let length: usize = lines.iter().map(|line| line.span.len()).sum();
379
- let mut text = String::with_capacity(length + lines.len());
380
- for (index, line) in lines.iter().enumerate() {
381
- let line = page.text(line);
382
- if index > 0 {
383
- let joined = text.ends_with('-') && {
384
- let mut before = text.chars().rev().skip(1);
385
- before.next().is_some_and(char::is_lowercase)
386
- && line.chars().next().is_some_and(char::is_lowercase)
387
- };
388
- if joined {
389
- text.pop();
390
- } else {
391
- text.push('\n');
392
- }
393
- }
394
- text.push_str(line);
395
- }
396
- text
397
- }
398
-
399
- fn numbered_heading_level(text: &str) -> Option<u8> {
400
- let (depth, rest) = numbered_prefix(text)?;
401
- if depth < 2 || !(rest.is_empty() || rest.starts_with(' ')) {
402
- return None;
403
- }
404
- let title = rest.trim_start();
405
- if title.contains("....") || title.starts_with(|ch: char| !ch.is_alphanumeric()) {
406
- return None;
407
- }
408
- Some(u8::try_from(depth).unwrap_or(6).min(6))
409
- }
410
-
411
- fn numbered_prefix(text: &str) -> Option<(usize, &str)> {
412
- let bytes = text.as_bytes();
413
- let mut groups = 0_usize;
414
- let mut pos = 0_usize;
415
- loop {
416
- let start = pos;
417
- while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
418
- pos += 1;
419
- }
420
- if pos == start {
421
- break;
422
- }
423
- groups += 1;
424
- if groups >= 4
425
- || bytes.get(pos) != Some(&b'.')
426
- || !bytes.get(pos + 1).is_some_and(u8::is_ascii_digit)
427
- {
428
- break;
429
- }
430
- pos += 1;
431
- }
432
- (groups > 0).then(|| (groups, &text[pos..]))
433
- }
434
-
435
- /// Where upright lines lie on the page, from its top-left corner: from the
436
- /// top of the first line's capitals to below the last line's descenders.
437
- fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
438
- if lines.iter().any(|line| line.turns != 0) {
439
- return None;
440
- }
441
- let (first, last) = (lines.first()?, lines.last()?);
442
- let left = lines.iter().map(|line| line.left).fold(f64::MAX, f64::min);
443
- let right = lines.iter().map(|line| line.right).fold(f64::MIN, f64::max);
444
- let top = page.height - first.size.mul_add(0.8, first.y);
445
- let bottom = page.height - last.size.mul_add(-0.25, last.y);
446
- Some(Bounds { left, top, right, bottom })
447
- }
448
-
449
- /// The keys `key` returns for lines that repeat on enough pages to be
450
- /// boilerplate rather than coincidence, sorted.
451
- fn repeated_keys(pages: &[PageLines], key: impl Fn(&Line) -> u64) -> Vec<u64> {
452
- if pages.len() < 3 {
453
- return Vec::new();
454
- }
455
- let mut counts: HashMap<u64, usize> = HashMap::new();
456
- let mut seen: Vec<u64> = Vec::new();
457
- for page in pages {
458
- seen.clear();
459
- seen.extend(page.lines.iter().map(&key).filter(|&key| key != 0));
460
- seen.sort_unstable();
461
- seen.dedup();
462
- for &key in &seen {
463
- *counts.entry(key).or_default() += 1;
464
- }
465
- }
466
- let threshold = (pages.len() * 2 / 5).max(3);
467
- let mut repeated: Vec<u64> =
468
- counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
469
- repeated.sort_unstable();
470
- repeated
471
- }
472
-
473
- /// Every page's lines that `kept` keeps.
474
- fn kept_lines<'p>(
475
- pages: &'p [PageLines],
476
- kept: &'p impl Fn(&&Line) -> bool,
477
- ) -> impl Iterator<Item = &'p Line> {
478
- pages.iter().flat_map(|page| &page.lines).filter(kept)
479
- }
480
-
481
- /// The most common font size, by characters.
482
- fn body_size(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool) -> f64 {
483
- let mut counts: HashMap<i64, usize> = HashMap::new();
484
- for line in kept_lines(pages, kept) {
485
- #[expect(clippy::cast_possible_truncation, reason = "font sizes are small")]
486
- let key = (line.size * 2.0).round() as i64;
487
- *counts.entry(key).or_default() += line.span.len();
488
- }
489
- #[expect(clippy::cast_precision_loss, reason = "a small integer")]
490
- counts
491
- .into_iter()
492
- .max_by_key(|&(size, count)| (count, -size))
493
- .map_or(0.0, |(size, _)| size as f64 / 2.0)
494
- }
495
-
496
- /// The sizes of headings, largest first: distinct sizes clearly above the
497
- /// body's, three at most.
498
- fn heading_sizes(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool, body: f64) -> Vec<f64> {
499
- let mut sizes: Vec<f64> = kept_lines(pages, kept)
500
- .filter(|line| line.size >= body * 1.15 && line.span.len() <= 200)
501
- .map(|line| (line.size * 2.0).round() / 2.0)
502
- .collect();
503
- sizes.sort_by(|a, b| b.total_cmp(a));
504
- sizes.dedup();
505
- sizes.truncate(3);
506
- sizes
507
- }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur_native"
3
- version = "0.8.0"
3
+ version = "0.8.1"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -4,8 +4,10 @@ use imprimatur::read::{Limits, pdf};
4
4
  use magnus::{Error, RString, Ruby};
5
5
  use serde_json::json;
6
6
  use std::path::PathBuf;
7
+ use std::time::Duration;
7
8
 
8
9
  use crate::read::without_gvl;
10
+ use crate::{argument_error, domain_error};
9
11
 
10
12
  type Keywords = (Option<usize>, Option<u64>, Option<f64>, bool);
11
13
 
@@ -29,39 +31,61 @@ pub fn pages(
29
31
  ruby: &Ruby,
30
32
  document: RString,
31
33
  keywords: Keywords,
32
- options: (u32, u32, usize, usize, usize),
34
+ options: (u32, u32, usize, usize, usize, Option<f64>),
33
35
  worker: String,
34
36
  memory_bytes: u64,
35
37
  ) -> Result<RString, Error> {
36
- let (first_page, last_page, max_pages, max_page_bytes, max_output_bytes) = options;
38
+ let (first_page, last_page, max_pages, max_page_bytes, max_output_bytes, batch_timeout) =
39
+ options;
37
40
  let options =
38
41
  pdf::ReadOptions { first_page, last_page, max_pages, max_page_bytes, max_output_bytes };
39
42
  let worker = PathBuf::from(worker);
40
43
  let process = process_limits(memory_bytes);
41
- let value = without_gvl(ruby, document, keywords, |data, limits| {
42
- let result = pdf::isolation::pages_isolated(&worker, data, &options, limits, &process)?;
43
- let pages: Vec<_> = result
44
- .pages
45
- .into_iter()
46
- .map(|page| {
47
- json!({
48
- "page": page.number,
49
- "status": page.status.as_str(),
50
- "text": page.text,
51
- "images": page.images,
52
- "truncated": page.truncated,
53
- "reason": page.reason,
54
- })
44
+ let Some(seconds) = batch_timeout else {
45
+ let result = without_gvl(ruby, document, keywords, |data, limits| {
46
+ pdf::isolation::pages_isolated(&worker, data, &options, limits, &process)
47
+ })?;
48
+ return Ok(ruby.str_new(&pages_json(result)));
49
+ };
50
+ let attempt = Duration::try_from_secs_f64(seconds)
51
+ .map_err(|_| argument_error(ruby, "batch timeout must be finite and positive"))?;
52
+ let mut recovery = pdf::isolation::Recovery::new(&options, attempt)
53
+ .map_err(|error| domain_error(ruby, &error))?;
54
+ loop {
55
+ let step = without_gvl(ruby, document, keywords, |data, limits| {
56
+ let process = pdf::isolation::ProcessLimits {
57
+ wall_time: limits.timeout_duration().unwrap_or(process.wall_time),
58
+ ..process.clone()
59
+ };
60
+ recovery.step(&worker, data, limits, &process)
61
+ })?;
62
+ if let Some(result) = step {
63
+ return Ok(ruby.str_new(&pages_json(result)));
64
+ }
65
+ }
66
+ }
67
+
68
+ fn pages_json(result: pdf::Pages) -> String {
69
+ let pages: Vec<_> = result
70
+ .pages
71
+ .into_iter()
72
+ .map(|page| {
73
+ json!({
74
+ "page": page.number,
75
+ "status": page.status.as_str(),
76
+ "text": page.text,
77
+ "images": page.images,
78
+ "truncated": page.truncated,
79
+ "reason": page.reason,
55
80
  })
56
- .collect();
57
- Ok(json!({
58
- "page_count": result.info.page_count,
59
- "encrypted": result.info.encrypted,
60
- "pages": pages,
61
81
  })
62
- .to_string())
63
- })?;
64
- Ok(ruby.str_new(&value))
82
+ .collect();
83
+ json!({
84
+ "page_count": result.info.page_count,
85
+ "encrypted": result.info.encrypted,
86
+ "pages": pages,
87
+ })
88
+ .to_string()
65
89
  }
66
90
 
67
91
  pub fn render(
@@ -127,7 +127,7 @@ pub fn without_gvl<T, F>(
127
127
  job: F,
128
128
  ) -> Result<T, Error>
129
129
  where
130
- F: Fn(&[u8], &Limits) -> imprimatur::Result<T>,
130
+ F: FnMut(&[u8], &Limits) -> imprimatur::Result<T>,
131
131
  {
132
132
  let document = crate::bytes(document);
133
133
  let cancel = Arc::new(AtomicBool::new(false));
@@ -174,7 +174,7 @@ where
174
174
  #[allow(unsafe_code)]
175
175
  unsafe extern "C" fn run<T, F>(data: *mut c_void) -> *mut c_void
176
176
  where
177
- F: Fn(&[u8], &Limits) -> imprimatur::Result<T>,
177
+ F: FnMut(&[u8], &Limits) -> imprimatur::Result<T>,
178
178
  {
179
179
  // SAFETY: `data` is the `Read<T>` without_gvl passed, alive and not
180
180
  // otherwise borrowed until this returns.
@@ -11,8 +11,9 @@ module Imprimatur
11
11
 
12
12
  def self.pages(document, first_page: 1, last_page: 0xFFFFFFFF, max_pages: 500,
13
13
  max_page_bytes: 256 * 1024, max_output_bytes: 8 * 1024 * 1024,
14
- memory_bytes: 1024 * 1024 * 1024, max_bytes: nil, **limits)
15
- options = [first_page, last_page, max_pages, max_page_bytes, max_output_bytes]
14
+ memory_bytes: 1024 * 1024 * 1024, max_bytes: nil, batch_timeout: nil, **limits)
15
+ options = [first_page, last_page, max_pages, max_page_bytes, max_output_bytes,
16
+ batch_timeout && Float(batch_timeout)]
16
17
  JSON.parse(Native.pdf_pages(Reader.read(document, max_bytes), Reader.limits(max_bytes:, **limits),
17
18
  options, RENDER_WORKER, memory_bytes),
18
19
  symbolize_names: true)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- VERSION = '0.8.0'
4
+ VERSION = '0.8.1'
5
5
  end
data/lib/imprimatur.rb CHANGED
@@ -115,6 +115,8 @@ module Imprimatur
115
115
  # image count (nil when unknown), truncation flag and failure reason.
116
116
  # Takes +first_page:+, +last_page:+, +max_pages:+ (500),
117
117
  # +max_page_bytes:+ (256 KiB), +max_output_bytes:+ (8 MiB), and read limits.
118
+ # Optional +batch_timeout:+ caps each worker attempt; +timeout:+ then bounds
119
+ # the whole call, including one-pass recovery of timeout-unread neighbours.
118
120
  # @return [Hash] +page_count:+, +encrypted:+ and +pages:+
119
121
  def pdf_pages(document, ...) = PdfReader.pages(document, ...)
120
122
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imprimatur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -115,11 +115,13 @@ files:
115
115
  - ext/imprimatur_native/core/src/read/pdf/images.rs
116
116
  - ext/imprimatur_native/core/src/read/pdf/isolation.rs
117
117
  - ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs
118
+ - ext/imprimatur_native/core/src/read/pdf/isolation/recovery.rs
118
119
  - ext/imprimatur_native/core/src/read/pdf/isolation/stream.rs
119
120
  - ext/imprimatur_native/core/src/read/pdf/isolation/system.rs
120
121
  - ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs
121
122
  - ext/imprimatur_native/core/src/read/pdf/layout.rs
122
123
  - ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs
124
+ - ext/imprimatur_native/core/src/read/pdf/layout/blocks.rs
123
125
  - ext/imprimatur_native/core/src/read/pdf/lexer.rs
124
126
  - ext/imprimatur_native/core/src/read/pdf/object.rs
125
127
  - ext/imprimatur_native/core/src/read/pdf/object/name.rs