imprimatur 0.6.0 → 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.
@@ -1,43 +1,74 @@
1
- //! The companion worker sets limits before touching the document.
1
+ //! Apply resource limits before parsing any document bytes.
2
2
 
3
- use std::io::Read;
3
+ use std::io::{Read, Write};
4
4
  use std::process::ExitCode;
5
5
 
6
- use super::{protocol, system};
7
- use crate::read::pdf::render_page;
6
+ use super::protocol::{Operation, Request};
7
+ use super::{protocol, stream, system};
8
+ use crate::read::pdf::{self, bounded};
8
9
  use crate::{Error, Result};
9
10
 
10
- /// Internal entry point for the bundled worker executable.
11
+ /// Internal entry point for the bundled PDF worker executable.
11
12
  #[doc(hidden)]
12
13
  #[must_use]
13
14
  pub fn worker_main() -> ExitCode {
14
- match protocol::write_reply(std::io::stdout().lock(), run()) {
15
+ let mut out = std::io::stdout().lock();
16
+ let result = Request::parse().and_then(|request| run(&request, &mut out));
17
+ match result.or_else(|error| protocol::write_reply(&mut out, Err(error))) {
15
18
  Ok(()) => ExitCode::SUCCESS,
16
19
  Err(error) => {
17
- eprintln!("imprimatur render worker: {error}");
20
+ eprintln!("imprimatur PDF worker: {error}");
18
21
  ExitCode::FAILURE
19
22
  }
20
23
  }
21
24
  }
22
25
 
23
- fn run() -> Result<Vec<u8>> {
24
- let request = protocol::Request::parse()?;
25
- let output = request
26
- .options
27
- .max_output_bytes
28
- .max(protocol::MAX_ERROR_BYTES)
29
- .checked_add(protocol::HEADER.len())
30
- .and_then(|value| u64::try_from(value).ok())
31
- .ok_or(Error::InvalidPdfOption("output limit is too large"))?;
26
+ fn run(request: &Request, out: &mut impl Write) -> Result<()> {
27
+ let output = u64::try_from(request.operation.maximum()?)
28
+ .map_err(|_| Error::InvalidPdfOption("worker output limit is too large"))?;
32
29
  system::apply_limits(request.memory, request.cpu_seconds, output)?;
33
- let mut bytes = Vec::new();
30
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
31
+ if !request.restricted {
32
+ system::reexec(&request.arguments())?;
33
+ }
34
34
  let maximum = u64::try_from(request.limits.max_input)
35
35
  .ok()
36
36
  .and_then(|value| value.checked_add(1))
37
37
  .ok_or(Error::InvalidPdfOption("input limit is too large"))?;
38
+ let mut bytes = Vec::new();
38
39
  std::io::stdin().lock().take(maximum).read_to_end(&mut bytes)?;
39
40
  if bytes.len() > request.limits.max_input {
40
41
  return Err(Error::LimitExceeded("input size"));
41
42
  }
42
- render_page(&bytes, request.number, &request.options, &request.limits).map(|image| image.data)
43
+ match &request.operation {
44
+ Operation::Info => protocol::write_reply(
45
+ out,
46
+ pdf::info(&bytes, &request.limits).and_then(|info| {
47
+ serde_json::to_vec(&info)
48
+ .map_err(|error| Error::malformed("PDF worker", error.to_string()))
49
+ }),
50
+ ),
51
+ Operation::Render(number, options) => protocol::write_reply(
52
+ out,
53
+ pdf::render_page(&bytes, *number, options, &request.limits).map(|image| image.data),
54
+ ),
55
+ Operation::Pages(options) => {
56
+ out.write_all(protocol::HEADER)?;
57
+ let result = pdf::info(&bytes, &request.limits).and_then(|info| {
58
+ stream::write(out, &stream::Frame::Info(info))?;
59
+ bounded::visit_pages(&bytes, options, &request.limits, true, |page| {
60
+ stream::write(out, &stream::Frame::Page(page))
61
+ })?;
62
+ Ok(())
63
+ });
64
+ let frame = match result {
65
+ Ok(()) => stream::Frame::End,
66
+ Err(error) => {
67
+ let (kind, message) = protocol::failure(&error);
68
+ stream::Frame::Error { kind, message }
69
+ }
70
+ };
71
+ stream::write(out, &frame)
72
+ }
73
+ }
43
74
  }
@@ -1,7 +1,8 @@
1
- //! Render in a separate, resource-limited process. No untrusted PDF parsing
1
+ //! Read and render in a resource-limited process. No untrusted PDF parsing
2
2
  //! occurs in the supervisor.
3
3
 
4
4
  mod protocol;
5
+ mod stream;
5
6
  mod system;
6
7
  #[cfg(test)]
7
8
  mod tests;
@@ -14,11 +15,47 @@ use std::path::Path;
14
15
  use std::process::{Child, Command, ExitStatus, Stdio};
15
16
  use std::time::{Duration, Instant};
16
17
 
17
- use super::RenderOptions;
18
+ use super::{Info, Pages, ReadOptions, RenderOptions};
18
19
  use crate::read::{Budget, Image, Limits};
19
20
  use crate::{Error, Result};
20
21
  pub use worker::worker_main;
21
22
 
23
+ /// Inspects a PDF in the trusted, resource-limited worker.
24
+ ///
25
+ /// # Errors
26
+ /// Invalid options, worker errors, or exceeded input, memory or time limits.
27
+ pub fn info_isolated(
28
+ worker: &Path,
29
+ data: &[u8],
30
+ limits: &Limits,
31
+ process: &ProcessLimits,
32
+ ) -> Result<Info> {
33
+ let output = invoke(worker, data, protocol::Operation::Info, limits, process)?;
34
+ let bytes = output.complete(protocol::MAX_ERROR_BYTES)?;
35
+ serde_json::from_slice(&bytes)
36
+ .map_err(|error| Error::malformed("PDF worker", error.to_string()))
37
+ }
38
+
39
+ /// Reads PDF pages in the worker, retaining completed records on resource failure.
40
+ ///
41
+ /// Page-local stream limits trigger bounded individual recovery.
42
+ /// All attempts share one external wall-clock and process-memory limit.
43
+ ///
44
+ /// # Errors
45
+ /// As [`info_isolated`], and cancellation or an invalid worker reply.
46
+ /// A failure before page metadata is available remains a document error.
47
+ pub fn pages_isolated(
48
+ worker: &Path,
49
+ data: &[u8],
50
+ options: &ReadOptions,
51
+ limits: &Limits,
52
+ process: &ProcessLimits,
53
+ ) -> Result<Pages> {
54
+ let output =
55
+ invoke(worker, data, protocol::Operation::Pages(options.clone()), limits, process)?;
56
+ stream::decode(output, options)
57
+ }
58
+
22
59
  /// Mandatory process limits, in addition to the reader/render limits.
23
60
  #[derive(Debug, Clone)]
24
61
  pub struct ProcessLimits {
@@ -51,6 +88,44 @@ pub fn render_page_isolated(
51
88
  limits: &Limits,
52
89
  process: &ProcessLimits,
53
90
  ) -> Result<Image> {
91
+ let output = invoke(
92
+ worker,
93
+ data,
94
+ protocol::Operation::Render(number, options.clone()),
95
+ limits,
96
+ process,
97
+ )?;
98
+ let data = output.complete(options.max_output_bytes)?;
99
+ // The trusted worker writes only PNG; reject malformed/empty replies too.
100
+ if !data.starts_with(b"\x89PNG\r\n\x1a\n") || data.len() < 26 {
101
+ return Err(Error::malformed("PDF worker", "invalid PNG response"));
102
+ }
103
+ let dimension = |at| u32::from_be_bytes(data[at..at + 4].try_into().unwrap_or([0; 4]));
104
+ let (width, height) = (dimension(16), dimension(20));
105
+ options.image(data, number, (width, height))
106
+ }
107
+
108
+ struct Output {
109
+ data: Vec<u8>,
110
+ failure: Option<Error>,
111
+ }
112
+
113
+ impl Output {
114
+ fn complete(self, maximum: usize) -> Result<Vec<u8>> {
115
+ if let Some(error) = self.failure {
116
+ return Err(error);
117
+ }
118
+ protocol::decode(self.data, maximum)
119
+ }
120
+ }
121
+
122
+ fn invoke(
123
+ worker: &Path,
124
+ data: &[u8],
125
+ operation: protocol::Operation,
126
+ limits: &Limits,
127
+ process: &ProcessLimits,
128
+ ) -> Result<Output> {
54
129
  let budget = Budget::new(limits, data.len())?;
55
130
  budget.check()?;
56
131
  let deadline = Instant::now()
@@ -59,10 +134,12 @@ pub fn render_page_isolated(
59
134
  if !worker.is_absolute() || !worker.is_file() {
60
135
  return Err(Error::InvalidPdfOption("an absolute path to the render worker is required"));
61
136
  }
62
- if process.memory_bytes == 0 || process.wall_time.is_zero() || number == 0 {
63
- return Err(Error::InvalidPdfOption("process limits and page number must be positive"));
137
+ if process.memory_bytes == 0 || process.wall_time.is_zero() {
138
+ return Err(Error::InvalidPdfOption("process limits must be positive"));
64
139
  }
65
- let request = protocol::Request::new(number, options, limits, process)?;
140
+ let request = protocol::Request::new(operation, limits, process)?;
141
+ let maximum = u64::try_from(request.operation.maximum()?)
142
+ .map_err(|_| Error::InvalidPdfOption("worker output limit is too large"))?;
66
143
  let directory = tempfile::tempdir()?;
67
144
  let input_path = directory.path().join("input.pdf");
68
145
  File::create(&input_path)?.write_all(data)?;
@@ -77,30 +154,20 @@ pub fn render_page_isolated(
77
154
  .process_group(0)
78
155
  .spawn()?;
79
156
  let mut child = Running { child, finished: false };
80
- let status = supervise(&mut child, &budget, deadline, process.memory_bytes)?;
81
- if !status.success() {
82
- return Err(Error::LimitExceeded("render worker resources"));
83
- }
84
- let maximum = options
85
- .max_output_bytes
86
- .max(protocol::MAX_ERROR_BYTES)
87
- .checked_add(protocol::HEADER.len())
88
- .ok_or(Error::InvalidPdfOption("output limit is too large"))?;
89
- let maximum = u64::try_from(maximum).unwrap_or(u64::MAX);
157
+ let failure = match supervise(&mut child, &budget, deadline, process.memory_bytes) {
158
+ Ok(status) if status.success() => check_finished(&budget, deadline).err(),
159
+ Ok(_) => Some(Error::LimitExceeded("PDF worker resources")),
160
+ Err(Error::Cancelled) => return Err(Error::Cancelled),
161
+ Err(error) => Some(error),
162
+ };
163
+ // Stop and reap an unfinished worker before inspecting its flushed output.
164
+ drop(child);
90
165
  if fs::metadata(&output_path)?.len() > maximum {
91
- return Err(Error::LimitExceeded("render worker output"));
166
+ return Err(Error::LimitExceeded("PDF worker output"));
92
167
  }
93
168
  let mut output = Vec::new();
94
169
  File::open(output_path)?.take(maximum).read_to_end(&mut output)?;
95
- check_finished(&budget, deadline)?;
96
- let data = protocol::decode(output, options.max_output_bytes)?;
97
- // The trusted worker writes only PNG; also reject malformed/empty replies.
98
- if !data.starts_with(b"\x89PNG\r\n\x1a\n") || data.len() < 26 {
99
- return Err(Error::malformed("render worker", "invalid PNG response"));
100
- }
101
- let dimension = |at| u32::from_be_bytes(data[at..at + 4].try_into().unwrap_or([0; 4]));
102
- let (width, height) = (dimension(16), dimension(20));
103
- options.image(data, number, (width, height))
170
+ Ok(Output { data: output, failure })
104
171
  }
105
172
 
106
173
  fn check(budget: &Budget<'_>, deadline: Instant) -> Result<()> {
@@ -155,12 +222,34 @@ fn supervise(
155
222
  check(budget, deadline)?;
156
223
  #[cfg(target_os = "macos")]
157
224
  if let Err(error) = system::check_memory(child.child.id(), memory) {
158
- // It may have exited between try_wait and the memory query.
159
- if let Some(status) = child.poll()? {
160
- return Ok(status);
225
+ if matches!(error, Error::Io(_)) {
226
+ return finish_monitor_error(child, budget, deadline, error);
161
227
  }
162
228
  return Err(error);
163
229
  }
164
230
  std::thread::sleep(Duration::from_millis(5));
165
231
  }
166
232
  }
233
+
234
+ #[cfg(target_os = "macos")]
235
+ fn finish_monitor_error(
236
+ child: &mut Running,
237
+ budget: &Budget<'_>,
238
+ deadline: Instant,
239
+ error: Error,
240
+ ) -> Result<ExitStatus> {
241
+ // proc_pidinfo can stop seeing an exiting task before waitpid can reap it.
242
+ // Never bypass monitoring for a live worker: allow only a bounded exit
243
+ // grace, still respecting cancellation and the original overall deadline.
244
+ let grace = Instant::now() + Duration::from_millis(50);
245
+ loop {
246
+ if let Some(status) = child.poll()? {
247
+ return Ok(status);
248
+ }
249
+ check(budget, deadline)?;
250
+ if Instant::now() >= grace {
251
+ return Err(error);
252
+ }
253
+ std::thread::sleep(Duration::from_millis(5));
254
+ }
255
+ }
@@ -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.0"
3
+ version = "0.7.0"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -326,8 +326,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
326
326
  native.define_singleton_method("markdown", function!(read::markdown_native, 4))?;
327
327
  native.define_singleton_method("content", function!(read::content_native, 5))?;
328
328
  native.define_singleton_method("images", function!(read::images_native, 4))?;
329
- native.define_singleton_method("pdf_info", function!(pdf::info, 2))?;
330
- native.define_singleton_method("pdf_pages", function!(pdf::pages, 3))?;
329
+ native.define_singleton_method("pdf_info", function!(pdf::info, 4))?;
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
332
  native.define_singleton_method("sheets", function!(sheets::sheets_native, 5))?;
333
333
  let zip_writer = native.define_class("ZipWriter", ruby.class_object())?;