imprimatur 0.6.1 → 0.8.0
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 +26 -0
- data/Cargo.lock +3 -3
- data/Cargo.toml +1 -1
- data/README.md +1 -1
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +18 -5
- data/ext/imprimatur_native/core/Cargo.toml +1 -1
- data/ext/imprimatur_native/core/src/read/budget.rs +15 -0
- data/ext/imprimatur_native/core/src/read/content.rs +1 -0
- data/ext/imprimatur_native/core/src/read/json.rs +1 -1
- data/ext/imprimatur_native/core/src/read/markdown.rs +12 -2
- data/ext/imprimatur_native/core/src/read/pdf/document/labels.rs +152 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +1 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +204 -54
- data/ext/imprimatur_native/core/src/read/pdf.rs +2 -1
- 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 +1 -1
- data/ext/imprimatur_native/ffi/src/read.rs +25 -9
- data/ext/imprimatur_native/ffi/src/sheets.rs +3 -1
- data/lib/imprimatur/reader.rb +2 -2
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur.rb +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c343d074a7ed5e2ba382910345401adaaa92269eb915c901db443d97ed6f68a
|
|
4
|
+
data.tar.gz: 00cfe2c403d09f50708469c6228813f8ab9361775b848ac23457389008e643ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e003c94d4f867c4d957a7921a8ef50eb36f0393196535064bfab45cfead076f881a39dbbc7d2e73aa15487e57046a18ea3df1483f8b493f926a8d6e362e6ee7c
|
|
7
|
+
data.tar.gz: ac5657121fd5b657880fee907b3a4508087aff20a15ad057b1d43254796d767e908eec39952ac44a0aa65f814a55d36db8c9b56119effe647d8ea018bcb2e6b4
|
data/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,32 @@
|
|
|
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.0](https://github.com/seuros/imprimatur/compare/v0.7.0...v0.8.0) (2026-10-27)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **reader:** accept drop_repeated in the Ruby API ([4e537d6](https://github.com/seuros/imprimatur/commit/4e537d641bafd8d3cdb8b1c20d16f4cb766ad073))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **pdf:** read printed page numbers from headers too, not just footers ([c036434](https://github.com/seuros/imprimatur/commit/c0364341ee8fc36be2c97d25fd06da0aa76a4ad6))
|
|
25
|
+
|
|
26
|
+
## [0.7.0](https://github.com/seuros/imprimatur/compare/v0.6.1...v0.7.0) (2026-10-26)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Features
|
|
30
|
+
|
|
31
|
+
* **pdf:** add --drop-repeated to remove watermarks anywhere on the page ([8ea82c9](https://github.com/seuros/imprimatur/commit/8ea82c98f6b507ff7ffd296f8e0ec8f30aed529f))
|
|
32
|
+
* **pdf:** emit printed page labels from /PageLabels or footer text ([f761555](https://github.com/seuros/imprimatur/commit/f76155578acfc8514465b93b5b019df100d54de7))
|
|
33
|
+
* **pdf:** recognize numbered sections as headings in markdown output ([341f651](https://github.com/seuros/imprimatur/commit/341f651631b74ff93879f9eba71d0874ae261fd1))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Bug Fixes
|
|
37
|
+
|
|
38
|
+
* **pdf:** keep ordinal superscripts attached to their base text ([21fbb51](https://github.com/seuros/imprimatur/commit/21fbb510f90ab7c029d22e4e396b887fa66e16c6))
|
|
39
|
+
|
|
14
40
|
## [0.6.1](https://github.com/seuros/imprimatur/compare/v0.6.0...v0.6.1) (2026-10-25)
|
|
15
41
|
|
|
16
42
|
|
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.0"
|
|
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.0"
|
|
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.0"
|
|
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.0" }
|
|
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+).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "imprimatur-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
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"]
|
|
@@ -15,9 +15,12 @@ use crate::app::{self, App, Out, Status};
|
|
|
15
15
|
///
|
|
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
|
-
/// `<!-- page N
|
|
19
|
-
///
|
|
20
|
-
///
|
|
18
|
+
/// `<!-- page N -->` (or `<!-- page N (label L) -->` with its printed page
|
|
19
|
+
/// label, from `/PageLabels` or a header or footer line that looks like
|
|
20
|
+
/// one). Encrypted
|
|
21
|
+
/// PDFs open when their user password is empty. XLSX, XLS and ODS: each
|
|
22
|
+
/// sheet as a table, values as shown, then its comments. DOCX: headings,
|
|
23
|
+
/// lists, emphasis, links and tables.
|
|
21
24
|
///
|
|
22
25
|
/// With several documents, each is introduced by `<!-- file: PATH -->`. A
|
|
23
26
|
/// document that fails is reported on standard error and the others are
|
|
@@ -29,6 +32,10 @@ use crate::app::{self, App, Out, Status};
|
|
|
29
32
|
/// (number, date, bool, text, error, formula) and comments; a Word
|
|
30
33
|
/// document's blocks (heading, paragraph, list item, table) with the byte
|
|
31
34
|
/// ranges of their bold, italic and linked text.
|
|
35
|
+
///
|
|
36
|
+
/// With `--drop-repeated` on a PDF, text that sits at the same spot on most
|
|
37
|
+
/// pages is dropped wherever it falls, not only in the top and bottom
|
|
38
|
+
/// margins: diagonal or rotated watermark stamps included. Off by default.
|
|
32
39
|
#[derive(Args, Debug)]
|
|
33
40
|
#[usage(
|
|
34
41
|
effect = "read",
|
|
@@ -38,7 +45,8 @@ use crate::app::{self, App, Out, Status};
|
|
|
38
45
|
example("imprimatur markdown audit.pdf", help = "Print a PDF's text"),
|
|
39
46
|
example("imprimatur md -o notes/ *.docx", help = "Write notes/NAME.md for each"),
|
|
40
47
|
example("curl -s $URL | imprimatur md -", help = "Read standard input"),
|
|
41
|
-
example("imprimatur --json md sales.xlsx", help = "Typed cells, as JSON")
|
|
48
|
+
example("imprimatur --json md sales.xlsx", help = "Typed cells, as JSON"),
|
|
49
|
+
example("imprimatur md --drop-repeated stamped.pdf", help = "Drop a repeated watermark")
|
|
42
50
|
)]
|
|
43
51
|
pub struct Markdown {
|
|
44
52
|
/// Documents to read; `-` reads standard input
|
|
@@ -49,6 +57,11 @@ pub struct Markdown {
|
|
|
49
57
|
#[usage(short = 'o', long, value_name = "DIR", value_hint = usage_rs::ValueHint::DirPath)]
|
|
50
58
|
pub output: Option<PathBuf>,
|
|
51
59
|
|
|
60
|
+
/// PDF: drop text repeated at the same spot on most pages, anywhere on
|
|
61
|
+
/// the page (watermarks); off by default
|
|
62
|
+
#[usage(long)]
|
|
63
|
+
pub drop_repeated: bool,
|
|
64
|
+
|
|
52
65
|
#[usage(flatten)]
|
|
53
66
|
pub limits: LimitFlags,
|
|
54
67
|
}
|
|
@@ -58,7 +71,7 @@ impl RunWith<&App> for Markdown {
|
|
|
58
71
|
|
|
59
72
|
fn run_with(self, app: &App) -> ExitCode {
|
|
60
73
|
let limits = match self.limits.checked() {
|
|
61
|
-
Ok(limits) => limits,
|
|
74
|
+
Ok(limits) => limits.drop_repeated(self.drop_repeated),
|
|
62
75
|
Err(status) => return status,
|
|
63
76
|
};
|
|
64
77
|
if let Some(directory) = &self.output
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "imprimatur"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
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"]
|
|
@@ -28,6 +28,7 @@ pub struct Limits {
|
|
|
28
28
|
pub(crate) max_depth: usize,
|
|
29
29
|
pub(crate) timeout: Option<Duration>,
|
|
30
30
|
pub(crate) cancel: Option<Arc<AtomicBool>>,
|
|
31
|
+
pub(crate) drop_repeated: bool,
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
impl Default for Limits {
|
|
@@ -38,6 +39,7 @@ impl Default for Limits {
|
|
|
38
39
|
max_depth: 256,
|
|
39
40
|
timeout: Some(Duration::from_mins(1)),
|
|
40
41
|
cancel: None,
|
|
42
|
+
drop_repeated: false,
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -79,6 +81,15 @@ impl Limits {
|
|
|
79
81
|
self.cancel = Some(flag);
|
|
80
82
|
self
|
|
81
83
|
}
|
|
84
|
+
|
|
85
|
+
/// For a PDF: drop text that repeats at the same spot on most pages
|
|
86
|
+
/// anywhere on the page, not only in the top and bottom margins. Off by
|
|
87
|
+
/// default.
|
|
88
|
+
#[must_use]
|
|
89
|
+
pub const fn drop_repeated(mut self, drop: bool) -> Self {
|
|
90
|
+
self.drop_repeated = drop;
|
|
91
|
+
self
|
|
92
|
+
}
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
/// One read's consumption. Single-threaded: a read runs on one thread.
|
|
@@ -100,6 +111,10 @@ impl<'a> Budget<'a> {
|
|
|
100
111
|
self.limits.max_depth
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
pub const fn drop_repeated(&self) -> bool {
|
|
115
|
+
self.limits.drop_repeated
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
/// Decompressed bytes still allowed.
|
|
104
119
|
pub fn inflatable(&self) -> usize {
|
|
105
120
|
let left = self.limits.max_inflated.saturating_sub(self.inflated.get());
|
|
@@ -55,7 +55,7 @@ fn page(page: &Page) -> Json {
|
|
|
55
55
|
object
|
|
56
56
|
})
|
|
57
57
|
.collect();
|
|
58
|
-
json!({ "page": page.number, "images": page.images, "blocks": blocks })
|
|
58
|
+
json!({ "page": page.number, "label": page.label, "images": page.images, "blocks": blocks })
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/// An image's description, its data left out: `format`, `width`, `height`,
|
|
@@ -44,11 +44,21 @@ 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.
|
|
51
|
-
|
|
54
|
+
match &page.label {
|
|
55
|
+
Some(label) => {
|
|
56
|
+
let _ = writeln!(out, "<!-- page {} (label {label}) -->", page.number);
|
|
57
|
+
}
|
|
58
|
+
None => {
|
|
59
|
+
let _ = writeln!(out, "<!-- page {} -->", page.number);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
52
62
|
}
|
|
53
63
|
write_blocks(out, page.blocks.iter().map(|placed| &placed.block), Mode::Body);
|
|
54
64
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
|
|
3
|
+
use super::{Dict, Document, Object, Result};
|
|
4
|
+
|
|
5
|
+
struct LabelRange {
|
|
6
|
+
start: u32,
|
|
7
|
+
style: Option<u8>,
|
|
8
|
+
prefix: Option<String>,
|
|
9
|
+
first: i64,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ROMAN: &[(i64, &str)] = &[
|
|
13
|
+
(1000, "M"),
|
|
14
|
+
(900, "CM"),
|
|
15
|
+
(500, "D"),
|
|
16
|
+
(400, "CD"),
|
|
17
|
+
(100, "C"),
|
|
18
|
+
(90, "XC"),
|
|
19
|
+
(50, "L"),
|
|
20
|
+
(40, "XL"),
|
|
21
|
+
(10, "X"),
|
|
22
|
+
(9, "IX"),
|
|
23
|
+
(5, "V"),
|
|
24
|
+
(4, "IV"),
|
|
25
|
+
(1, "I"),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
impl Document<'_> {
|
|
29
|
+
pub fn page_labels(&self, count: usize) -> Result<Option<Vec<Option<String>>>> {
|
|
30
|
+
let Some(ranges) = self.label_ranges()? else { return Ok(None) };
|
|
31
|
+
Ok(Some(
|
|
32
|
+
(0..count)
|
|
33
|
+
.map(|index| label_for(&ranges, u32::try_from(index).unwrap_or(u32::MAX)))
|
|
34
|
+
.collect(),
|
|
35
|
+
))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn label_ranges(&self) -> Result<Option<Vec<LabelRange>>> {
|
|
39
|
+
let Some(root) = self.trailer().get(b"Root") else { return Ok(None) };
|
|
40
|
+
let root = self.resolve(root)?;
|
|
41
|
+
let Some(catalog) = root.as_dict() else { return Ok(None) };
|
|
42
|
+
let Some(tree) = catalog.get(b"PageLabels") else { return Ok(None) };
|
|
43
|
+
let mut ranges = Vec::new();
|
|
44
|
+
self.walk_labels(tree, 0, &mut HashSet::new(), &mut ranges)?;
|
|
45
|
+
ranges.sort_by_key(|range| range.start);
|
|
46
|
+
ranges.dedup_by_key(|range| range.start);
|
|
47
|
+
Ok(Some(ranges))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn walk_labels(
|
|
51
|
+
&self,
|
|
52
|
+
node: &Object,
|
|
53
|
+
depth: usize,
|
|
54
|
+
visited: &mut HashSet<u32>,
|
|
55
|
+
out: &mut Vec<LabelRange>,
|
|
56
|
+
) -> Result<()> {
|
|
57
|
+
self.budget.check()?;
|
|
58
|
+
self.budget.nest(depth)?;
|
|
59
|
+
if let Some((number, _)) = node.as_reference()
|
|
60
|
+
&& !visited.insert(number)
|
|
61
|
+
{
|
|
62
|
+
return Ok(());
|
|
63
|
+
}
|
|
64
|
+
let node = self.resolve(node)?;
|
|
65
|
+
let Some(dict) = node.as_dict() else { return Ok(()) };
|
|
66
|
+
if let Some(Object::Array(nums)) = self.entry(dict, b"Nums")?.as_deref() {
|
|
67
|
+
let mut nums = nums.iter();
|
|
68
|
+
while let (Some(start), Some(value)) = (nums.next(), nums.next()) {
|
|
69
|
+
let Some(start) =
|
|
70
|
+
self.resolve(start)?.as_integer().and_then(|start| u32::try_from(start).ok())
|
|
71
|
+
else {
|
|
72
|
+
continue;
|
|
73
|
+
};
|
|
74
|
+
let value = self.resolve(value)?;
|
|
75
|
+
if let Some(label) = value.as_dict() {
|
|
76
|
+
out.push(self.label_range(start, label)?);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if let Some(Object::Array(kids)) = self.entry(dict, b"Kids")?.as_deref() {
|
|
81
|
+
for kid in kids {
|
|
82
|
+
self.walk_labels(kid, depth + 1, visited, out)?;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fn label_range(&self, start: u32, dict: &Dict) -> Result<LabelRange> {
|
|
89
|
+
let style = self
|
|
90
|
+
.entry(dict, b"S")?
|
|
91
|
+
.as_deref()
|
|
92
|
+
.and_then(Object::as_name)
|
|
93
|
+
.and_then(|name| name.first().copied());
|
|
94
|
+
let prefix = self
|
|
95
|
+
.entry(dict, b"P")?
|
|
96
|
+
.as_deref()
|
|
97
|
+
.and_then(Object::as_bytes)
|
|
98
|
+
.map(|bytes| String::from_utf8_lossy(bytes).into_owned());
|
|
99
|
+
let first = self.entry(dict, b"St")?.as_deref().and_then(Object::as_integer).unwrap_or(1);
|
|
100
|
+
Ok(LabelRange { start, style, prefix, first })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fn label_for(ranges: &[LabelRange], index: u32) -> Option<String> {
|
|
105
|
+
let range = ranges.iter().rev().find(|range| range.start <= index)?;
|
|
106
|
+
let offset = i64::from(index - range.start);
|
|
107
|
+
let number = range.first.checked_add(offset)?;
|
|
108
|
+
let mut label = range.prefix.clone().unwrap_or_default();
|
|
109
|
+
if let Some(style) = range.style {
|
|
110
|
+
label.push_str(&numeral(style, number));
|
|
111
|
+
}
|
|
112
|
+
Some(label)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn numeral(style: u8, number: i64) -> String {
|
|
116
|
+
match style {
|
|
117
|
+
b'R' => roman(number, true),
|
|
118
|
+
b'r' => roman(number, false),
|
|
119
|
+
b'A' => alpha(number, true),
|
|
120
|
+
b'a' => alpha(number, false),
|
|
121
|
+
_ => number.to_string(),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn roman(number: i64, upper: bool) -> String {
|
|
126
|
+
if number < 1 {
|
|
127
|
+
return number.to_string();
|
|
128
|
+
}
|
|
129
|
+
let mut remaining = number;
|
|
130
|
+
let mut out = String::new();
|
|
131
|
+
for &(value, symbol) in ROMAN {
|
|
132
|
+
while remaining >= value {
|
|
133
|
+
out.push_str(symbol);
|
|
134
|
+
remaining -= value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if upper { out } else { out.to_ascii_lowercase() }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn alpha(number: i64, upper: bool) -> String {
|
|
141
|
+
if number < 1 {
|
|
142
|
+
return String::new();
|
|
143
|
+
}
|
|
144
|
+
let zero_based = number - 1;
|
|
145
|
+
let letter = u8::try_from(zero_based % 26).unwrap_or(0);
|
|
146
|
+
let repeat = usize::try_from(zero_based / 26 + 1).unwrap_or(1);
|
|
147
|
+
let letter = if upper { b'A' + letter } else { b'a' + letter };
|
|
148
|
+
std::iter::repeat_n(char::from(letter), repeat).collect()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[cfg(test)]
|
|
152
|
+
mod tests;
|
|
@@ -20,6 +20,7 @@ use crate::script::Counts;
|
|
|
20
20
|
const EDGE: f64 = 0.09;
|
|
21
21
|
/// A gap wider than this share of the font size separates words.
|
|
22
22
|
const WORD_GAP: f64 = 0.18;
|
|
23
|
+
const POSITION_BUCKET: f64 = 3.0;
|
|
23
24
|
|
|
24
25
|
/// A line of a page, its text a span of the page's buffer.
|
|
25
26
|
#[derive(Debug, Clone)]
|
|
@@ -36,6 +37,9 @@ pub struct Line {
|
|
|
36
37
|
/// In the top or bottom margin of an upright page: a hash of the text,
|
|
37
38
|
/// digits aside, to spot running headers and footers. 0 elsewhere.
|
|
38
39
|
pub margin: u64,
|
|
40
|
+
/// A hash of the text and its bucketed position and direction, to spot
|
|
41
|
+
/// anything else that repeats at the same spot on most pages.
|
|
42
|
+
pub repeat: u64,
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/// A page's lines and their text (one buffer for all of them), its height
|
|
@@ -82,14 +86,9 @@ pub fn lines(page: &PageText) -> PageLines {
|
|
|
82
86
|
glyphs.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.total_cmp(&b.0)));
|
|
83
87
|
let mut start = 0;
|
|
84
88
|
while start < glyphs.len() {
|
|
85
|
-
let
|
|
86
|
-
let
|
|
87
|
-
|
|
88
|
-
let (_, y, glyph) = glyphs[end];
|
|
89
|
-
if (baseline - y).abs() > 0.45 * first.size.max(glyph.size) {
|
|
90
|
-
break;
|
|
91
|
-
}
|
|
92
|
-
end += 1;
|
|
89
|
+
let mut end = row_end(&glyphs, start);
|
|
90
|
+
if let Some(merged) = attach_superscript(&glyphs, start, end) {
|
|
91
|
+
end = merged;
|
|
93
92
|
}
|
|
94
93
|
let row = &mut glyphs[start..end];
|
|
95
94
|
row.sort_by(|a, b| a.0.total_cmp(&b.0));
|
|
@@ -108,6 +107,54 @@ pub fn lines(page: &PageText) -> PageLines {
|
|
|
108
107
|
out
|
|
109
108
|
}
|
|
110
109
|
|
|
110
|
+
fn row_end(glyphs: &[(f64, f64, &Glyph)], start: usize) -> usize {
|
|
111
|
+
let (_, baseline, first) = glyphs[start];
|
|
112
|
+
let mut end = start + 1;
|
|
113
|
+
while end < glyphs.len() {
|
|
114
|
+
let (_, y, glyph) = glyphs[end];
|
|
115
|
+
if (baseline - y).abs() > 0.45 * first.size.max(glyph.size) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
end += 1;
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const SUPERSCRIPT_GLYPHS: usize = 3;
|
|
124
|
+
const SUPERSCRIPT_SIZE_RATIO: f64 = 0.85;
|
|
125
|
+
|
|
126
|
+
fn attach_superscript(glyphs: &[(f64, f64, &Glyph)], start: usize, end: usize) -> Option<usize> {
|
|
127
|
+
if end - start > SUPERSCRIPT_GLYPHS || end >= glyphs.len() {
|
|
128
|
+
return None;
|
|
129
|
+
}
|
|
130
|
+
let row = &glyphs[start..end];
|
|
131
|
+
let (_, baseline, _) = glyphs[start];
|
|
132
|
+
let (next_x, next_y, next) = glyphs[end];
|
|
133
|
+
let base_size = next.size;
|
|
134
|
+
let candidate_size = row.iter().map(|&(_, _, glyph)| glyph.size).fold(0.0, f64::max);
|
|
135
|
+
let raised = baseline - next_y;
|
|
136
|
+
if raised <= 0.0 || raised > base_size.max(candidate_size) {
|
|
137
|
+
return None;
|
|
138
|
+
}
|
|
139
|
+
if row.iter().any(|&(_, _, glyph)| glyph.size > SUPERSCRIPT_SIZE_RATIO * base_size) {
|
|
140
|
+
return None;
|
|
141
|
+
}
|
|
142
|
+
let row_left = row.iter().map(|&(x, _, _)| x).fold(f64::MAX, f64::min);
|
|
143
|
+
let row_right = row.iter().map(|&(x, _, glyph)| x + glyph.width).fold(f64::MIN, f64::max);
|
|
144
|
+
let next_right = next_x + next.width;
|
|
145
|
+
let gap = if row_left >= next_right {
|
|
146
|
+
row_left - next_right
|
|
147
|
+
} else if next_x >= row_right {
|
|
148
|
+
next_x - row_right
|
|
149
|
+
} else {
|
|
150
|
+
return None;
|
|
151
|
+
};
|
|
152
|
+
if gap > WORD_GAP * base_size.max(1.0) {
|
|
153
|
+
return None;
|
|
154
|
+
}
|
|
155
|
+
Some(row_end(glyphs, end))
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
/// Whether a page reads right to left: more of its letters, those in the
|
|
112
159
|
/// top and bottom margins of upright text aside, are right to left.
|
|
113
160
|
fn right_to_left(page: &PageText) -> bool {
|
|
@@ -179,7 +226,9 @@ fn line<'p>(
|
|
|
179
226
|
let (left, y, _) = row[0];
|
|
180
227
|
let span = span::end(&out.text[..start])..span::end(&out.text);
|
|
181
228
|
let bold = bold * 2 > count;
|
|
182
|
-
|
|
229
|
+
let text = span::slice(&out.text, &span);
|
|
230
|
+
let repeat = repeat_key(text, left, y, turns);
|
|
231
|
+
out.lines.push(Line { span, y, left, right, size, bold, turns, margin: 0, repeat });
|
|
183
232
|
}
|
|
184
233
|
|
|
185
234
|
/// A hash of a line's text with digits as `#` and ASCII letters lowercased
|
|
@@ -192,18 +241,39 @@ fn margin_key(text: &str) -> u64 {
|
|
|
192
241
|
hash.max(1)
|
|
193
242
|
}
|
|
194
243
|
|
|
244
|
+
#[expect(clippy::cast_possible_truncation, reason = "page coordinates fit in i64")]
|
|
245
|
+
fn repeat_key(text: &str, left: f64, y: f64, turns: u8) -> u64 {
|
|
246
|
+
let bucket = |value: f64| (value / POSITION_BUCKET).round() as i64;
|
|
247
|
+
let mut hash = margin_key(text);
|
|
248
|
+
for part in [u64::from(turns), bucket(left).cast_unsigned(), bucket(y).cast_unsigned()] {
|
|
249
|
+
hash = (hash ^ part).wrapping_mul(0x0100_0000_01b3);
|
|
250
|
+
}
|
|
251
|
+
hash.max(1)
|
|
252
|
+
}
|
|
253
|
+
|
|
195
254
|
/// The document's pages, from their lines: blocks of the lines that are
|
|
196
|
-
/// not running headers or footers
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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);
|
|
202
272
|
let mut out = Vec::with_capacity(pages.len());
|
|
203
273
|
let mut paragraph: Vec<&Line> = Vec::new();
|
|
204
274
|
// Each page's blocks, collected here, then moved out at their size.
|
|
205
275
|
let mut blocks = Vec::new();
|
|
206
|
-
for (number, page) in (1..).zip(pages) {
|
|
276
|
+
for (index, (number, page)) in (1..).zip(pages).enumerate() {
|
|
207
277
|
paragraph.clear();
|
|
208
278
|
for line in page.lines.iter().filter(kept) {
|
|
209
279
|
if let Some(last) = paragraph.last()
|
|
@@ -223,11 +293,53 @@ pub fn pages(pages: &[PageLines]) -> Vec<Page> {
|
|
|
223
293
|
reason = "`blocks` keeps its buffer for the next page"
|
|
224
294
|
)]
|
|
225
295
|
let blocks = blocks.drain(..).collect();
|
|
226
|
-
|
|
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 });
|
|
227
299
|
}
|
|
228
300
|
out
|
|
229
301
|
}
|
|
230
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
|
+
|
|
231
343
|
/// Whether `next` carries on the paragraph `last` is in: same direction,
|
|
232
344
|
/// similar size, and no more than a line and a half below it.
|
|
233
345
|
fn continues(last: &Line, next: &Line) -> bool {
|
|
@@ -245,41 +357,79 @@ fn continues(last: &Line, next: &Line) -> bool {
|
|
|
245
357
|
fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
|
|
246
358
|
let first = lines[0];
|
|
247
359
|
let length: usize = lines.iter().map(|line| line.span.len()).sum();
|
|
248
|
-
let
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
None
|
|
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) },
|
|
252
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();
|
|
253
379
|
let mut text = String::with_capacity(length + lines.len());
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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');
|
|
258
392
|
}
|
|
259
|
-
text.push_str(page.text(line));
|
|
260
393
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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;
|
|
279
419
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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..]))
|
|
283
433
|
}
|
|
284
434
|
|
|
285
435
|
/// Where upright lines lie on the page, from its top-left corner: from the
|
|
@@ -296,9 +446,9 @@ fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
|
|
|
296
446
|
Some(Bounds { left, top, right, bottom })
|
|
297
447
|
}
|
|
298
448
|
|
|
299
|
-
///
|
|
300
|
-
///
|
|
301
|
-
fn
|
|
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> {
|
|
302
452
|
if pages.len() < 3 {
|
|
303
453
|
return Vec::new();
|
|
304
454
|
}
|
|
@@ -306,7 +456,7 @@ fn running_lines(pages: &[PageLines]) -> Vec<u64> {
|
|
|
306
456
|
let mut seen: Vec<u64> = Vec::new();
|
|
307
457
|
for page in pages {
|
|
308
458
|
seen.clear();
|
|
309
|
-
seen.extend(page.lines.iter().map(
|
|
459
|
+
seen.extend(page.lines.iter().map(&key).filter(|&key| key != 0));
|
|
310
460
|
seen.sort_unstable();
|
|
311
461
|
seen.dedup();
|
|
312
462
|
for &key in &seen {
|
|
@@ -314,10 +464,10 @@ fn running_lines(pages: &[PageLines]) -> Vec<u64> {
|
|
|
314
464
|
}
|
|
315
465
|
}
|
|
316
466
|
let threshold = (pages.len() * 2 / 5).max(3);
|
|
317
|
-
let mut
|
|
467
|
+
let mut repeated: Vec<u64> =
|
|
318
468
|
counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
|
|
319
|
-
|
|
320
|
-
|
|
469
|
+
repeated.sort_unstable();
|
|
470
|
+
repeated
|
|
321
471
|
}
|
|
322
472
|
|
|
323
473
|
/// Every page's lines that `kept` keeps.
|
|
@@ -57,5 +57,6 @@ pub(super) fn content(data: &[u8], budget: &Budget<'_>) -> Result<Vec<Page>> {
|
|
|
57
57
|
Err(_) => lines.push(layout::PageLines::default()),
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
let labels = document.page_labels(lines.len())?;
|
|
61
|
+
Ok(layout::pages(&lines, labels.as_deref(), budget.drop_repeated()))
|
|
61
62
|
}
|
|
@@ -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))?;
|
|
@@ -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,7 +123,7 @@ 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
|
|
@@ -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"));
|
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
|
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.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -106,6 +106,7 @@ files:
|
|
|
106
106
|
- ext/imprimatur_native/core/src/read/pdf/content/state.rs
|
|
107
107
|
- ext/imprimatur_native/core/src/read/pdf/crypt.rs
|
|
108
108
|
- ext/imprimatur_native/core/src/read/pdf/document.rs
|
|
109
|
+
- ext/imprimatur_native/core/src/read/pdf/document/labels.rs
|
|
109
110
|
- ext/imprimatur_native/core/src/read/pdf/document/pages.rs
|
|
110
111
|
- ext/imprimatur_native/core/src/read/pdf/filter.rs
|
|
111
112
|
- ext/imprimatur_native/core/src/read/pdf/font.rs
|