imprimatur 0.1.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.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Cargo.lock +835 -0
  4. data/Cargo.toml +44 -0
  5. data/LICENSE.txt +29 -0
  6. data/README.md +314 -0
  7. data/ext/imprimatur_native/cli/Cargo.toml +30 -0
  8. data/ext/imprimatur_native/cli/src/app.rs +183 -0
  9. data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
  10. data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
  11. data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
  12. data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
  13. data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
  14. data/ext/imprimatur_native/cli/src/cli.rs +71 -0
  15. data/ext/imprimatur_native/cli/src/main.rs +15 -0
  16. data/ext/imprimatur_native/core/Cargo.toml +33 -0
  17. data/ext/imprimatur_native/core/LICENSE +29 -0
  18. data/ext/imprimatur_native/core/README.md +105 -0
  19. data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
  20. data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
  21. data/ext/imprimatur_native/core/src/doc.rs +210 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +343 -0
  23. data/ext/imprimatur_native/core/src/error.rs +113 -0
  24. data/ext/imprimatur_native/core/src/lib.rs +38 -0
  25. data/ext/imprimatur_native/core/src/opc.rs +51 -0
  26. data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
  27. data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
  28. data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
  29. data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
  30. data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
  31. data/ext/imprimatur_native/core/src/pdf.rs +84 -0
  32. data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
  33. data/ext/imprimatur_native/core/src/read/content.rs +266 -0
  34. data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
  35. data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
  36. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
  37. data/ext/imprimatur_native/core/src/read/image.rs +233 -0
  38. data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
  39. data/ext/imprimatur_native/core/src/read/json.rs +172 -0
  40. data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
  41. data/ext/imprimatur_native/core/src/read/package.rs +191 -0
  42. data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
  43. data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
  44. data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
  45. data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
  46. data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
  47. data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
  48. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
  49. data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
  50. data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
  51. data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
  52. data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
  53. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
  54. data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
  55. data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
  56. data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
  57. data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
  58. data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
  59. data/ext/imprimatur_native/core/src/read/span.rs +22 -0
  60. data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
  61. data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
  62. data/ext/imprimatur_native/core/src/read.rs +145 -0
  63. data/ext/imprimatur_native/core/src/style.rs +82 -0
  64. data/ext/imprimatur_native/core/src/test_support.rs +163 -0
  65. data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
  66. data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
  67. data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
  68. data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
  69. data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
  70. data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
  71. data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
  72. data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
  73. data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
  74. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
  75. data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
  76. data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
  77. data/ext/imprimatur_native/core/src/xml.rs +95 -0
  78. data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
  79. data/ext/imprimatur_native/core/src/zip.rs +161 -0
  80. data/ext/imprimatur_native/extconf.rb +45 -0
  81. data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
  82. data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
  83. data/ext/imprimatur_native/ffi/src/document.rs +138 -0
  84. data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
  85. data/ext/imprimatur_native/ffi/src/read.rs +182 -0
  86. data/ext/imprimatur_native/ffi/src/style.rs +85 -0
  87. data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
  88. data/lib/imprimatur/document.rb +127 -0
  89. data/lib/imprimatur/error.rb +22 -0
  90. data/lib/imprimatur/formula.rb +13 -0
  91. data/lib/imprimatur/native.rb +17 -0
  92. data/lib/imprimatur/reader.rb +59 -0
  93. data/lib/imprimatur/style.rb +31 -0
  94. data/lib/imprimatur/validation.rb +35 -0
  95. data/lib/imprimatur/version.rb +5 -0
  96. data/lib/imprimatur/xlsx.rb +183 -0
  97. data/lib/imprimatur.rb +111 -0
  98. metadata +157 -0
