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 +4 -4
- data/CHANGELOG.md +20 -0
- data/Cargo.lock +3 -3
- data/Cargo.toml +1 -1
- data/README.md +23 -1
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -1
- data/ext/imprimatur_native/core/Cargo.toml +1 -1
- data/ext/imprimatur_native/core/src/read/budget.rs +6 -0
- data/ext/imprimatur_native/core/src/read/markdown.rs +4 -1
- data/ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs +3 -1
- data/ext/imprimatur_native/core/src/read/pdf/isolation/recovery.rs +158 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/stream.rs +17 -6
- data/ext/imprimatur_native/core/src/read/pdf/isolation.rs +10 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout/blocks.rs +259 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +2 -243
- data/ext/imprimatur_native/ffi/Cargo.toml +1 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +4 -4
- data/ext/imprimatur_native/ffi/src/pdf.rs +49 -25
- data/ext/imprimatur_native/ffi/src/read.rs +27 -11
- data/ext/imprimatur_native/ffi/src/sheets.rs +3 -1
- data/lib/imprimatur/pdf_reader.rb +3 -2
- data/lib/imprimatur/reader.rb +2 -2
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur.rb +4 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20a7d3843214018bf15d889668ad1d33ba8dfee183b86e041d56b5cc2db88832
|
|
4
|
+
data.tar.gz: 6699fc743a0a04fffbaf67d68551ca680c416cdc56f1fd71984ec2f7cba8e5b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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+
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
75
|
-
|
|
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, ¶graph, body, &levels));
|
|
35
|
+
paragraph.clear();
|
|
36
|
+
}
|
|
37
|
+
paragraph.push(line);
|
|
38
|
+
}
|
|
39
|
+
if !paragraph.is_empty() {
|
|
40
|
+
blocks.push(block(page, ¶graph, 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, ¶graph, body, &levels));
|
|
283
|
-
paragraph.clear();
|
|
284
|
-
}
|
|
285
|
-
paragraph.push(line);
|
|
286
|
-
}
|
|
287
|
-
if !paragraph.is_empty() {
|
|
288
|
-
blocks.push(block(page, ¶graph, 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
|
-
}
|
|
@@ -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,
|
|
327
|
-
native.define_singleton_method("content", function!(read::content_native,
|
|
328
|
-
native.define_singleton_method("images", function!(read::images_native,
|
|
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,
|
|
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) =
|
|
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
|
|
42
|
-
let result =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
.
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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): (
|
|
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:
|
|
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:
|
|
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 =
|
|
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)
|
data/lib/imprimatur/reader.rb
CHANGED
|
@@ -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
|
data/lib/imprimatur/version.rb
CHANGED
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.
|
|
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
|