imprimatur 0.7.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: 9812ca055242755ab6117d2c5eba9bb1991e02a21572682f9f36c2a0481e2139
4
- data.tar.gz: 8e1d32d440ec79ea732c62dd0a830c0b13f76655df7f9b85d60d539e0d429936
3
+ metadata.gz: 20a7d3843214018bf15d889668ad1d33ba8dfee183b86e041d56b5cc2db88832
4
+ data.tar.gz: 6699fc743a0a04fffbaf67d68551ca680c416cdc56f1fd71984ec2f7cba8e5b1
5
5
  SHA512:
6
- metadata.gz: 3572b1a17819ae0fa85346788343f19ef9410d19f48b7257edb95dc00544ae1234991a5d0b50284ed3d10e21f34ca991f965aa7956fecb6a03244805870cea13
7
- data.tar.gz: 9399ac644f99a699a12a2a8bdcefe73977bddcec75571e8053d984d829ddc33817fd40bb6c9c79fd15ce89f56fbe472035c276e3177430b9a35ab20023b66f16
6
+ metadata.gz: ef0a7f9c8d545aa513881626e798702095c66a42a5665b663a56b4c6205e5a0c09596366d9dae05c6ac6539f46ba242744feb2ab99080e906b418c0af2550625
7
+ data.tar.gz: 38ccdff0d6b4d2bfcb516660de96f1274ac0d1cef01b9d97f2790b0223b041ec2fd5b747be21a053a368c0bf58e33e2f8b9c608ed1bd5d5ea2e1da7bfd218615
data/CHANGELOG.md CHANGED
@@ -11,6 +11,26 @@
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
+
22
+ ## [0.8.0](https://github.com/seuros/imprimatur/compare/v0.7.0...v0.8.0) (2026-10-27)
23
+
24
+
25
+ ### Features
26
+
27
+ * **reader:** accept drop_repeated in the Ruby API ([4e537d6](https://github.com/seuros/imprimatur/commit/4e537d641bafd8d3cdb8b1c20d16f4cb766ad073))
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **pdf:** read printed page numbers from headers too, not just footers ([c036434](https://github.com/seuros/imprimatur/commit/c0364341ee8fc36be2c97d25fd06da0aa76a4ad6))
33
+
14
34
  ## [0.7.0](https://github.com/seuros/imprimatur/compare/v0.6.1...v0.7.0) (2026-10-26)
15
35
 
16
36
 
data/Cargo.lock CHANGED
@@ -513,7 +513,7 @@ dependencies = [
513
513
 
514
514
  [[package]]
515
515
  name = "imprimatur"
516
- version = "0.7.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.7.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.7.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.7.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
@@ -32,7 +32,7 @@ end
32
32
  gem "imprimatur"
33
33
  ```
34
34
 
35
- Precompiled gems ship for Linux (x86_64 and aarch64, glibc 2.34+ and musl),
35
+ Precompiled gems ship for Linux (x86_64 and aarch64, glibc 2.34+),
36
36
  macOS 13+ (arm64, x86_64) and FreeBSD 15.1 (amd64): nothing to compile.
37
37
  Anywhere else, the source gem compiles the extension on install, with the
38
38
  toolchain from [rustup.rs](https://rustup.rs) (1.99+).
@@ -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.7.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"]
@@ -16,7 +16,8 @@ use crate::app::{self, App, Out, Status};
16
16
  /// PDF: the text layer, placed glyph by glyph, with headings by font size,
17
17
  /// running headers and footers left out, and pages after the first marked
18
18
  /// `<!-- page N -->` (or `<!-- page N (label L) -->` with its printed page
19
- /// label, from `/PageLabels` or a footer that looks like one). Encrypted
19
+ /// label, from `/PageLabels` or a header or footer line that looks like
20
+ /// one). Encrypted
20
21
  /// PDFs open when their user password is empty. XLSX, XLS and ODS: each
21
22
  /// sheet as a table, values as shown, then its comments. DOCX: headings,
22
23
  /// lists, emphasis, links and tables.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.7.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 {
@@ -44,7 +44,10 @@ pub enum Mode {
44
44
  }
45
45
 
46
46
  fn pdf(out: &mut String, pages: &[Page]) {
47
- for page in pages.iter().filter(|page| !page.blocks.is_empty()) {
47
+ // Every page past the first gets its marker, blocks or none: an empty
48
+ // page (left bare by `--drop-repeated`, or genuinely blank) still needs
49
+ // one, so the markers stay a reliable page count and index.
50
+ for page in pages {
48
51
  if page.number > 1 {
49
52
  end_block(out);
50
53
  // Writing to a String cannot fail.
@@ -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,244 +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.map_or_else(
297
- || footer_label(page, &boilerplate),
298
- |labels| labels.get(index).cloned().flatten(),
299
- );
300
- out.push(Page { number, blocks, images: page.images, label });
301
- }
302
- out
303
- }
304
-
305
- fn footer_label(page: &PageLines, boilerplate: &[u64]) -> Option<String> {
306
- let mut candidates = page.lines.iter().filter(|line: &&Line| {
307
- line.margin != 0
308
- && line.y < page.height * EDGE
309
- && boilerplate.binary_search(&line.margin).is_err()
310
- });
311
- let line = candidates.next()?;
312
- if candidates.next().is_some() {
313
- return None;
314
- }
315
- let text = page.text(line).trim();
316
- looks_like_page_number(text).then(|| text.to_owned())
317
- }
318
-
319
- fn looks_like_page_number(text: &str) -> bool {
320
- let core = text.trim_matches(|ch: char| !ch.is_alphanumeric());
321
- !core.is_empty()
322
- && core.len() <= 8
323
- && (core.chars().all(|ch| ch.is_ascii_digit())
324
- || core.chars().all(|ch| {
325
- matches!(ch.to_ascii_lowercase(), 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')
326
- }))
327
- }
328
-
329
- /// Whether `next` carries on the paragraph `last` is in: same direction,
330
- /// similar size, and no more than a line and a half below it.
331
- fn continues(last: &Line, next: &Line) -> bool {
332
- let size = last.size.max(next.size).max(1.0);
333
- last.turns == next.turns
334
- && (last.size - next.size).abs() <= 0.15 * size
335
- && last.y - next.y > 0.0
336
- && last.y - next.y <= 1.55 * size
337
- && last.bold == next.bold
338
- }
339
-
340
- /// A paragraph of lines as a block: a heading when its few short lines
341
- /// are set in a heading size, otherwise a paragraph with its lines on lines
342
- /// of their own, words hyphenated across them joined again.
343
- fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
344
- let first = lines[0];
345
- let length: usize = lines.iter().map(|line| line.span.len()).sum();
346
- let short = lines.len() <= 3 && length <= 200;
347
- let text = paragraph_text(page, lines);
348
- let level =
349
- if short { levels.iter().position(|&size| first.size >= size - 0.25) } else { None };
350
- let sized = level
351
- .filter(|_| first.size >= body * 1.15)
352
- .map(|level| u8::try_from(level.min(2) + 1).unwrap_or(3));
353
- let numbered = (short && first.bold && first.size >= body - 0.25)
354
- .then(|| numbered_heading_level(&text))
355
- .flatten();
356
- let block = match sized.or(numbered) {
357
- Some(level) => Block::Heading { level, text: Text::plain(text) },
358
- None => Block::Paragraph { text: Text::plain(text) },
359
- };
360
- Placed { block, bounds: bounds(page, lines) }
361
- }
362
-
363
- fn paragraph_text(page: &PageLines, lines: &[&Line]) -> String {
364
- let length: usize = lines.iter().map(|line| line.span.len()).sum();
365
- let mut text = String::with_capacity(length + lines.len());
366
- for (index, line) in lines.iter().enumerate() {
367
- let line = page.text(line);
368
- if index > 0 {
369
- let joined = text.ends_with('-') && {
370
- let mut before = text.chars().rev().skip(1);
371
- before.next().is_some_and(char::is_lowercase)
372
- && line.chars().next().is_some_and(char::is_lowercase)
373
- };
374
- if joined {
375
- text.pop();
376
- } else {
377
- text.push('\n');
378
- }
379
- }
380
- text.push_str(line);
381
- }
382
- text
383
- }
384
-
385
- fn numbered_heading_level(text: &str) -> Option<u8> {
386
- let (depth, rest) = numbered_prefix(text)?;
387
- if depth < 2 || !(rest.is_empty() || rest.starts_with(' ')) {
388
- return None;
389
- }
390
- let title = rest.trim_start();
391
- if title.contains("....") || title.starts_with(|ch: char| !ch.is_alphanumeric()) {
392
- return None;
393
- }
394
- Some(u8::try_from(depth).unwrap_or(6).min(6))
395
- }
396
-
397
- fn numbered_prefix(text: &str) -> Option<(usize, &str)> {
398
- let bytes = text.as_bytes();
399
- let mut groups = 0_usize;
400
- let mut pos = 0_usize;
401
- loop {
402
- let start = pos;
403
- while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
404
- pos += 1;
405
- }
406
- if pos == start {
407
- break;
408
- }
409
- groups += 1;
410
- if groups >= 4
411
- || bytes.get(pos) != Some(&b'.')
412
- || !bytes.get(pos + 1).is_some_and(u8::is_ascii_digit)
413
- {
414
- break;
415
- }
416
- pos += 1;
417
- }
418
- (groups > 0).then(|| (groups, &text[pos..]))
419
- }
420
-
421
- /// Where upright lines lie on the page, from its top-left corner: from the
422
- /// top of the first line's capitals to below the last line's descenders.
423
- fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
424
- if lines.iter().any(|line| line.turns != 0) {
425
- return None;
426
- }
427
- let (first, last) = (lines.first()?, lines.last()?);
428
- let left = lines.iter().map(|line| line.left).fold(f64::MAX, f64::min);
429
- let right = lines.iter().map(|line| line.right).fold(f64::MIN, f64::max);
430
- let top = page.height - first.size.mul_add(0.8, first.y);
431
- let bottom = page.height - last.size.mul_add(-0.25, last.y);
432
- Some(Bounds { left, top, right, bottom })
433
- }
434
-
435
- /// The keys `key` returns for lines that repeat on enough pages to be
436
- /// boilerplate rather than coincidence, sorted.
437
- fn repeated_keys(pages: &[PageLines], key: impl Fn(&Line) -> u64) -> Vec<u64> {
438
- if pages.len() < 3 {
439
- return Vec::new();
440
- }
441
- let mut counts: HashMap<u64, usize> = HashMap::new();
442
- let mut seen: Vec<u64> = Vec::new();
443
- for page in pages {
444
- seen.clear();
445
- seen.extend(page.lines.iter().map(&key).filter(|&key| key != 0));
446
- seen.sort_unstable();
447
- seen.dedup();
448
- for &key in &seen {
449
- *counts.entry(key).or_default() += 1;
450
- }
451
- }
452
- let threshold = (pages.len() * 2 / 5).max(3);
453
- let mut repeated: Vec<u64> =
454
- counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
455
- repeated.sort_unstable();
456
- repeated
457
- }
458
-
459
- /// Every page's lines that `kept` keeps.
460
- fn kept_lines<'p>(
461
- pages: &'p [PageLines],
462
- kept: &'p impl Fn(&&Line) -> bool,
463
- ) -> impl Iterator<Item = &'p Line> {
464
- pages.iter().flat_map(|page| &page.lines).filter(kept)
465
- }
466
-
467
- /// The most common font size, by characters.
468
- fn body_size(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool) -> f64 {
469
- let mut counts: HashMap<i64, usize> = HashMap::new();
470
- for line in kept_lines(pages, kept) {
471
- #[expect(clippy::cast_possible_truncation, reason = "font sizes are small")]
472
- let key = (line.size * 2.0).round() as i64;
473
- *counts.entry(key).or_default() += line.span.len();
474
- }
475
- #[expect(clippy::cast_precision_loss, reason = "a small integer")]
476
- counts
477
- .into_iter()
478
- .max_by_key(|&(size, count)| (count, -size))
479
- .map_or(0.0, |(size, _)| size as f64 / 2.0)
480
- }
481
-
482
- /// The sizes of headings, largest first: distinct sizes clearly above the
483
- /// body's, three at most.
484
- fn heading_sizes(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool, body: f64) -> Vec<f64> {
485
- let mut sizes: Vec<f64> = kept_lines(pages, kept)
486
- .filter(|line| line.size >= body * 1.15 && line.span.len() <= 200)
487
- .map(|line| (line.size * 2.0).round() / 2.0)
488
- .collect();
489
- sizes.sort_by(|a, b| b.total_cmp(a));
490
- sizes.dedup();
491
- sizes.truncate(3);
492
- sizes
493
- }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur_native"
3
- version = "0.7.0"
3
+ version = "0.8.1"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -323,13 +323,13 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
323
323
  barcode::define(ruby, imprimatur)?;
324
324
 
325
325
  let native = imprimatur.define_module("Native")?;
326
- native.define_singleton_method("markdown", function!(read::markdown_native, 4))?;
327
- native.define_singleton_method("content", function!(read::content_native, 5))?;
328
- native.define_singleton_method("images", function!(read::images_native, 4))?;
326
+ native.define_singleton_method("markdown", function!(read::markdown_native, 5))?;
327
+ native.define_singleton_method("content", function!(read::content_native, 6))?;
328
+ native.define_singleton_method("images", function!(read::images_native, 5))?;
329
329
  native.define_singleton_method("pdf_info", function!(pdf::info, 4))?;
330
330
  native.define_singleton_method("pdf_pages", function!(pdf::pages, 5))?;
331
331
  native.define_singleton_method("render_pdf_page", function!(pdf::render, 5))?;
332
- native.define_singleton_method("sheets", function!(sheets::sheets_native, 5))?;
332
+ native.define_singleton_method("sheets", function!(sheets::sheets_native, 6))?;
333
333
  let zip_writer = native.define_class("ZipWriter", ruby.class_object())?;
334
334
  zip_writer.define_singleton_method("new", function!(archive::ZipWriter::new, 1))?;
335
335
  zip_writer.define_method("add_file", method!(archive::ZipWriter::add_file, 3))?;
@@ -4,10 +4,12 @@ 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
- type Keywords = (Option<usize>, Option<u64>, Option<f64>);
12
+ type Keywords = (Option<usize>, Option<u64>, Option<f64>, bool);
11
13
 
12
14
  pub fn info(
13
15
  ruby: &Ruby,
@@ -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(
@@ -25,15 +25,19 @@ struct Read<T, F> {
25
25
  }
26
26
 
27
27
  /// Called by `Imprimatur.markdown`, which owns the keyword interface.
28
- /// `timeout` is in seconds; `nil` limits keep their defaults.
28
+ /// `timeout` is in seconds; `nil` limits keep their defaults. `drop_repeated`
29
+ /// drops PDF text repeated at the same spot on most pages, anywhere on the
30
+ /// page (watermarks); off by default.
29
31
  pub fn markdown_native(
30
32
  ruby: &Ruby,
31
33
  document: RString,
32
34
  max_input: Option<usize>,
33
35
  max_inflated: Option<u64>,
34
36
  timeout: Option<f64>,
37
+ drop_repeated: bool,
35
38
  ) -> Result<RString, Error> {
36
- let text = without_gvl(ruby, document, (max_input, max_inflated, timeout), markdown)?;
39
+ let text =
40
+ without_gvl(ruby, document, (max_input, max_inflated, timeout, drop_repeated), markdown)?;
37
41
  Ok(ruby.str_new(&text))
38
42
  }
39
43
 
@@ -45,9 +49,11 @@ pub fn content_native(
45
49
  max_input: Option<usize>,
46
50
  max_inflated: Option<u64>,
47
51
  timeout: Option<f64>,
52
+ drop_repeated: bool,
48
53
  styles: bool,
49
54
  ) -> Result<RString, Error> {
50
- let read = without_gvl(ruby, document, (max_input, max_inflated, timeout), content)?;
55
+ let read =
56
+ without_gvl(ruby, document, (max_input, max_inflated, timeout, drop_repeated), content)?;
51
57
  let options = json::Options { styles };
52
58
  Ok(ruby.str_new(&Json::Object(json::content_with(&read, options)).to_string()))
53
59
  }
@@ -60,9 +66,13 @@ pub fn images_native(
60
66
  max_input: Option<usize>,
61
67
  max_inflated: Option<u64>,
62
68
  timeout: Option<f64>,
69
+ drop_repeated: bool,
63
70
  ) -> Result<RArray, Error> {
64
- let found =
65
- without_gvl(ruby, document, (max_input, max_inflated, timeout), |bytes, limits| {
71
+ let found = without_gvl(
72
+ ruby,
73
+ document,
74
+ (max_input, max_inflated, timeout, drop_repeated),
75
+ |bytes, limits| {
66
76
  let mut found = Vec::new();
67
77
  images(bytes, limits, |image| {
68
78
  let description = Json::Object(json::image(&image)).to_string();
@@ -70,7 +80,8 @@ pub fn images_native(
70
80
  Ok(())
71
81
  })?;
72
82
  Ok(found)
73
- })?;
83
+ },
84
+ )?;
74
85
  let pairs = ruby.ary_new_capa(found.len());
75
86
  for (data, description) in found {
76
87
  let pair = [
@@ -86,9 +97,14 @@ pub fn images_native(
86
97
  fn limits(
87
98
  ruby: &Ruby,
88
99
  cancel: &Arc<AtomicBool>,
89
- (max_input, max_inflated, timeout): (Option<usize>, Option<u64>, Option<f64>),
100
+ (max_input, max_inflated, timeout, drop_repeated): (
101
+ Option<usize>,
102
+ Option<u64>,
103
+ Option<f64>,
104
+ bool,
105
+ ),
90
106
  ) -> Result<Limits, Error> {
91
- let mut limits = Limits::default().cancel_on(Arc::clone(cancel));
107
+ let mut limits = Limits::default().cancel_on(Arc::clone(cancel)).drop_repeated(drop_repeated);
92
108
  if let Some(bytes) = max_input {
93
109
  limits = limits.max_input(bytes);
94
110
  }
@@ -107,11 +123,11 @@ fn limits(
107
123
  pub fn without_gvl<T, F>(
108
124
  ruby: &Ruby,
109
125
  document: RString,
110
- keywords: (Option<usize>, Option<u64>, Option<f64>),
126
+ keywords: (Option<usize>, Option<u64>, Option<f64>, bool),
111
127
  job: F,
112
128
  ) -> Result<T, Error>
113
129
  where
114
- F: Fn(&[u8], &Limits) -> imprimatur::Result<T>,
130
+ F: FnMut(&[u8], &Limits) -> imprimatur::Result<T>,
115
131
  {
116
132
  let document = crate::bytes(document);
117
133
  let cancel = Arc::new(AtomicBool::new(false));
@@ -158,7 +174,7 @@ where
158
174
  #[allow(unsafe_code)]
159
175
  unsafe extern "C" fn run<T, F>(data: *mut c_void) -> *mut c_void
160
176
  where
161
- F: Fn(&[u8], &Limits) -> imprimatur::Result<T>,
177
+ F: FnMut(&[u8], &Limits) -> imprimatur::Result<T>,
162
178
  {
163
179
  // SAFETY: `data` is the `Read<T>` without_gvl passed, alive and not
164
180
  // otherwise borrowed until this returns.
@@ -23,9 +23,11 @@ pub fn sheets_native(
23
23
  max_input: Option<usize>,
24
24
  max_inflated: Option<u64>,
25
25
  timeout: Option<f64>,
26
+ drop_repeated: bool,
26
27
  styles: bool,
27
28
  ) -> Result<RArray, Error> {
28
- let read = without_gvl(ruby, document, (max_input, max_inflated, timeout), content)?;
29
+ let read =
30
+ without_gvl(ruby, document, (max_input, max_inflated, timeout, drop_repeated), content)?;
29
31
  let Some(sheets) = read.sheets() else {
30
32
  let kind = if matches!(read, Content::Pdf(_)) { "a PDF" } else { "a Word document" };
31
33
  let error = imprimatur::Error::UnsupportedDocument(format!("{kind} is no spreadsheet"));
@@ -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)
@@ -41,8 +41,8 @@ module Imprimatur
41
41
  end
42
42
 
43
43
  # @api private
44
- def self.limits(timeout: 60, max_bytes: nil, max_inflated: nil)
45
- [max_bytes, max_inflated, timeout && Float(timeout)]
44
+ def self.limits(timeout: 60, max_bytes: nil, max_inflated: nil, drop_repeated: false)
45
+ [max_bytes, max_inflated, timeout && Float(timeout), drop_repeated ? true : false]
46
46
  end
47
47
 
48
48
  # A path or an IO is read no further than +max_bytes+ (one byte past it
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.1'
5
5
  end
data/lib/imprimatur.rb CHANGED
@@ -72,6 +72,8 @@ module Imprimatur
72
72
  # @param timeout [Numeric, nil] seconds the read may take (60), +nil+ for none
73
73
  # @param max_bytes [Integer, nil] the largest document accepted (512 MiB)
74
74
  # @param max_inflated [Integer, nil] decompressed bytes allowed in all (1 GiB)
75
+ # @param drop_repeated [Boolean] PDF only: drop text repeated at the same
76
+ # spot on most pages, anywhere on the page (watermarks); off by default
75
77
  # @return [String] Markdown, UTF-8
76
78
  # @raise [NoTextLayer] a PDF of scanned pages: run OCR instead
77
79
  # @raise [PasswordRequired] a PDF that needs a password to open
@@ -113,6 +115,8 @@ module Imprimatur
113
115
  # image count (nil when unknown), truncation flag and failure reason.
114
116
  # Takes +first_page:+, +last_page:+, +max_pages:+ (500),
115
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.
116
120
  # @return [Hash] +page_count:+, +encrypted:+ and +pages:+
117
121
  def pdf_pages(document, ...) = PdfReader.pages(document, ...)
118
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.7.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