@@ -0,0 +1,451 @@
1
+ //! From glyphs to blocks: lines by baseline, words by the gaps between
2
+ //! glyphs, paragraphs by the gaps between lines, headings by size, and
3
+ //! the running headers and footers left out.
4
+
5
+ #[cfg(test)]
6
+ mod tests;
7
+
8
+ use std::collections::HashMap;
9
+ use std::ops::Range;
10
+
11
+ use super::content::{Glyph, PageText};
12
+ use crate::read::content::{Block, Bounds, Page, Placed, Text};
13
+ use crate::read::span;
14
+
15
+ /// Share of the page height, at the top and the bottom, where running
16
+ /// headers and footers live.
17
+ const EDGE: f64 = 0.09;
18
+ /// A gap wider than this share of the font size separates words.
19
+ const WORD_GAP: f64 = 0.18;
20
+
21
+ /// A line of a page, its text a span of the page's buffer.
22
+ #[derive(Debug, Clone)]
23
+ pub struct Line {
24
+ pub span: Range<u32>,
25
+ /// The baseline, in the frame where this line's text runs left to right.
26
+ pub y: f64,
27
+ /// Where the line starts and ends along its baseline, in that frame.
28
+ pub left: f64,
29
+ pub right: f64,
30
+ pub size: f64,
31
+ pub bold: bool,
32
+ pub turns: u8,
33
+ /// In the top or bottom margin of an upright page: a hash of the text,
34
+ /// digits aside, to spot running headers and footers. 0 elsewhere.
35
+ pub margin: u64,
36
+ }
37
+
38
+ /// A page's lines and their text (one buffer for all of them), its height
39
+ /// as shown, and how many images it draws.
40
+ #[derive(Debug, Default)]
41
+ pub struct PageLines {
42
+ pub text: String,
43
+ pub lines: Vec<Line>,
44
+ pub height: f64,
45
+ pub images: u32,
46
+ }
47
+
48
+ impl PageLines {
49
+ pub fn text(&self, line: &Line) -> &str {
50
+ span::slice(&self.text, &line.span)
51
+ }
52
+ }
53
+
54
+ /// A page's lines: upright text first, top to bottom, then each other
55
+ /// direction in turn.
56
+ pub fn lines(page: &PageText) -> PageLines {
57
+ let height = page.size.1.max(1.0);
58
+ let mut out = PageLines {
59
+ text: String::with_capacity(page.text.len() + page.text.len() / 8),
60
+ lines: Vec::new(),
61
+ height,
62
+ images: u32::try_from(page.images).unwrap_or(u32::MAX),
63
+ };
64
+ let mut pieces: Vec<&str> = Vec::new();
65
+ let mut glyphs: Vec<(f64, f64, &Glyph)> = Vec::with_capacity(page.glyphs.len());
66
+ for turns in 0..4_u8 {
67
+ glyphs.clear();
68
+ glyphs.extend(page.glyphs.iter().filter(|glyph| glyph.turns == turns).map(|glyph| {
69
+ let (x, y) = match turns {
70
+ 1 => (glyph.y, -glyph.x),
71
+ 2 => (-glyph.x, -glyph.y),
72
+ 3 => (-glyph.y, glyph.x),
73
+ _ => (glyph.x, glyph.y),
74
+ };
75
+ (x, y, glyph)
76
+ }));
77
+ glyphs.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.total_cmp(&b.0)));
78
+ let mut start = 0;
79
+ while start < glyphs.len() {
80
+ let (_, baseline, first) = glyphs[start];
81
+ let mut end = start + 1;
82
+ while end < glyphs.len() {
83
+ let (_, y, glyph) = glyphs[end];
84
+ if (baseline - y).abs() > 0.45 * first.size.max(glyph.size) {
85
+ break;
86
+ }
87
+ end += 1;
88
+ }
89
+ let row = &mut glyphs[start..end];
90
+ row.sort_by(|a, b| a.0.total_cmp(&b.0));
91
+ let added = out.lines.len();
92
+ line(page, row, turns, &mut pieces, &mut out);
93
+ if let Some(line) = out.lines.get_mut(added)
94
+ && turns == 0
95
+ && (line.y > height * (1.0 - EDGE) || line.y < height * EDGE)
96
+ {
97
+ let text = span::slice(&out.text, &line.span);
98
+ line.margin = margin_key(text);
99
+ }
100
+ start = end;
101
+ }
102
+ }
103
+ out
104
+ }
105
+
106
+ /// One line's text, appended to `out.text`: spaces where glyphs leave a
107
+ /// word gap, a glyph drawn twice on itself (fake bold) kept once, and
108
+ /// right-to-left lines in reading order. A line with no text is not added.
109
+ fn line<'p>(
110
+ page: &'p PageText,
111
+ row: &[(f64, f64, &Glyph)],
112
+ turns: u8,
113
+ pieces: &mut Vec<&'p str>,
114
+ out: &mut PageLines,
115
+ ) {
116
+ // Glyph texts and the spaces between them, left to right as drawn.
117
+ pieces.clear();
118
+ let mut previous: Option<(f64, &Glyph, &str)> = None;
119
+ let (mut size, mut bold, mut count) = (0.0_f64, 0_usize, 0_usize);
120
+ let mut right = f64::MIN;
121
+ for &(x, _, glyph) in row {
122
+ let glyph_text = &page.text[glyph.text.start as usize..glyph.text.end as usize];
123
+ if let Some((previous_x, last, last_text)) = previous {
124
+ if last_text == glyph_text && (x - previous_x).abs() < 0.2 * glyph.size.max(1.0) {
125
+ continue;
126
+ }
127
+ let gap = x - (previous_x + last.width);
128
+ let blank = glyph_text.starts_with(' ')
129
+ || last_text.ends_with(' ')
130
+ || glyph_text.starts_with(is_mark);
131
+ if !blank && gap > WORD_GAP * glyph.size.min(last.size).max(1.0) {
132
+ pieces.push(" ");
133
+ }
134
+ }
135
+ pieces.push(glyph_text);
136
+ right = right.max(x + glyph.width);
137
+ size = size.max(glyph.size);
138
+ bold += usize::from(glyph.bold);
139
+ count += 1;
140
+ previous = Some((x, glyph, glyph_text));
141
+ }
142
+ if right_to_left(pieces) {
143
+ reorder(pieces);
144
+ }
145
+ // Spaces collapsed and trimmed on the way in.
146
+ let start = out.text.len();
147
+ for ch in pieces.iter().flat_map(|piece| piece.chars()) {
148
+ if ch == ' ' && (out.text.len() == start || out.text.ends_with(' ')) {
149
+ continue;
150
+ }
151
+ out.text.push(ch);
152
+ }
153
+ if out.text.ends_with(' ') {
154
+ out.text.pop();
155
+ }
156
+ if out.text.len() == start {
157
+ return;
158
+ }
159
+ let (left, y, _) = row[0];
160
+ let span = span::end(&out.text[..start])..span::end(&out.text);
161
+ let bold = bold * 2 > count;
162
+ out.lines.push(Line { span, y, left, right, size, bold, turns, margin: 0 });
163
+ }
164
+
165
+ /// A hash of a line's text with digits as `#` and ASCII letters lowercased
166
+ /// (FNV-1a), never 0: page 3 of 9 and page 4 of 9 are one running line.
167
+ fn margin_key(text: &str) -> u64 {
168
+ let hash = text.chars().fold(0xcbf2_9ce4_8422_2325_u64, |hash, ch| {
169
+ let ch = if ch.is_ascii_digit() { '#' } else { ch.to_ascii_lowercase() };
170
+ (hash ^ u64::from(u32::from(ch))).wrapping_mul(0x0100_0000_01b3)
171
+ });
172
+ hash.max(1)
173
+ }
174
+
175
+ /// The strong direction of a character: `Some(true)` for the scripts
176
+ /// written right to left, `Some(false)` for other letters and for digits
177
+ /// (which keep their order within right-to-left text), `None` for spaces
178
+ /// and punctuation.
179
+ ///
180
+ /// Right to left: Hebrew (and Yiddish, Ladino), Arabic (and Persian, Urdu,
181
+ /// Pashto, Sindhi, Kurdish, Uyghur), Syriac, Thaana (Dhivehi), N'Ko,
182
+ /// Samaritan, Mandaic, their presentation forms; Adlam (Fulani), Hanifi
183
+ /// Rohingya, Mende Kikakui, Yezidi, Garay and the historic scripts
184
+ /// (Phoenician, Aramaic, Sogdian...); and the right-to-left mark.
185
+ fn direction(ch: char) -> Option<bool> {
186
+ match ch {
187
+ '\u{0590}'..='\u{08FF}'
188
+ | '\u{200F}'
189
+ | '\u{FB1D}'..='\u{FDFF}'
190
+ | '\u{FE70}'..='\u{FEFC}'
191
+ | '\u{10800}'..='\u{10FFF}'
192
+ | '\u{1E800}'..='\u{1EFFF}' => Some(true),
193
+ '\u{200E}' => Some(false),
194
+ ch if ch.is_alphanumeric() => Some(false),
195
+ _ => None,
196
+ }
197
+ }
198
+
199
+ /// Combining marks: diacritics, and the vowel points and signs of the
200
+ /// right-to-left scripts. Drawn as glyphs of their own, they never start a
201
+ /// word.
202
+ const fn is_mark(ch: char) -> bool {
203
+ matches!(
204
+ ch,
205
+ '\u{0300}'..='\u{036F}'
206
+ // Hebrew points and cantillation.
207
+ | '\u{0591}'..='\u{05C7}'
208
+ | '\u{FB1E}'
209
+ // Arabic harakat, Quranic annotation, extended Arabic marks.
210
+ | '\u{0610}'..='\u{061A}'
211
+ | '\u{064B}'..='\u{065F}'
212
+ | '\u{0670}'
213
+ | '\u{06D6}'..='\u{06ED}'
214
+ | '\u{0898}'..='\u{089F}'
215
+ | '\u{08CA}'..='\u{08E1}'
216
+ | '\u{08E3}'..='\u{08FF}'
217
+ // Syriac, Thaana, N'Ko, Samaritan, Mandaic.
218
+ | '\u{0711}'
219
+ | '\u{0730}'..='\u{074A}'
220
+ | '\u{07A6}'..='\u{07B0}'
221
+ | '\u{07EB}'..='\u{07F3}'
222
+ | '\u{07FD}'
223
+ | '\u{0816}'..='\u{0819}'
224
+ | '\u{081B}'..='\u{0823}'
225
+ | '\u{0825}'..='\u{0827}'
226
+ | '\u{0829}'..='\u{082D}'
227
+ | '\u{0859}'..='\u{085B}'
228
+ | '\u{1AB0}'..='\u{1AFF}'
229
+ | '\u{20D0}'..='\u{20FF}'
230
+ | '\u{FE20}'..='\u{FE2F}'
231
+ // Hanifi Rohingya, Adlam.
232
+ | '\u{10D24}'..='\u{10D27}'
233
+ | '\u{1E944}'..='\u{1E94A}'
234
+ )
235
+ }
236
+
237
+ fn piece_direction(piece: &str) -> Option<bool> {
238
+ piece.chars().find_map(direction)
239
+ }
240
+
241
+ /// Whether more of the line's letters read right to left than left to right.
242
+ fn right_to_left(pieces: &[&str]) -> bool {
243
+ let (mut right, mut left) = (0_usize, 0_usize);
244
+ for ch in pieces.iter().flat_map(|piece| piece.chars()) {
245
+ match direction(ch) {
246
+ Some(true) => right += 1,
247
+ Some(false) if ch.is_alphabetic() => left += 1,
248
+ _ => {}
249
+ }
250
+ }
251
+ right > left
252
+ }
253
+
254
+ /// Puts a right-to-left line, drawn left to right, in reading order: runs
255
+ /// of left-to-right text (Latin words, numbers) keep their order inside,
256
+ /// everything else reads from the right. Spaces and punctuation between two
257
+ /// left-to-right pieces belong to their run.
258
+ fn reorder(pieces: &mut [&str]) {
259
+ let directions: Vec<Option<bool>> = pieces.iter().map(|piece| piece_direction(piece)).collect();
260
+ let left_to_right: Vec<bool> = (0..pieces.len())
261
+ .map(|at| {
262
+ directions[at].map_or_else(
263
+ || {
264
+ let before = directions[..at].iter().rev().find_map(|direction| *direction);
265
+ let after = directions[at + 1..].iter().find_map(|direction| *direction);
266
+ before == Some(false) && after == Some(false)
267
+ },
268
+ |rtl| !rtl,
269
+ )
270
+ })
271
+ .collect();
272
+ pieces.reverse();
273
+ let mut at = 0;
274
+ let total = pieces.len();
275
+ while at < total {
276
+ // Reversed, so a left-to-right run is now backwards: turn it back.
277
+ if left_to_right[total - 1 - at] {
278
+ let start = at;
279
+ while at < total && left_to_right[total - 1 - at] {
280
+ at += 1;
281
+ }
282
+ pieces[start..at].reverse();
283
+ } else {
284
+ at += 1;
285
+ }
286
+ }
287
+ }
288
+
289
+ /// The document's pages, from their lines: blocks of the lines that are
290
+ /// not running headers or footers.
291
+ pub fn pages(pages: &[PageLines]) -> Vec<Page> {
292
+ let boilerplate = running_lines(pages);
293
+ let kept = |line: &&Line| line.margin == 0 || boilerplate.binary_search(&line.margin).is_err();
294
+ let body = body_size(pages, &kept);
295
+ let levels = heading_sizes(pages, &kept, body);
296
+ let mut out = Vec::with_capacity(pages.len());
297
+ let mut paragraph: Vec<&Line> = Vec::new();
298
+ // Each page's blocks, collected here, then moved out at their size.
299
+ let mut blocks = Vec::new();
300
+ for (number, page) in (1..).zip(pages) {
301
+ paragraph.clear();
302
+ for line in page.lines.iter().filter(kept) {
303
+ if let Some(last) = paragraph.last()
304
+ && !continues(last, line)
305
+ {
306
+ blocks.push(block(page, &paragraph, body, &levels));
307
+ paragraph.clear();
308
+ }
309
+ paragraph.push(line);
310
+ }
311
+ if !paragraph.is_empty() {
312
+ blocks.push(block(page, &paragraph, body, &levels));
313
+ }
314
+ #[expect(
315
+ clippy::drain_collect,
316
+ clippy::iter_with_drain,
317
+ reason = "`blocks` keeps its buffer for the next page"
318
+ )]
319
+ let blocks = blocks.drain(..).collect();
320
+ out.push(Page { number, blocks, images: page.images });
321
+ }
322
+ out
323
+ }
324
+
325
+ /// Whether `next` carries on the paragraph `last` is in: same direction,
326
+ /// similar size, and no more than a line and a half below it.
327
+ fn continues(last: &Line, next: &Line) -> bool {
328
+ let size = last.size.max(next.size).max(1.0);
329
+ last.turns == next.turns
330
+ && (last.size - next.size).abs() <= 0.15 * size
331
+ && last.y - next.y > 0.0
332
+ && last.y - next.y <= 1.55 * size
333
+ && last.bold == next.bold
334
+ }
335
+
336
+ /// A paragraph of lines as a block: a heading when its few short lines
337
+ /// are set in a heading size, otherwise a paragraph with its lines on lines
338
+ /// of their own, words hyphenated across them joined again.
339
+ fn block(page: &PageLines, lines: &[&Line], body: f64, levels: &[f64]) -> Placed {
340
+ let first = lines[0];
341
+ let length: usize = lines.iter().map(|line| line.span.len()).sum();
342
+ let level = if lines.len() <= 3 && length <= 200 {
343
+ levels.iter().position(|&size| first.size >= size - 0.25)
344
+ } else {
345
+ None
346
+ };
347
+ let mut text = String::with_capacity(length + lines.len());
348
+ let block = if let Some(level) = level.filter(|_| first.size >= body * 1.15) {
349
+ for (index, line) in lines.iter().enumerate() {
350
+ if index > 0 {
351
+ text.push(' ');
352
+ }
353
+ text.push_str(page.text(line));
354
+ }
355
+ let level = u8::try_from(level.min(2) + 1).unwrap_or(3);
356
+ Block::Heading { level, text: Text::plain(text) }
357
+ } else {
358
+ for (index, line) in lines.iter().enumerate() {
359
+ let line = page.text(line);
360
+ if index > 0 {
361
+ let joined = text.ends_with('-') && {
362
+ let mut before = text.chars().rev().skip(1);
363
+ before.next().is_some_and(char::is_lowercase)
364
+ && line.chars().next().is_some_and(char::is_lowercase)
365
+ };
366
+ if joined {
367
+ text.pop();
368
+ } else {
369
+ text.push('\n');
370
+ }
371
+ }
372
+ text.push_str(line);
373
+ }
374
+ Block::Paragraph { text: Text::plain(text) }
375
+ };
376
+ Placed { block, bounds: bounds(page, lines) }
377
+ }
378
+
379
+ /// Where upright lines lie on the page, from its top-left corner: from the
380
+ /// top of the first line's capitals to below the last line's descenders.
381
+ fn bounds(page: &PageLines, lines: &[&Line]) -> Option<Bounds> {
382
+ if lines.iter().any(|line| line.turns != 0) {
383
+ return None;
384
+ }
385
+ let (first, last) = (lines.first()?, lines.last()?);
386
+ let left = lines.iter().map(|line| line.left).fold(f64::MAX, f64::min);
387
+ let right = lines.iter().map(|line| line.right).fold(f64::MIN, f64::max);
388
+ let top = page.height - first.size.mul_add(0.8, first.y);
389
+ let bottom = page.height - last.size.mul_add(-0.25, last.y);
390
+ Some(Bounds { left, top, right, bottom })
391
+ }
392
+
393
+ /// Text that repeats in the margins of many pages (page numbers, running
394
+ /// titles), digits aside: their margin keys, sorted.
395
+ fn running_lines(pages: &[PageLines]) -> Vec<u64> {
396
+ if pages.len() < 3 {
397
+ return Vec::new();
398
+ }
399
+ let mut counts: HashMap<u64, usize> = HashMap::new();
400
+ let mut seen: Vec<u64> = Vec::new();
401
+ for page in pages {
402
+ seen.clear();
403
+ seen.extend(page.lines.iter().map(|line| line.margin).filter(|&key| key != 0));
404
+ seen.sort_unstable();
405
+ seen.dedup();
406
+ for &key in &seen {
407
+ *counts.entry(key).or_default() += 1;
408
+ }
409
+ }
410
+ let threshold = (pages.len() * 2 / 5).max(3);
411
+ let mut running: Vec<u64> =
412
+ counts.into_iter().filter(|&(_, count)| count >= threshold).map(|(key, _)| key).collect();
413
+ running.sort_unstable();
414
+ running
415
+ }
416
+
417
+ /// Every page's lines that `kept` keeps.
418
+ fn kept_lines<'p>(
419
+ pages: &'p [PageLines],
420
+ kept: &'p impl Fn(&&Line) -> bool,
421
+ ) -> impl Iterator<Item = &'p Line> {
422
+ pages.iter().flat_map(|page| &page.lines).filter(kept)
423
+ }
424
+
425
+ /// The most common font size, by characters.
426
+ fn body_size(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool) -> f64 {
427
+ let mut counts: HashMap<i64, usize> = HashMap::new();
428
+ for line in kept_lines(pages, kept) {
429
+ #[expect(clippy::cast_possible_truncation, reason = "font sizes are small")]
430
+ let key = (line.size * 2.0).round() as i64;
431
+ *counts.entry(key).or_default() += line.span.len();
432
+ }
433
+ #[expect(clippy::cast_precision_loss, reason = "a small integer")]
434
+ counts
435
+ .into_iter()
436
+ .max_by_key(|&(size, count)| (count, -size))
437
+ .map_or(0.0, |(size, _)| size as f64 / 2.0)
438
+ }
439
+
440
+ /// The sizes of headings, largest first: distinct sizes clearly above the
441
+ /// body's, three at most.
442
+ fn heading_sizes(pages: &[PageLines], kept: &impl Fn(&&Line) -> bool, body: f64) -> Vec<f64> {
443
+ let mut sizes: Vec<f64> = kept_lines(pages, kept)
444
+ .filter(|line| line.size >= body * 1.15 && line.span.len() <= 200)
445
+ .map(|line| (line.size * 2.0).round() / 2.0)
446
+ .collect();
447
+ sizes.sort_by(|a, b| b.total_cmp(a));
448
+ sizes.dedup();
449
+ sizes.truncate(3);
450
+ sizes
451
+ }