imprimatur 0.6.1 → 0.7.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: b79ecca5f76986eb4049090056dcee55fe7b8d2aafa1115de48ea2f45b0e12ef
4
- data.tar.gz: 925d5c93f99da882db19b562c749ba41c1ae17a5fd01d17b66bd9be93a9dc15a
3
+ metadata.gz: 9812ca055242755ab6117d2c5eba9bb1991e02a21572682f9f36c2a0481e2139
4
+ data.tar.gz: 8e1d32d440ec79ea732c62dd0a830c0b13f76655df7f9b85d60d539e0d429936
5
5
  SHA512:
6
- metadata.gz: e28f3c9640d20df215ed94a94195fcea8a28336f1ddc1d5a923355e09fdc68142517b92147c3b76dc089b68294c8aa4b4edde1a832fc613fb44612d92e4773ba
7
- data.tar.gz: c7f0d6f08c5f9349574e03c3ec025c82f737afdeb5e4e8c6994bf7afe0c5a7fa7091e418495691346f43b987a7abd7451d6eeca207b87dd911a523508039ac3a
6
+ metadata.gz: 3572b1a17819ae0fa85346788343f19ef9410d19f48b7257edb95dc00544ae1234991a5d0b50284ed3d10e21f34ca991f965aa7956fecb6a03244805870cea13
7
+ data.tar.gz: 9399ac644f99a699a12a2a8bdcefe73977bddcec75571e8053d984d829ddc33817fd40bb6c9c79fd15ce89f56fbe472035c276e3177430b9a35ab20023b66f16
data/CHANGELOG.md CHANGED
@@ -11,6 +11,20 @@
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.7.0](https://github.com/seuros/imprimatur/compare/v0.6.1...v0.7.0) (2026-10-26)
15
+
16
+
17
+ ### Features
18
+
19
+ * **pdf:** add --drop-repeated to remove watermarks anywhere on the page ([8ea82c9](https://github.com/seuros/imprimatur/commit/8ea82c98f6b507ff7ffd296f8e0ec8f30aed529f))
20
+ * **pdf:** emit printed page labels from /PageLabels or footer text ([f761555](https://github.com/seuros/imprimatur/commit/f76155578acfc8514465b93b5b019df100d54de7))
21
+ * **pdf:** recognize numbered sections as headings in markdown output ([341f651](https://github.com/seuros/imprimatur/commit/341f651631b74ff93879f9eba71d0874ae261fd1))
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * **pdf:** keep ordinal superscripts attached to their base text ([21fbb51](https://github.com/seuros/imprimatur/commit/21fbb510f90ab7c029d22e4e396b887fa66e16c6))
27
+
14
28
  ## [0.6.1](https://github.com/seuros/imprimatur/compare/v0.6.0...v0.6.1) (2026-10-25)
15
29
 
16
30
 
data/Cargo.lock CHANGED
@@ -513,7 +513,7 @@ dependencies = [
513
513
 
514
514
  [[package]]
515
515
  name = "imprimatur"
516
- version = "0.6.1"
516
+ version = "0.7.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.6.1"
539
+ version = "0.7.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.6.1"
551
+ version = "0.7.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.6.1" }
18
+ imprimatur = { path = "ext/imprimatur_native/core", version = "0.7.0" }
19
19
  libproc = "0.14.11"
20
20
  magnus = { version = "0.9.2", default-features = false }
21
21
  md-5 = "0.11.0"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.6.1"
3
+ version = "0.7.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,11 @@ 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 -->`. Encrypted PDFs open when their user password is
19
- /// empty. XLSX, XLS and ODS: each sheet as a table, values as shown, then
20
- /// its comments. DOCX: headings, lists, emphasis, links and tables.
18
+ /// `<!-- page N -->` (or `<!-- page N (label L) -->` with its printed page
19
+ /// label, from `/PageLabels` or a footer that looks like one). Encrypted
20
+ /// PDFs open when their user password is empty. XLSX, XLS and ODS: each
21
+ /// sheet as a table, values as shown, then its comments. DOCX: headings,
22
+ /// lists, emphasis, links and tables.
21
23
  ///
22
24
  /// With several documents, each is introduced by `<!-- file: PATH -->`. A
23
25
  /// document that fails is reported on standard error and the others are
@@ -29,6 +31,10 @@ use crate::app::{self, App, Out, Status};
29
31
  /// (number, date, bool, text, error, formula) and comments; a Word
30
32
  /// document's blocks (heading, paragraph, list item, table) with the byte
31
33
  /// ranges of their bold, italic and linked text.
34
+ ///
35
+ /// With `--drop-repeated` on a PDF, text that sits at the same spot on most
36
+ /// pages is dropped wherever it falls, not only in the top and bottom
37
+ /// margins: diagonal or rotated watermark stamps included. Off by default.
32
38
  #[derive(Args, Debug)]
33
39
  #[usage(
34
40
  effect = "read",
@@ -38,7 +44,8 @@ use crate::app::{self, App, Out, Status};
38
44
  example("imprimatur markdown audit.pdf", help = "Print a PDF's text"),
39
45
  example("imprimatur md -o notes/ *.docx", help = "Write notes/NAME.md for each"),
40
46
  example("curl -s $URL | imprimatur md -", help = "Read standard input"),
41
- example("imprimatur --json md sales.xlsx", help = "Typed cells, as JSON")
47
+ example("imprimatur --json md sales.xlsx", help = "Typed cells, as JSON"),
48
+ example("imprimatur md --drop-repeated stamped.pdf", help = "Drop a repeated watermark")
42
49
  )]
43
50
  pub struct Markdown {
44
51
  /// Documents to read; `-` reads standard input
@@ -49,6 +56,11 @@ pub struct Markdown {
49
56
  #[usage(short = 'o', long, value_name = "DIR", value_hint = usage_rs::ValueHint::DirPath)]
50
57
  pub output: Option<PathBuf>,
51
58
 
59
+ /// PDF: drop text repeated at the same spot on most pages, anywhere on
60
+ /// the page (watermarks); off by default
61
+ #[usage(long)]
62
+ pub drop_repeated: bool,
63
+
52
64
  #[usage(flatten)]
53
65
  pub limits: LimitFlags,
54
66
  }
@@ -58,7 +70,7 @@ impl RunWith<&App> for Markdown {
58
70
 
59
71
  fn run_with(self, app: &App) -> ExitCode {
60
72
  let limits = match self.limits.checked() {
61
- Ok(limits) => limits,
73
+ Ok(limits) => limits.drop_repeated(self.drop_repeated),
62
74
  Err(status) => return status,
63
75
  };
64
76
  if let Some(directory) = &self.output
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.6.1"
3
+ version = "0.7.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());
@@ -72,6 +72,7 @@ pub struct Page {
72
72
  pub number: u32,
73
73
  pub blocks: Vec<Placed>,
74
74
  pub images: u32,
75
+ pub label: Option<String>,
75
76
  }
76
77
 
77
78
  /// A block and where it lies on its page.
@@ -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`,
@@ -48,7 +48,14 @@ fn pdf(out: &mut String, pages: &[Page]) {
48
48
  if page.number > 1 {
49
49
  end_block(out);
50
50
  // Writing to a String cannot fail.
51
- let _ = writeln!(out, "<!-- page {} -->", page.number);
51
+ match &page.label {
52
+ Some(label) => {
53
+ let _ = writeln!(out, "<!-- page {} (label {label}) -->", page.number);
54
+ }
55
+ None => {
56
+ let _ = writeln!(out, "<!-- page {} -->", page.number);
57
+ }
58
+ }
52
59
  }
53
60
  write_blocks(out, page.blocks.iter().map(|placed| &placed.block), Mode::Body);
54
61
  }
@@ -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;
@@ -1,6 +1,7 @@
1
1
  //! A PDF file's objects on demand: loaded once and shared, decrypted, and
2
2
  //! their streams decoded; then the pages, with what they inherit.
3
3
 
4
+ mod labels;
4
5
  mod pages;
5
6
 
6
7
  use std::borrow::Cow;
@@ -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 (_, baseline, first) = glyphs[start];
86
- let mut end = start + 1;
87
- while end < glyphs.len() {
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
- out.lines.push(Line { span, y, left, right, size, bold, turns, margin: 0 });
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
- pub fn pages(pages: &[PageLines]) -> Vec<Page> {
198
- let boilerplate = running_lines(pages);
199
- let kept = |line: &&Line| line.margin == 0 || boilerplate.binary_search(&line.margin).is_err();
200
- let body = body_size(pages, &kept);
201
- let levels = heading_sizes(pages, &kept, body);
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,39 @@ 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
- out.push(Page { number, blocks, images: page.images });
296
+ let label = labels.map_or_else(
297
+ || footer_label(page, &boilerplate),
298
+ |labels| labels.get(index).cloned().flatten(),
299
+ );
300
+ out.push(Page { number, blocks, images: page.images, label });
227
301
  }
228
302
  out
229
303
  }
230
304
 
305
+ fn footer_label(page: &PageLines, boilerplate: &[u64]) -> Option<String> {
306
+ let mut candidates = page.lines.iter().filter(|line: &&Line| {
307
+ line.margin != 0
308
+ && line.y < page.height * EDGE
309
+ && boilerplate.binary_search(&line.margin).is_err()
310
+ });
311
+ let line = candidates.next()?;
312
+ if candidates.next().is_some() {
313
+ return None;
314
+ }
315
+ let text = page.text(line).trim();
316
+ looks_like_page_number(text).then(|| text.to_owned())
317
+ }
318
+
319
+ fn looks_like_page_number(text: &str) -> bool {
320
+ let core = text.trim_matches(|ch: char| !ch.is_alphanumeric());
321
+ !core.is_empty()
322
+ && core.len() <= 8
323
+ && (core.chars().all(|ch| ch.is_ascii_digit())
324
+ || core.chars().all(|ch| {
325
+ matches!(ch.to_ascii_lowercase(), 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')
326
+ }))
327
+ }
328
+
231
329
  /// Whether `next` carries on the paragraph `last` is in: same direction,
232
330
  /// similar size, and no more than a line and a half below it.
233
331
  fn continues(last: &Line, next: &Line) -> bool {
@@ -245,41 +343,79 @@ fn continues(last: &Line, next: &Line) -> bool {
245
343
  fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
246
344
  let first = lines[0];
247
345
  let length: usize = lines.iter().map(|line| line.span.len()).sum();
248
- let level = if lines.len() <= 3 && length <= 200 {
249
- levels.iter().position(|&size| first.size >= size - 0.25)
250
- } else {
251
- None
346
+ let short = lines.len() <= 3 && length <= 200;
347
+ let text = paragraph_text(page, lines);
348
+ let level =
349
+ if short { levels.iter().position(|&size| first.size >= size - 0.25) } else { None };
350
+ let sized = level
351
+ .filter(|_| first.size >= body * 1.15)
352
+ .map(|level| u8::try_from(level.min(2) + 1).unwrap_or(3));
353
+ let numbered = (short && first.bold && first.size >= body - 0.25)
354
+ .then(|| numbered_heading_level(&text))
355
+ .flatten();
356
+ let block = match sized.or(numbered) {
357
+ Some(level) => Block::Heading { level, text: Text::plain(text) },
358
+ None => Block::Paragraph { text: Text::plain(text) },
252
359
  };
360
+ Placed { block, bounds: bounds(page, lines) }
361
+ }
362
+
363
+ fn paragraph_text(page: &PageLines, lines: &[&Line]) -> String {
364
+ let length: usize = lines.iter().map(|line| line.span.len()).sum();
253
365
  let mut text = String::with_capacity(length + lines.len());
254
- let block = if let Some(level) = level.filter(|_| first.size >= body * 1.15) {
255
- for (index, line) in lines.iter().enumerate() {
256
- if index > 0 {
257
- text.push(' ');
366
+ for (index, line) in lines.iter().enumerate() {
367
+ let line = page.text(line);
368
+ if index > 0 {
369
+ let joined = text.ends_with('-') && {
370
+ let mut before = text.chars().rev().skip(1);
371
+ before.next().is_some_and(char::is_lowercase)
372
+ && line.chars().next().is_some_and(char::is_lowercase)
373
+ };
374
+ if joined {
375
+ text.pop();
376
+ } else {
377
+ text.push('\n');
258
378
  }
259
- text.push_str(page.text(line));
260
379
  }
261
- let level = u8::try_from(level.min(2) + 1).unwrap_or(3);
262
- Block::Heading { level, text: Text::plain(text) }
263
- } else {
264
- for (index, line) in lines.iter().enumerate() {
265
- let line = page.text(line);
266
- if index > 0 {
267
- let joined = text.ends_with('-') && {
268
- let mut before = text.chars().rev().skip(1);
269
- before.next().is_some_and(char::is_lowercase)
270
- && line.chars().next().is_some_and(char::is_lowercase)
271
- };
272
- if joined {
273
- text.pop();
274
- } else {
275
- text.push('\n');
276
- }
277
- }
278
- text.push_str(line);
380
+ text.push_str(line);
381
+ }
382
+ text
383
+ }
384
+
385
+ fn numbered_heading_level(text: &str) -> Option<u8> {
386
+ let (depth, rest) = numbered_prefix(text)?;
387
+ if depth < 2 || !(rest.is_empty() || rest.starts_with(' ')) {
388
+ return None;
389
+ }
390
+ let title = rest.trim_start();
391
+ if title.contains("....") || title.starts_with(|ch: char| !ch.is_alphanumeric()) {
392
+ return None;
393
+ }
394
+ Some(u8::try_from(depth).unwrap_or(6).min(6))
395
+ }
396
+
397
+ fn numbered_prefix(text: &str) -> Option<(usize, &str)> {
398
+ let bytes = text.as_bytes();
399
+ let mut groups = 0_usize;
400
+ let mut pos = 0_usize;
401
+ loop {
402
+ let start = pos;
403
+ while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
404
+ pos += 1;
279
405
  }
280
- Block::Paragraph { text: Text::plain(text) }
281
- };
282
- Placed { block, bounds: bounds(page, lines) }
406
+ if pos == start {
407
+ break;
408
+ }
409
+ groups += 1;
410
+ if groups >= 4
411
+ || bytes.get(pos) != Some(&b'.')
412
+ || !bytes.get(pos + 1).is_some_and(u8::is_ascii_digit)
413
+ {
414
+ break;
415
+ }
416
+ pos += 1;
417
+ }
418
+ (groups > 0).then(|| (groups, &text[pos..]))
283
419
  }
284
420
 
285
421
  /// Where upright lines lie on the page, from its top-left corner: from the
@@ -296,9 +432,9 @@ fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
296
432
  Some(Bounds { left, top, right, bottom })
297
433
  }
298
434
 
299
- /// Text that repeats in the margins of many pages (page numbers, running
300
- /// titles), digits aside: their margin keys, sorted.
301
- fn running_lines(pages: &[PageLines]) -> Vec<u64> {
435
+ /// The keys `key` returns for lines that repeat on enough pages to be
436
+ /// boilerplate rather than coincidence, sorted.
437
+ fn repeated_keys(pages: &[PageLines], key: impl Fn(&Line) -> u64) -> Vec<u64> {
302
438
  if pages.len() < 3 {
303
439
  return Vec::new();
304
440
  }
@@ -306,7 +442,7 @@ fn running_lines(pages: &[PageLines]) -> Vec<u64> {
306
442
  let mut seen: Vec<u64> = Vec::new();
307
443
  for page in pages {
308
444
  seen.clear();
309
- seen.extend(page.lines.iter().map(|line| line.margin).filter(|&key| key != 0));
445
+ seen.extend(page.lines.iter().map(&key).filter(|&key| key != 0));
310
446
  seen.sort_unstable();
311
447
  seen.dedup();
312
448
  for &key in &seen {
@@ -314,10 +450,10 @@ fn running_lines(pages: &[PageLines]) -> Vec<u64> {
314
450
  }
315
451
  }
316
452
  let threshold = (pages.len() * 2 / 5).max(3);
317
- let mut running: Vec<u64> =
453
+ let mut repeated: Vec<u64> =
318
454
  counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
319
- running.sort_unstable();
320
- running
455
+ repeated.sort_unstable();
456
+ repeated
321
457
  }
322
458
 
323
459
  /// 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
- Ok(layout::pages(&lines))
60
+ let labels = document.page_labels(lines.len())?;
61
+ Ok(layout::pages(&lines, labels.as_deref(), budget.drop_repeated()))
61
62
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur_native"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.0'
5
5
  end
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.6.1
4
+ version: 0.7.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