imprimatur 0.7.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9812ca055242755ab6117d2c5eba9bb1991e02a21572682f9f36c2a0481e2139
4
- data.tar.gz: 8e1d32d440ec79ea732c62dd0a830c0b13f76655df7f9b85d60d539e0d429936
3
+ metadata.gz: 7c343d074a7ed5e2ba382910345401adaaa92269eb915c901db443d97ed6f68a
4
+ data.tar.gz: 00cfe2c403d09f50708469c6228813f8ab9361775b848ac23457389008e643ab
5
5
  SHA512:
6
- metadata.gz: 3572b1a17819ae0fa85346788343f19ef9410d19f48b7257edb95dc00544ae1234991a5d0b50284ed3d10e21f34ca991f965aa7956fecb6a03244805870cea13
7
- data.tar.gz: 9399ac644f99a699a12a2a8bdcefe73977bddcec75571e8053d984d829ddc33817fd40bb6c9c79fd15ce89f56fbe472035c276e3177430b9a35ab20023b66f16
6
+ metadata.gz: e003c94d4f867c4d957a7921a8ef50eb36f0393196535064bfab45cfead076f881a39dbbc7d2e73aa15487e57046a18ea3df1483f8b493f926a8d6e362e6ee7c
7
+ data.tar.gz: ac5657121fd5b657880fee907b3a4508087aff20a15ad057b1d43254796d767e908eec39952ac44a0aa65f814a55d36db8c9b56119effe647d8ea018bcb2e6b4
data/CHANGELOG.md CHANGED
@@ -11,6 +11,18 @@
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
+
14
26
  ## [0.7.0](https://github.com/seuros/imprimatur/compare/v0.6.1...v0.7.0) (2026-10-26)
15
27
 
16
28
 
