imprimatur 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/Cargo.lock +3 -3
- data/Cargo.toml +1 -1
- data/README.md +22 -0
- data/ext/imprimatur_native/cli/Cargo.toml +1 -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/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 -257
- data/ext/imprimatur_native/ffi/Cargo.toml +1 -1
- data/ext/imprimatur_native/ffi/src/pdf.rs +48 -24
- data/ext/imprimatur_native/ffi/src/read.rs +2 -2
- data/lib/imprimatur/pdf_reader.rb +3 -2
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur.rb +2 -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,14 @@
|
|
|
11
11
|
- Allow a bounded exit grace when macOS cannot inspect an exiting worker;
|
|
12
12
|
persistent monitor failures still terminate and reap a live worker.
|
|
13
13
|
|
|
14
|
+
## [0.8.1](https://github.com/seuros/imprimatur/compare/v0.8.0...v0.8.1) (2026-10-29)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **pdf:** recover timeout neighbours within the overall deadline ([#16](https://github.com/seuros/imprimatur/issues/16)) ([e86fbbf](https://github.com/seuros/imprimatur/commit/e86fbbfef85f2c645090c43359c0744962e951a1))
|
|
20
|
+
* **pdf:** step timeout recovery so a Ruby wakeup cannot restart it ([75c3894](https://github.com/seuros/imprimatur/commit/75c3894c8a791c5ef4af562dc99de4e745770ad3))
|
|
21
|
+
|
|
14
22
|
## [0.8.0](https://github.com/seuros/imprimatur/compare/v0.7.0...v0.8.0) (2026-10-27)
|
|
15
23
|
|
|
16
24
|
|
data/Cargo.lock
CHANGED
|
@@ -513,7 +513,7 @@ dependencies = [
|
|
|
513
513
|
|
|
514
514
|
[[package]]
|
|
515
515
|
name = "imprimatur"
|
|
516
|
-
version = "0.8.
|
|
516
|
+
version = "0.8.1"
|
|
517
517
|
dependencies = [
|
|
518
518
|
"aes",
|
|
519
519
|
"crc32fast",
|
|
@@ -536,7 +536,7 @@ dependencies = [
|
|
|
536
536
|
|
|
537
537
|
[[package]]
|
|
538
538
|
name = "imprimatur-cli"
|
|
539
|
-
version = "0.8.
|
|
539
|
+
version = "0.8.1"
|
|
540
540
|
dependencies = [
|
|
541
541
|
"imprimatur",
|
|
542
542
|
"regex",
|
|
@@ -548,7 +548,7 @@ dependencies = [
|
|
|
548
548
|
|
|
549
549
|
[[package]]
|
|
550
550
|
name = "imprimatur_native"
|
|
551
|
-
version = "0.8.
|
|
551
|
+
version = "0.8.1"
|
|
552
552
|
dependencies = [
|
|
553
553
|
"imprimatur",
|
|
554
554
|
"magnus",
|
data/Cargo.toml
CHANGED
|
@@ -15,7 +15,7 @@ crc32fast = "1.5.2"
|
|
|
15
15
|
datamatrix = "0.3.3"
|
|
16
16
|
font-test-data = "0.10.0"
|
|
17
17
|
hayro = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
|
18
|
-
imprimatur = { path = "ext/imprimatur_native/core", version = "0.8.
|
|
18
|
+
imprimatur = { path = "ext/imprimatur_native/core", version = "0.8.1" }
|
|
19
19
|
libproc = "0.14.11"
|
|
20
20
|
magnus = { version = "0.9.2", default-features = false }
|
|
21
21
|
md-5 = "0.11.0"
|
data/README.md
CHANGED
|
@@ -384,6 +384,28 @@ after metadata was read preserves completed pages and explicitly marks the
|
|
|
384
384
|
unreached pages; an expired overall deadline marks them `skipped`. Cancellation
|
|
385
385
|
still raises rather than returning a partial successful call.
|
|
386
386
|
|
|
387
|
+
For timeout-neighbour recovery, opt in to a shorter worker-attempt budget:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
Imprimatur.pdf_pages(bytes, timeout: 300, batch_timeout: 60)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Here `timeout:` is the **overall** deadline, including recovery; `batch_timeout:`
|
|
394
|
+
caps the initial worker and each singleton retry. Completed pages survive a
|
|
395
|
+
batch timeout. Unread later neighbours are tried once before the first missing
|
|
396
|
+
page (which may have stalled the worker). Recovery is best effort, never an
|
|
397
|
+
extension of the overall deadline. Output-limit skips are not retried, and all
|
|
398
|
+
attempts share `max_output_bytes:`. Input, depth, process-memory and per-page
|
|
399
|
+
limits still apply; `max_inflated:` remains per worker/recovery attempt, not a
|
|
400
|
+
cumulative decompression allowance.
|
|
401
|
+
|
|
402
|
+
With `batch_timeout:` enabled, an explicit finite `timeout:` replaces the
|
|
403
|
+
ordinary 60-second process ceiling for this call; `timeout: nil` retains that
|
|
404
|
+
60-second ceiling. Both budgets must be positive; zero overall time still fails
|
|
405
|
+
immediately. Omitting `batch_timeout:` preserves the existing behaviour. Rust
|
|
406
|
+
callers can use `pages_isolated_recovering` with an explicit attempt duration
|
|
407
|
+
and overall `Limits`/`ProcessLimits`.
|
|
408
|
+
|
|
387
409
|
This is resource containment, **not a filesystem/network security sandbox**.
|
|
388
410
|
The raw crate `info`, `pages` and `render_page` APIs remain in-process;
|
|
389
411
|
`render_page` cannot interrupt Hayro.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "imprimatur-cli"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.1"
|
|
4
4
|
description = "Read PDF, XLSX, XLS, ODS and DOCX documents as Markdown or JSON, search them, extract their images."
|
|
5
5
|
keywords = ["pdf", "xlsx", "docx", "markdown", "cli"]
|
|
6
6
|
categories = ["command-line-utilities", "text-processing"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "imprimatur"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.1"
|
|
4
4
|
description = "XLSX, PDF and DOCX authoring, and PDF, XLSX, XLS, ODS and DOCX reading, with its own XML, zip and PDF code."
|
|
5
5
|
keywords = ["xlsx", "excel", "pdf", "docx", "spreadsheet"]
|
|
6
6
|
categories = ["encoding", "text-processing"]
|
|
@@ -45,6 +45,12 @@ impl Default for Limits {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
impl Limits {
|
|
48
|
+
/// The configured overall read timeout, if enabled.
|
|
49
|
+
#[must_use]
|
|
50
|
+
pub const fn timeout_duration(&self) -> Option<Duration> {
|
|
51
|
+
self.timeout
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
/// The largest document accepted, in bytes; 512 MiB by default.
|
|
49
55
|
#[must_use]
|
|
50
56
|
pub const fn max_input(mut self, bytes: usize) -> Self {
|
|
@@ -60,7 +60,7 @@ impl Request {
|
|
|
60
60
|
if process.memory_bytes == 0 || process.memory_bytes >= i64::MAX as u64 {
|
|
61
61
|
return Err(Error::InvalidPdfOption("memory limit must be positive and finite"));
|
|
62
62
|
}
|
|
63
|
-
let wall =
|
|
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,258 +250,3 @@ fn repeat_key(text: &str, left: f64, y: f64, turns: u8) -> u64 {
|
|
|
250
250
|
}
|
|
251
251
|
hash.max(1)
|
|
252
252
|
}
|
|
253
|
-
|
|
254
|
-
/// The document's pages, from their lines: blocks of the lines that are
|
|
255
|
-
/// not running headers or footers, and, with `drop_repeated_anywhere`, not
|
|
256
|
-
/// text repeated at the same spot on most pages anywhere on the page.
|
|
257
|
-
pub fn pages(
|
|
258
|
-
pages: &[PageLines],
|
|
259
|
-
labels: Option<&[Option<String>]>,
|
|
260
|
-
drop_repeated_anywhere: bool,
|
|
261
|
-
) -> Vec<Page> {
|
|
262
|
-
let boilerplate = repeated_keys(pages, |line| line.margin);
|
|
263
|
-
let margin_kept =
|
|
264
|
-
|line: &&Line| line.margin == 0 || boilerplate.binary_search(&line.margin).is_err();
|
|
265
|
-
let repeated =
|
|
266
|
-
if drop_repeated_anywhere { repeated_keys(pages, |line| line.repeat) } else { Vec::new() };
|
|
267
|
-
let kept = |line: &&Line| {
|
|
268
|
-
margin_kept(line) && (repeated.is_empty() || repeated.binary_search(&line.repeat).is_err())
|
|
269
|
-
};
|
|
270
|
-
let body = body_size(pages, &margin_kept);
|
|
271
|
-
let levels = heading_sizes(pages, &margin_kept, body);
|
|
272
|
-
let mut out = Vec::with_capacity(pages.len());
|
|
273
|
-
let mut paragraph: Vec<&Line> = Vec::new();
|
|
274
|
-
// Each page's blocks, collected here, then moved out at their size.
|
|
275
|
-
let mut blocks = Vec::new();
|
|
276
|
-
for (index, (number, page)) in (1..).zip(pages).enumerate() {
|
|
277
|
-
paragraph.clear();
|
|
278
|
-
for line in page.lines.iter().filter(kept) {
|
|
279
|
-
if let Some(last) = paragraph.last()
|
|
280
|
-
&& !continues(last, line)
|
|
281
|
-
{
|
|
282
|
-
blocks.push(block(page, ¶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
|
|
297
|
-
.map_or_else(|| printed_label(page), |labels| labels.get(index).cloned().flatten());
|
|
298
|
-
out.push(Page { number, blocks, images: page.images, label });
|
|
299
|
-
}
|
|
300
|
-
out
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/// The page's own printed label, from a single header or footer line that
|
|
304
|
-
/// looks like one: a bare page number or roman numeral ("`-i-`", "`7`"), or
|
|
305
|
-
/// the same after a short label word ("`Page 70`" -> "`70`"). Unlike the
|
|
306
|
-
/// boilerplate this document's other margin lines are checked against, a
|
|
307
|
-
/// running "`Page 70`", "`Page 71`", ... header is not noise here: each
|
|
308
|
-
/// instance *is* that page's label, not a repeated line to drop.
|
|
309
|
-
fn printed_label(page: &PageLines) -> Option<String> {
|
|
310
|
-
let mut candidates =
|
|
311
|
-
page.lines.iter().filter(|line: &&Line| line.margin != 0).filter_map(|line| {
|
|
312
|
-
let text = page.text(line).trim();
|
|
313
|
-
page_number_label(text)
|
|
314
|
-
});
|
|
315
|
-
let label = candidates.next()?;
|
|
316
|
-
candidates.next().is_none().then_some(label)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/// `text` as a page label, if it looks like one, stripped of a leading
|
|
320
|
-
/// label word such as "Page" when there is one.
|
|
321
|
-
fn page_number_label(text: &str) -> Option<String> {
|
|
322
|
-
if looks_like_page_number(text) {
|
|
323
|
-
return Some(text.to_owned());
|
|
324
|
-
}
|
|
325
|
-
let word_end = text.find(|ch: char| !ch.is_ascii_alphabetic())?;
|
|
326
|
-
if word_end == 0 {
|
|
327
|
-
return None;
|
|
328
|
-
}
|
|
329
|
-
let rest = text[word_end..].trim_start_matches(|ch: char| !ch.is_alphanumeric());
|
|
330
|
-
looks_like_page_number(rest).then(|| rest.to_owned())
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
fn looks_like_page_number(text: &str) -> bool {
|
|
334
|
-
let core = text.trim_matches(|ch: char| !ch.is_alphanumeric());
|
|
335
|
-
!core.is_empty()
|
|
336
|
-
&& core.len() <= 8
|
|
337
|
-
&& (core.chars().all(|ch| ch.is_ascii_digit())
|
|
338
|
-
|| core.chars().all(|ch| {
|
|
339
|
-
matches!(ch.to_ascii_lowercase(), 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')
|
|
340
|
-
}))
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/// Whether `next` carries on the paragraph `last` is in: same direction,
|
|
344
|
-
/// similar size, and no more than a line and a half below it.
|
|
345
|
-
fn continues(last: &Line, next: &Line) -> bool {
|
|
346
|
-
let size = last.size.max(next.size).max(1.0);
|
|
347
|
-
last.turns == next.turns
|
|
348
|
-
&& (last.size - next.size).abs() <= 0.15 * size
|
|
349
|
-
&& last.y - next.y > 0.0
|
|
350
|
-
&& last.y - next.y <= 1.55 * size
|
|
351
|
-
&& last.bold == next.bold
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/// A paragraph of lines as a block: a heading when its few short lines
|
|
355
|
-
/// are set in a heading size, otherwise a paragraph with its lines on lines
|
|
356
|
-
/// of their own, words hyphenated across them joined again.
|
|
357
|
-
fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
|
|
358
|
-
let first = lines[0];
|
|
359
|
-
let length: usize = lines.iter().map(|line| line.span.len()).sum();
|
|
360
|
-
let short = lines.len() <= 3 && length <= 200;
|
|
361
|
-
let text = paragraph_text(page, lines);
|
|
362
|
-
let level =
|
|
363
|
-
if short { levels.iter().position(|&size| first.size >= size - 0.25) } else { None };
|
|
364
|
-
let sized = level
|
|
365
|
-
.filter(|_| first.size >= body * 1.15)
|
|
366
|
-
.map(|level| u8::try_from(level.min(2) + 1).unwrap_or(3));
|
|
367
|
-
let numbered = (short && first.bold && first.size >= body - 0.25)
|
|
368
|
-
.then(|| numbered_heading_level(&text))
|
|
369
|
-
.flatten();
|
|
370
|
-
let block = match sized.or(numbered) {
|
|
371
|
-
Some(level) => Block::Heading { level, text: Text::plain(text) },
|
|
372
|
-
None => Block::Paragraph { text: Text::plain(text) },
|
|
373
|
-
};
|
|
374
|
-
Placed { block, bounds: bounds(page, lines) }
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
fn paragraph_text(page: &PageLines, lines: &[&Line]) -> String {
|
|
378
|
-
let length: usize = lines.iter().map(|line| line.span.len()).sum();
|
|
379
|
-
let mut text = String::with_capacity(length + lines.len());
|
|
380
|
-
for (index, line) in lines.iter().enumerate() {
|
|
381
|
-
let line = page.text(line);
|
|
382
|
-
if index > 0 {
|
|
383
|
-
let joined = text.ends_with('-') && {
|
|
384
|
-
let mut before = text.chars().rev().skip(1);
|
|
385
|
-
before.next().is_some_and(char::is_lowercase)
|
|
386
|
-
&& line.chars().next().is_some_and(char::is_lowercase)
|
|
387
|
-
};
|
|
388
|
-
if joined {
|
|
389
|
-
text.pop();
|
|
390
|
-
} else {
|
|
391
|
-
text.push('\n');
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
text.push_str(line);
|
|
395
|
-
}
|
|
396
|
-
text
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
fn numbered_heading_level(text: &str) -> Option<u8> {
|
|
400
|
-
let (depth, rest) = numbered_prefix(text)?;
|
|
401
|
-
if depth < 2 || !(rest.is_empty() || rest.starts_with(' ')) {
|
|
402
|
-
return None;
|
|
403
|
-
}
|
|
404
|
-
let title = rest.trim_start();
|
|
405
|
-
if title.contains("....") || title.starts_with(|ch: char| !ch.is_alphanumeric()) {
|
|
406
|
-
return None;
|
|
407
|
-
}
|
|
408
|
-
Some(u8::try_from(depth).unwrap_or(6).min(6))
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
fn numbered_prefix(text: &str) -> Option<(usize, &str)> {
|
|
412
|
-
let bytes = text.as_bytes();
|
|
413
|
-
let mut groups = 0_usize;
|
|
414
|
-
let mut pos = 0_usize;
|
|
415
|
-
loop {
|
|
416
|
-
let start = pos;
|
|
417
|
-
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
|
|
418
|
-
pos += 1;
|
|
419
|
-
}
|
|
420
|
-
if pos == start {
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
423
|
-
groups += 1;
|
|
424
|
-
if groups >= 4
|
|
425
|
-
|| bytes.get(pos) != Some(&b'.')
|
|
426
|
-
|| !bytes.get(pos + 1).is_some_and(u8::is_ascii_digit)
|
|
427
|
-
{
|
|
428
|
-
break;
|
|
429
|
-
}
|
|
430
|
-
pos += 1;
|
|
431
|
-
}
|
|
432
|
-
(groups > 0).then(|| (groups, &text[pos..]))
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/// Where upright lines lie on the page, from its top-left corner: from the
|
|
436
|
-
/// top of the first line's capitals to below the last line's descenders.
|
|
437
|
-
fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
|
|
438
|
-
if lines.iter().any(|line| line.turns != 0) {
|
|
439
|
-
return None;
|
|
440
|
-
}
|
|
441
|
-
let (first, last) = (lines.first()?, lines.last()?);
|
|
442
|
-
let left = lines.iter().map(|line| line.left).fold(f64::MAX, f64::min);
|
|
443
|
-
let right = lines.iter().map(|line| line.right).fold(f64::MIN, f64::max);
|
|
444
|
-
let top = page.height - first.size.mul_add(0.8, first.y);
|
|
445
|
-
let bottom = page.height - last.size.mul_add(-0.25, last.y);
|
|
446
|
-
Some(Bounds { left, top, right, bottom })
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/// The keys `key` returns for lines that repeat on enough pages to be
|
|
450
|
-
/// boilerplate rather than coincidence, sorted.
|
|
451
|
-
fn repeated_keys(pages: &[PageLines], key: impl Fn(&Line) -> u64) -> Vec<u64> {
|
|
452
|
-
if pages.len() < 3 {
|
|
453
|
-
return Vec::new();
|
|
454
|
-
}
|
|
455
|
-
let mut counts: HashMap<u64, usize> = HashMap::new();
|
|
456
|
-
let mut seen: Vec<u64> = Vec::new();
|
|
457
|
-
for page in pages {
|
|
458
|
-
seen.clear();
|
|
459
|
-
seen.extend(page.lines.iter().map(&key).filter(|&key| key != 0));
|
|
460
|
-
seen.sort_unstable();
|
|
461
|
-
seen.dedup();
|
|
462
|
-
for &key in &seen {
|
|
463
|
-
*counts.entry(key).or_default() += 1;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
let threshold = (pages.len() * 2 / 5).max(3);
|
|
467
|
-
let mut repeated: Vec<u64> =
|
|
468
|
-
counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
|
|
469
|
-
repeated.sort_unstable();
|
|
470
|
-
repeated
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/// Every page's lines that `kept` keeps.
|
|
474
|
-
fn kept_lines<'p>(
|
|
475
|
-
pages: &'p [PageLines],
|
|
476
|
-
kept: &'p impl Fn(&&Line) -> bool,
|
|
477
|
-
) -> impl Iterator<Item = &'p Line> {
|
|
478
|
-
pages.iter().flat_map(|page| &page.lines).filter(kept)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/// The most common font size, by characters.
|
|
482
|
-
fn body_size(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool) -> f64 {
|
|
483
|
-
let mut counts: HashMap<i64, usize> = HashMap::new();
|
|
484
|
-
for line in kept_lines(pages, kept) {
|
|
485
|
-
#[expect(clippy::cast_possible_truncation, reason = "font sizes are small")]
|
|
486
|
-
let key = (line.size * 2.0).round() as i64;
|
|
487
|
-
*counts.entry(key).or_default() += line.span.len();
|
|
488
|
-
}
|
|
489
|
-
#[expect(clippy::cast_precision_loss, reason = "a small integer")]
|
|
490
|
-
counts
|
|
491
|
-
.into_iter()
|
|
492
|
-
.max_by_key(|&(size, count)| (count, -size))
|
|
493
|
-
.map_or(0.0, |(size, _)| size as f64 / 2.0)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/// The sizes of headings, largest first: distinct sizes clearly above the
|
|
497
|
-
/// body's, three at most.
|
|
498
|
-
fn heading_sizes(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool, body: f64) -> Vec<f64> {
|
|
499
|
-
let mut sizes: Vec<f64> = kept_lines(pages, kept)
|
|
500
|
-
.filter(|line| line.size >= body * 1.15 && line.span.len() <= 200)
|
|
501
|
-
.map(|line| (line.size * 2.0).round() / 2.0)
|
|
502
|
-
.collect();
|
|
503
|
-
sizes.sort_by(|a, b| b.total_cmp(a));
|
|
504
|
-
sizes.dedup();
|
|
505
|
-
sizes.truncate(3);
|
|
506
|
-
sizes
|
|
507
|
-
}
|
|
@@ -4,8 +4,10 @@ use imprimatur::read::{Limits, pdf};
|
|
|
4
4
|
use magnus::{Error, RString, Ruby};
|
|
5
5
|
use serde_json::json;
|
|
6
6
|
use std::path::PathBuf;
|
|
7
|
+
use std::time::Duration;
|
|
7
8
|
|
|
8
9
|
use crate::read::without_gvl;
|
|
10
|
+
use crate::{argument_error, domain_error};
|
|
9
11
|
|
|
10
12
|
type Keywords = (Option<usize>, Option<u64>, Option<f64>, bool);
|
|
11
13
|
|
|
@@ -29,39 +31,61 @@ pub fn pages(
|
|
|
29
31
|
ruby: &Ruby,
|
|
30
32
|
document: RString,
|
|
31
33
|
keywords: Keywords,
|
|
32
|
-
options: (u32, u32, usize, usize, usize),
|
|
34
|
+
options: (u32, u32, usize, usize, usize, Option<f64>),
|
|
33
35
|
worker: String,
|
|
34
36
|
memory_bytes: u64,
|
|
35
37
|
) -> Result<RString, Error> {
|
|
36
|
-
let (first_page, last_page, max_pages, max_page_bytes, max_output_bytes) =
|
|
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(
|
|
@@ -127,7 +127,7 @@ pub fn without_gvl<T, F>(
|
|
|
127
127
|
job: F,
|
|
128
128
|
) -> Result<T, Error>
|
|
129
129
|
where
|
|
130
|
-
F:
|
|
130
|
+
F: FnMut(&[u8], &Limits) -> imprimatur::Result<T>,
|
|
131
131
|
{
|
|
132
132
|
let document = crate::bytes(document);
|
|
133
133
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
@@ -174,7 +174,7 @@ where
|
|
|
174
174
|
#[allow(unsafe_code)]
|
|
175
175
|
unsafe extern "C" fn run<T, F>(data: *mut c_void) -> *mut c_void
|
|
176
176
|
where
|
|
177
|
-
F:
|
|
177
|
+
F: FnMut(&[u8], &Limits) -> imprimatur::Result<T>,
|
|
178
178
|
{
|
|
179
179
|
// SAFETY: `data` is the `Read<T>` without_gvl passed, alive and not
|
|
180
180
|
// otherwise borrowed until this returns.
|
|
@@ -11,8 +11,9 @@ module Imprimatur
|
|
|
11
11
|
|
|
12
12
|
def self.pages(document, first_page: 1, last_page: 0xFFFFFFFF, max_pages: 500,
|
|
13
13
|
max_page_bytes: 256 * 1024, max_output_bytes: 8 * 1024 * 1024,
|
|
14
|
-
memory_bytes: 1024 * 1024 * 1024, max_bytes: nil, **limits)
|
|
15
|
-
options = [first_page, last_page, max_pages, max_page_bytes, max_output_bytes
|
|
14
|
+
memory_bytes: 1024 * 1024 * 1024, max_bytes: nil, batch_timeout: nil, **limits)
|
|
15
|
+
options = [first_page, last_page, max_pages, max_page_bytes, max_output_bytes,
|
|
16
|
+
batch_timeout && Float(batch_timeout)]
|
|
16
17
|
JSON.parse(Native.pdf_pages(Reader.read(document, max_bytes), Reader.limits(max_bytes:, **limits),
|
|
17
18
|
options, RENDER_WORKER, memory_bytes),
|
|
18
19
|
symbolize_names: true)
|
data/lib/imprimatur/version.rb
CHANGED
data/lib/imprimatur.rb
CHANGED
|
@@ -115,6 +115,8 @@ module Imprimatur
|
|
|
115
115
|
# image count (nil when unknown), truncation flag and failure reason.
|
|
116
116
|
# Takes +first_page:+, +last_page:+, +max_pages:+ (500),
|
|
117
117
|
# +max_page_bytes:+ (256 KiB), +max_output_bytes:+ (8 MiB), and read limits.
|
|
118
|
+
# Optional +batch_timeout:+ caps each worker attempt; +timeout:+ then bounds
|
|
119
|
+
# the whole call, including one-pass recovery of timeout-unread neighbours.
|
|
118
120
|
# @return [Hash] +page_count:+, +encrypted:+ and +pages:+
|
|
119
121
|
def pdf_pages(document, ...) = PdfReader.pages(document, ...)
|
|
120
122
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: imprimatur
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
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
|