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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4b065ca85ce09dca629c5a4ca92fbd0c361a8855b7226fce30e6a6e93f587cb
4
- data.tar.gz: 63bbf24b4c97feb96676db2c59a2d137220799a993ea51d707977d37d3e90cf2
3
+ metadata.gz: 9812ca055242755ab6117d2c5eba9bb1991e02a21572682f9f36c2a0481e2139
4
+ data.tar.gz: 8e1d32d440ec79ea732c62dd0a830c0b13f76655df7f9b85d60d539e0d429936
5
5
  SHA512:
6
- metadata.gz: 3f58a00ab92228d0cb3c1403c1111909ebda037e776e78b05a0d3830b92f46501a34a3d069303ad6092dfe0f30b5544b2638ce4b708631f60706ef04c21d09ae
7
- data.tar.gz: 32f985236d5804b10812fe87d5f2e74488085d35dd1a66d648474c47f07537e1ff62a7c04f8b06c51847ac0d3d557d35804b308f7972e2b2df24a020e562630c
6
+ metadata.gz: 3572b1a17819ae0fa85346788343f19ef9410d19f48b7257edb95dc00544ae1234991a5d0b50284ed3d10e21f34ca991f965aa7956fecb6a03244805870cea13
7
+ data.tar.gz: 9399ac644f99a699a12a2a8bdcefe73977bddcec75571e8053d984d829ddc33817fd40bb6c9c79fd15ce89f56fbe472035c276e3177430b9a35ab20023b66f16
data/CHANGELOG.md CHANGED
@@ -2,11 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- - Add native PDF metadata, bounded page reads with explicit failure states,
6
- and optional pure-Rust full-page PNG rendering.
7
- - Keep scanned and blank page structure available without a text layer.
8
- - Render PDFs through a bundled Rust worker with time, memory and output limits;
9
- cancellation kills and reaps it. macOS uses an RSS watchdog, not a hard cap.
5
+ - Isolate PDF metadata and text reads in the bundled resource-limited Rust
6
+ worker, as rendering already is; all three accept `memory_bytes:`.
7
+ - On Linux/FreeBSD, re-exec the worker under its address-space limit before
8
+ reading the PDF, including tiny metadata-only reads.
9
+ - Preserve completed pages on worker termination and recover healthy neighbors
10
+ after page-local stream limits without renewing the overall deadline.
11
+ - Allow a bounded exit grace when macOS cannot inspect an exiting worker;
12
+ persistent monitor failures still terminate and reap a live worker.
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
+
28
+ ## [0.6.1](https://github.com/seuros/imprimatur/compare/v0.6.0...v0.6.1) (2026-10-25)
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * **pdf:** isolate bounded reads and preserve page recovery ([fbfbbc5](https://github.com/seuros/imprimatur/commit/fbfbbc5e7e44768efae293b37be255b8b797f831))
10
34
 
11
35
  ## [0.6.0](https://github.com/seuros/imprimatur/compare/v0.5.0...v0.6.0) (2026-10-25)
12
36
 
data/Cargo.lock CHANGED
@@ -513,7 +513,7 @@ dependencies = [
513
513
 
514
514
  [[package]]
515
515
  name = "imprimatur"
516
- version = "0.6.0"
516
+ version = "0.7.0"
517
517
  dependencies = [
518
518
  "aes",
519
519
  "crc32fast",
@@ -526,6 +526,7 @@ dependencies = [
526
526
  "nix",
527
527
  "qrcode",
528
528
  "read-fonts 0.45.1",
529
+ "serde",
529
530
  "serde_json",
530
531
  "sha2",
531
532
  "tempfile",
@@ -535,7 +536,7 @@ dependencies = [
535
536
 
536
537
  [[package]]
537
538
  name = "imprimatur-cli"
538
- version = "0.6.0"
539
+ version = "0.7.0"
539
540
  dependencies = [
540
541
  "imprimatur",
541
542
  "regex",
@@ -547,7 +548,7 @@ dependencies = [
547
548
 
548
549
  [[package]]
549
550
  name = "imprimatur_native"
550
- version = "0.6.0"
551
+ version = "0.7.0"
551
552
  dependencies = [
552
553
  "imprimatur",
553
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.0" }
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"
@@ -27,6 +27,7 @@ read-fonts = "0.45.1"
27
27
  # rb-sys, features and all.
28
28
  rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api"] }
29
29
  regex = "1.13.1"
30
+ serde = { version = "1.0.229", features = ["derive"] }
30
31
  serde_json = "1.0.154"
31
32
  sha2 = "0.11.1"
32
33
  tempfile = "3"
data/README.md CHANGED
@@ -362,20 +362,31 @@ before allocation (`max_pixels:`, 4 million) and encoded output
362
362
  (`max_output_bytes:`, 8 MiB). Preflight rejects pages drawing more than 40 million
363
363
  source-image pixels. Renderer warnings fail instead of silently dropping content.
364
364
  The crate exposes `read::pdf::{info, pages, render_page}` (`render` feature) and
365
- `read::pdf::isolation::render_page_isolated` (`isolation` feature). The latter
366
- takes an absolute path to the trusted `imprimatur-render-worker` executable.
367
- The gem bundles that worker and always uses isolated rendering; it never falls
368
- back to an in-process render or searches PATH.
365
+ `read::pdf::isolation::{info_isolated, pages_isolated, render_page_isolated}`
366
+ (`isolation` feature). These take an absolute path to the trusted
367
+ `imprimatur-render-worker` executable. The gem bundles that worker and always
368
+ isolates all three PDF operations; it never falls back to in-process parsing
369
+ or searches PATH.
369
370
 
370
- All three accept the normal read limits. Rendering also accepts `memory_bytes:`
371
+ All three accept the normal read limits and `memory_bytes:`
371
372
  (1 GiB by default): Linux/FreeBSD enforce an address-space cap; macOS uses a
372
373
  sampled RSS watchdog, which can briefly overshoot. A worker is killed and reaped
373
- on timeout, cancellation or supervisor failure. Rendering has a mandatory
374
+ on timeout, cancellation or supervisor failure. Each operation has a mandatory
374
375
  60-second process ceiling even with `timeout: nil`; a shorter `timeout:` still
375
376
  applies. CPU time, output files and core dumps are also limited.
376
377
 
378
+ The page worker flushes completed records as it goes. An exhausted stream
379
+ budget triggers individual-page recovery inside that worker: each attempt has
380
+ its own `max_inflated` allowance, while the original overall deadline, process
381
+ memory limit and aggregate text cap remain in force. An oversized page is
382
+ `failed`, not blank, without discarding healthy neighbors. A worker terminated
383
+ after metadata was read preserves completed pages and explicitly marks the
384
+ unreached pages; an expired overall deadline marks them `skipped`. Cancellation
385
+ still raises rather than returning a partial successful call.
386
+
377
387
  This is resource containment, **not a filesystem/network security sandbox**.
378
- The raw crate `render_page` remains in-process and cannot interrupt Hayro.
388
+ The raw crate `info`, `pages` and `render_page` APIs remain in-process;
389
+ `render_page` cannot interrupt Hayro.
379
390
  `max_inflated` bounds our preflight, not Hayro's internal decoders.
380
391
 
381
392
  ```ruby
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.6.0"
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.0"
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"]
@@ -33,6 +33,7 @@ md-5.workspace = true
33
33
  miniz_oxide.workspace = true
34
34
  qrcode.workspace = true
35
35
  read-fonts = { workspace = true, optional = true }
36
+ serde = { workspace = true, optional = true }
36
37
  serde_json = { workspace = true, optional = true }
37
38
  sha2.workspace = true
38
39
  thiserror.workspace = true
@@ -51,7 +52,7 @@ archive = ["dep:zip"]
51
52
  fonts = ["dep:read-fonts"]
52
53
  # Pure-Rust full-page PDF rasterization; readers and writers remain independent.
53
54
  render = ["dep:hayro"]
54
- isolation = ["render", "dep:tempfile", "dep:nix", "dep:libproc"]
55
+ isolation = ["render", "json", "dep:serde", "dep:tempfile", "dep:nix", "dep:libproc"]
55
56
 
56
57
  [target.'cfg(unix)'.dependencies]
57
58
  nix = { workspace = true, optional = true }
@@ -11,10 +11,15 @@ states and UTF-8-safe text limits. `read::pdf::render_page`, under the optional
11
11
  `render` feature, rasterizes a full page through Hayro (pure Rust).
12
12
  That raw API is not preemptible. For untrusted documents, enable `isolation`,
13
13
  build the `imprimatur-render-worker` binary and pass its absolute path to
14
- `read::pdf::isolation::render_page_isolated`. Rust supervises finite wall/CPU,
14
+ `read::pdf::isolation::{info_isolated, pages_isolated, render_page_isolated}`.
15
+ Rust supervises finite wall/CPU,
15
16
  memory and file-output limits, killing and reaping failed/cancelled workers.
16
17
  Linux/FreeBSD enforce address-space limits; macOS samples RSS and can briefly
17
18
  overshoot. This is resource containment, not a filesystem/network sandbox.
19
+ Isolated page reads flush completed records, retain them on worker termination,
20
+ and recover healthy neighbors after page-local decompression failures. Recovery
21
+ attempts renew only the individual decompression allowance, never the overall
22
+ deadline, process-memory limit or aggregate text cap.
18
23
 
19
24
  ```rust
20
25
  use imprimatur::xlsx::{Cell, Color, DateTime, Format, Workbook};
@@ -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
  }
@@ -11,6 +11,7 @@ use crate::{Error, Result};
11
11
 
12
12
  /// Metadata read without interpreting page content.
13
13
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14
+ #[cfg_attr(feature = "isolation", derive(serde::Serialize, serde::Deserialize))]
14
15
  pub struct Info {
15
16
  pub page_count: u32,
16
17
  /// Opened with the empty password: permissions, not a locked document.
@@ -40,7 +41,7 @@ impl Default for ReadOptions {
40
41
  }
41
42
 
42
43
  impl ReadOptions {
43
- const fn validate(&self) -> Result<()> {
44
+ pub(super) const fn validate(&self) -> Result<()> {
44
45
  if self.first_page == 0 || self.last_page < self.first_page {
45
46
  return Err(Error::InvalidPdfOption("pages must be an ascending one-based range"));
46
47
  }
@@ -53,6 +54,7 @@ impl ReadOptions {
53
54
 
54
55
  /// What is known about a page; failure is never reported as empty.
55
56
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57
+ #[cfg_attr(feature = "isolation", derive(serde::Serialize, serde::Deserialize))]
56
58
  pub enum Status {
57
59
  Text,
58
60
  Empty,
@@ -73,6 +75,7 @@ impl Status {
73
75
  }
74
76
 
75
77
  #[derive(Debug)]
78
+ #[cfg_attr(feature = "isolation", derive(serde::Serialize, serde::Deserialize))]
76
79
  pub struct PageResult {
77
80
  pub number: u32,
78
81
  pub status: Status,
@@ -106,6 +109,24 @@ pub fn info(data: &[u8], limits: &Limits) -> Result<Info> {
106
109
  /// # Errors
107
110
  /// As [`info`], and invalid options.
108
111
  pub fn pages(data: &[u8], options: &ReadOptions, limits: &Limits) -> Result<Pages> {
112
+ let mut pages = Vec::new();
113
+ let info = visit_pages(data, options, limits, false, |page| {
114
+ pages.push(page);
115
+ Ok(())
116
+ })?;
117
+ Ok(Pages { info, pages })
118
+ }
119
+
120
+ /// The worker flushes each completed page. After an exhausted stream budget,
121
+ /// single-page attempts get fresh decompression budgets, as individual reads
122
+ /// would; the supervisor still enforces one wall-clock and process limit.
123
+ pub(super) fn visit_pages(
124
+ data: &[u8],
125
+ options: &ReadOptions,
126
+ limits: &Limits,
127
+ recover: bool,
128
+ mut emit: impl FnMut(PageResult) -> Result<()>,
129
+ ) -> Result<Info> {
109
130
  options.validate()?;
110
131
  let budget = Budget::new(limits, data.len())?;
111
132
  let document = open(data, &budget)?;
@@ -114,52 +135,89 @@ pub fn pages(data: &[u8], options: &ReadOptions, limits: &Limits) -> Result<Page
114
135
  let mut fonts = Fonts::new();
115
136
  let mut text = PageText { strict: true, ..PageText::default() };
116
137
  let mut remaining = options.max_output_bytes;
117
- let mut results = Vec::with_capacity(selected.pages.len());
138
+ let mut individual = false;
118
139
  for (number, page) in selected.pages {
119
140
  budget.check()?;
120
141
  if remaining == 0 {
121
- results.push(unread(
122
- number,
123
- Status::Skipped,
124
- "the text output limit was reached".into(),
125
- ));
142
+ emit(unread(number, Status::Skipped, "the text output limit was reached".into()))?;
126
143
  continue;
127
144
  }
128
145
  text.max_bytes = Some(options.max_page_bytes.min(remaining));
129
- let read = page.and_then(|page| page_text(&document, &page, &mut fonts, &mut text));
130
- match read {
131
- Ok(()) => {
132
- let lines = layout::lines(&text);
133
- let mut output = String::new();
134
- for line in &lines.lines {
135
- if !output.is_empty() {
136
- output.push('\n');
137
- }
138
- output.push_str(lines.text(line));
146
+ let read = if individual {
147
+ single_page(data, number, options, remaining, limits)
148
+ } else {
149
+ page.and_then(|page| page_text(&document, &page, &mut fonts, &mut text))
150
+ .map(|()| text_result(number, &text, text.max_bytes.unwrap_or(remaining)))
151
+ };
152
+ let read = match read {
153
+ Err(error @ Error::LimitExceeded(_)) if recover => {
154
+ // Never renew an expired time budget or swallow cancellation.
155
+ budget.check()?;
156
+ if individual {
157
+ Err(error)
158
+ } else {
159
+ individual = true;
160
+ single_page(data, number, options, remaining, limits)
139
161
  }
140
- let limit = options.max_page_bytes.min(remaining);
141
- let truncated = text.truncated || output.len() > limit;
142
- truncate(&mut output, limit);
143
- remaining -= output.len();
144
- results.push(PageResult {
145
- number,
146
- status: if output.trim().is_empty() { Status::Empty } else { Status::Text },
147
- text: output,
148
- images: Some(u32::try_from(text.images).unwrap_or(u32::MAX)),
149
- truncated,
150
- reason: truncated.then(|| "the page text limit was reached".into()),
151
- });
162
+ }
163
+ other => other,
164
+ };
165
+ let result = match read {
166
+ Ok(page) => page,
167
+ Err(error @ Error::LimitExceeded(_)) if recover => {
168
+ budget.check()?;
169
+ unread(number, Status::Failed, error.to_string())
152
170
  }
153
171
  Err(error) if xref::is_limit(&error) || matches!(error, Error::PasswordRequired) => {
154
172
  return Err(error);
155
173
  }
156
- Err(error) => results.push(unread(number, Status::Failed, error.to_string())),
174
+ Err(error) => unread(number, Status::Failed, error.to_string()),
175
+ };
176
+ remaining -= result.text.len();
177
+ emit(result)?;
178
+ }
179
+ Ok(Info { page_count: selected.count, encrypted: document.encrypted() })
180
+ }
181
+
182
+ fn single_page(
183
+ data: &[u8],
184
+ number: u32,
185
+ options: &ReadOptions,
186
+ remaining: usize,
187
+ limits: &Limits,
188
+ ) -> Result<PageResult> {
189
+ let options = ReadOptions {
190
+ first_page: number,
191
+ last_page: number,
192
+ max_pages: 1,
193
+ max_output_bytes: remaining,
194
+ ..options.clone()
195
+ };
196
+ pages(data, &options, limits)?
197
+ .pages
198
+ .pop()
199
+ .ok_or_else(|| Error::malformed("PDF", "the selected page disappeared"))
200
+ }
201
+
202
+ fn text_result(number: u32, text: &PageText, limit: usize) -> PageResult {
203
+ let lines = layout::lines(text);
204
+ let mut output = String::new();
205
+ for line in &lines.lines {
206
+ if !output.is_empty() {
207
+ output.push('\n');
157
208
  }
209
+ output.push_str(lines.text(line));
210
+ }
211
+ let truncated = text.truncated || output.len() > limit;
212
+ truncate(&mut output, limit);
213
+ PageResult {
214
+ number,
215
+ status: if output.trim().is_empty() { Status::Empty } else { Status::Text },
216
+ text: output,
217
+ images: Some(u32::try_from(text.images).unwrap_or(u32::MAX)),
218
+ truncated,
219
+ reason: truncated.then(|| "the page text limit was reached".into()),
158
220
  }
159
- Ok(Pages {
160
- info: Info { page_count: selected.count, encrypted: document.encrypted() },
161
- pages: results,
162
- })
163
221
  }
164
222
 
165
223
  pub(super) fn open<'a>(data: &'a [u8], budget: &'a Budget<'a>) -> Result<Document<'a>> {
@@ -170,7 +228,8 @@ pub(super) fn open<'a>(data: &'a [u8], budget: &'a Budget<'a>) -> Result<Documen
170
228
  Document::open(data, budget).map(Document::strict)
171
229
  }
172
230
 
173
- const fn unread(number: u32, status: Status, reason: String) -> PageResult {
231
+ pub(super) fn unread(number: u32, status: Status, mut reason: String) -> PageResult {
232
+ truncate(&mut reason, 4096);
174
233
  PageResult {
175
234
  number,
176
235
  status,