data/Cargo.lock CHANGED
@@ -513,7 +513,7 @@ dependencies = [
513
513
 
514
514
  [[package]]
515
515
  name = "imprimatur"
516
- version = "0.7.0"
516
+ version = "0.8.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.7.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.7.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.7.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+ and musl),
35
+ Precompiled gems ship for Linux (x86_64 and aarch64, glibc 2.34+),
36
36
  macOS 13+ (arm64, x86_64) and FreeBSD 15.1 (amd64): nothing to compile.
37
37
  Anywhere else, the source gem compiles the extension on install, with the
38
38
  toolchain from [rustup.rs](https://rustup.rs) (1.99+).
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.7.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"]
@@ -16,7 +16,8 @@ use crate::app::{self, App, Out, Status};
16
16
  /// PDF: the text layer, placed glyph by glyph, with headings by font size,
17
17
  /// running headers and footers left out, and pages after the first marked
18
18
  /// `<!-- page N -->` (or `<!-- page N (label L) -->` with its printed page
19
- /// label, from `/PageLabels` or a footer that looks like one). Encrypted
19
+ /// label, from `/PageLabels` or a header or footer line that looks like
20
+ /// one). Encrypted
20
21
  /// PDFs open when their user password is empty. XLSX, XLS and ODS: each
21
22
  /// sheet as a table, values as shown, then its comments. DOCX: headings,
22
23
  /// lists, emphasis, links and tables.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.7.0"
3
+ version = "0.8.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"]
@@ -44,7 +44,10 @@ pub enum Mode {
44
44
  }
45
45
 
46
46
  fn pdf(out: &mut String, pages: &[Page]) {
47
- for page in pages.iter().filter(|page| !page.blocks.is_empty()) {
47
+ // Every page past the first gets its marker, blocks or none: an empty
48
+ // page (left bare by `--drop-repeated`, or genuinely blank) still needs
49
+ // one, so the markers stay a reliable page count and index.
50
+ for page in pages {
48
51
  if page.number > 1 {
49
52
  end_block(out);
50
53
  // Writing to a String cannot fail.
@@ -293,27 +293,41 @@ pub fn pages(
293
293
  reason = "`blocks` keeps its buffer for the next page"
294
294
  )]
295
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
- );
296
+ let label = labels
297
+ .map_or_else(|| printed_label(page), |labels| labels.get(index).cloned().flatten());
300
298
  out.push(Page { number, blocks, images: page.images, label });
301
299
  }
302
300
  out
303
301
  }
304
302
 
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() {
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 {
313
327
  return None;
314
328
  }
315
- let text = page.text(line).trim();
316
- looks_like_page_number(text).then(|| text.to_owned())
329
+ let rest = text[word_end..].trim_start_matches(|ch: char| !ch.is_alphanumeric());
330
+ looks_like_page_number(rest).then(|| rest.to_owned())
317
331
  }
318
332
 
319
333
  fn looks_like_page_number(text: &str) -> bool {
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur_native"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -323,13 +323,13 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
323
323
  barcode::define(ruby, imprimatur)?;
324
324
 
325
325
  let native = imprimatur.define_module("Native")?;
326
- native.define_singleton_method("markdown", function!(read::markdown_native, 4))?;
327
- native.define_singleton_method("content", function!(read::content_native, 5))?;
328
- native.define_singleton_method("images", function!(read::images_native, 4))?;
326
+ native.define_singleton_method("markdown", function!(read::markdown_native, 5))?;
327
+ native.define_singleton_method("content", function!(read::content_native, 6))?;
328
+ native.define_singleton_method("images", function!(read::images_native, 5))?;
329
329
  native.define_singleton_method("pdf_info", function!(pdf::info, 4))?;
330
330
  native.define_singleton_method("pdf_pages", function!(pdf::pages, 5))?;
331
331
  native.define_singleton_method("render_pdf_page", function!(pdf::render, 5))?;
332
- native.define_singleton_method("sheets", function!(sheets::sheets_native, 5))?;
332
+ native.define_singleton_method("sheets", function!(sheets::sheets_native, 6))?;
333
333
  let zip_writer = native.define_class("ZipWriter", ruby.class_object())?;
334
334
  zip_writer.define_singleton_method("new", function!(archive::ZipWriter::new, 1))?;
335
335
  zip_writer.define_method("add_file", method!(archive::ZipWriter::add_file, 3))?;
@@ -7,7 +7,7 @@ use std::path::PathBuf;
7
7
 
8
8
  use crate::read::without_gvl;
9
9
 
10
- type Keywords = (Option<usize>, Option<u64>, Option<f64>);
10
+ type Keywords = (Option<usize>, Option<u64>, Option<f64>, bool);
11
11
 
12
12
  pub fn info(
13
13
  ruby: &Ruby,
@@ -25,15 +25,19 @@ struct Read<T, F> {
25
25
  }
26
26
 
27
27
  /// Called by `Imprimatur.markdown`, which owns the keyword interface.
28
- /// `timeout` is in seconds; `nil` limits keep their defaults.
28
+ /// `timeout` is in seconds; `nil` limits keep their defaults. `drop_repeated`
29
+ /// drops PDF text repeated at the same spot on most pages, anywhere on the
30
+ /// page (watermarks); off by default.
29
31
  pub fn markdown_native(
30
32
  ruby: &Ruby,
31
33
  document: RString,
32
34
  max_input: Option<usize>,
33
35
  max_inflated: Option<u64>,
34
36
  timeout: Option<f64>,
37
+ drop_repeated: bool,
35
38
  ) -> Result<RString, Error> {
36
- let text = without_gvl(ruby, document, (max_input, max_inflated, timeout), markdown)?;
39
+ let text =
40
+ without_gvl(ruby, document, (max_input, max_inflated, timeout, drop_repeated), markdown)?;
37
41
  Ok(ruby.str_new(&text))
38
42
  }
39
43
 
@@ -45,9 +49,11 @@ pub fn content_native(
45
49
  max_input: Option<usize>,
46
50
  max_inflated: Option<u64>,
47
51
  timeout: Option<f64>,
52
+ drop_repeated: bool,
48
53
  styles: bool,
49
54
  ) -> Result<RString, Error> {
50
- let read = without_gvl(ruby, document, (max_input, max_inflated, timeout), content)?;
55
+ let read =
56
+ without_gvl(ruby, document, (max_input, max_inflated, timeout, drop_repeated), content)?;
51
57
  let options = json::Options { styles };
52
58
  Ok(ruby.str_new(&Json::Object(json::content_with(&read, options)).to_string()))
53
59
  }
@@ -60,9 +66,13 @@ pub fn images_native(
60
66
  max_input: Option<usize>,
61
67
  max_inflated: Option<u64>,
62
68
  timeout: Option<f64>,
69
+ drop_repeated: bool,
63
70
  ) -> Result<RArray, Error> {
64
- let found =
65
- without_gvl(ruby, document, (max_input, max_inflated, timeout), |bytes, limits| {
71
+ let found = without_gvl(
72
+ ruby,
73
+ document,
74
+ (max_input, max_inflated, timeout, drop_repeated),
75
+ |bytes, limits| {
66
76
  let mut found = Vec::new();
67
77
  images(bytes, limits, |image| {
68
78
  let description = Json::Object(json::image(&image)).to_string();
@@ -70,7 +80,8 @@ pub fn images_native(
70
80
  Ok(())
71
81
  })?;
72
82
  Ok(found)
73
- })?;
83
+ },
84
+ )?;
74
85
  let pairs = ruby.ary_new_capa(found.len());
75
86
  for (data, description) in found {
76
87
  let pair = [
@@ -86,9 +97,14 @@ pub fn images_native(
86
97
  fn limits(
87
98
  ruby: &Ruby,
88
99
  cancel: &Arc<AtomicBool>,
89
- (max_input, max_inflated, timeout): (Option<usize>, Option<u64>, Option<f64>),
100
+ (max_input, max_inflated, timeout, drop_repeated): (
101
+ Option<usize>,
102
+ Option<u64>,
103
+ Option<f64>,
104
+ bool,
105
+ ),
90
106
  ) -> Result<Limits, Error> {
91
- let mut limits = Limits::default().cancel_on(Arc::clone(cancel));
107
+ let mut limits = Limits::default().cancel_on(Arc::clone(cancel)).drop_repeated(drop_repeated);
92
108
  if let Some(bytes) = max_input {
93
109
  limits = limits.max_input(bytes);
94
110
  }
@@ -107,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 = without_gvl(ruby, document, (max_input, max_inflated, timeout), content)?;
29
+ let read =
30
+ without_gvl(ruby, document, (max_input, max_inflated, timeout, drop_repeated), content)?;
29
31
  let Some(sheets) = read.sheets() else {
30
32
  let kind = if matches!(read, Content::Pdf(_)) { "a PDF" } else { "a Word document" };
31
33
  let error = imprimatur::Error::UnsupportedDocument(format!("{kind} is no spreadsheet"));
@@ -41,8 +41,8 @@ module Imprimatur
41
41
  end
42
42
 
43
43
  # @api private
44
- def self.limits(timeout: 60, max_bytes: nil, max_inflated: nil)
45
- [max_bytes, max_inflated, timeout && Float(timeout)]
44
+ def self.limits(timeout: 60, max_bytes: nil, max_inflated: nil, drop_repeated: false)
45
+ [max_bytes, max_inflated, timeout && Float(timeout), drop_repeated ? true : false]
46
46
  end
47
47
 
48
48
  # A path or an IO is read no further than +max_bytes+ (one byte past it
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/imprimatur.rb CHANGED
@@ -72,6 +72,8 @@ module Imprimatur
72
72
  # @param timeout [Numeric, nil] seconds the read may take (60), +nil+ for none
73
73
  # @param max_bytes [Integer, nil] the largest document accepted (512 MiB)
74
74
  # @param max_inflated [Integer, nil] decompressed bytes allowed in all (1 GiB)
75
+ # @param drop_repeated [Boolean] PDF only: drop text repeated at the same
76
+ # spot on most pages, anywhere on the page (watermarks); off by default
75
77
  # @return [String] Markdown, UTF-8
76
78
  # @raise [NoTextLayer] a PDF of scanned pages: run OCR instead
77
79
  # @raise [PasswordRequired] a PDF that needs a password to open
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imprimatur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih