imprimatur 0.4.0 → 0.6.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Cargo.lock +472 -5
  4. data/Cargo.toml +16 -1
  5. data/README.md +59 -7
  6. data/ext/imprimatur_native/cli/Cargo.toml +1 -1
  7. data/ext/imprimatur_native/cli/src/app.rs +3 -1
  8. data/ext/imprimatur_native/core/Cargo.toml +29 -13
  9. data/ext/imprimatur_native/core/README.md +13 -1
  10. data/ext/imprimatur_native/core/src/bin/imprimatur-render-worker.rs +10 -0
  11. data/ext/imprimatur_native/core/src/bytes.rs +16 -0
  12. data/ext/imprimatur_native/core/src/doc/picture.rs +3 -7
  13. data/ext/imprimatur_native/core/src/doc/size.rs +14 -0
  14. data/ext/imprimatur_native/core/src/doc/symbol.rs +3 -5
  15. data/ext/imprimatur_native/core/src/doc.rs +24 -0
  16. data/ext/imprimatur_native/core/src/docx.rs +6 -5
  17. data/ext/imprimatur_native/core/src/error.rs +6 -0
  18. data/ext/imprimatur_native/core/src/font.rs +12 -0
  19. data/ext/imprimatur_native/core/src/lib.rs +6 -0
  20. data/ext/imprimatur_native/core/src/pdf/layout.rs +13 -5
  21. data/ext/imprimatur_native/core/src/pdf/objects.rs +1 -1
  22. data/ext/imprimatur_native/core/src/pdf/truetype/descriptor.rs +1 -0
  23. data/ext/imprimatur_native/core/src/read/budget.rs +10 -5
  24. data/ext/imprimatur_native/core/src/read/content.rs +16 -0
  25. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +2 -2
  26. data/ext/imprimatur_native/core/src/read/ole.rs +1 -8
  27. data/ext/imprimatur_native/core/src/read/pdf/bounded.rs +190 -0
  28. data/ext/imprimatur_native/core/src/read/pdf/content/state.rs +50 -0
  29. data/ext/imprimatur_native/core/src/read/pdf/content.rs +43 -54
  30. data/ext/imprimatur_native/core/src/read/pdf/document/pages.rs +140 -0
  31. data/ext/imprimatur_native/core/src/read/pdf/document.rs +20 -110
  32. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +25 -13
  33. data/ext/imprimatur_native/core/src/read/pdf/font.rs +4 -3
  34. data/ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs +155 -0
  35. data/ext/imprimatur_native/core/src/read/pdf/isolation/system.rs +71 -0
  36. data/ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs +43 -0
  37. data/ext/imprimatur_native/core/src/read/pdf/isolation.rs +166 -0
  38. data/ext/imprimatur_native/core/src/read/pdf/render.rs +206 -0
  39. data/ext/imprimatur_native/core/src/read/pdf.rs +16 -8
  40. data/ext/imprimatur_native/core/src/read/xls/strings.rs +2 -1
  41. data/ext/imprimatur_native/core/src/read/xls.rs +1 -8
  42. data/ext/imprimatur_native/core/src/read.rs +6 -5
  43. data/ext/imprimatur_native/core/src/test_support/font.rs +37 -0
  44. data/ext/imprimatur_native/core/src/xlsx/validation.rs +28 -18
  45. data/ext/imprimatur_native/core/src/zip/reader.rs +1 -8
  46. data/ext/imprimatur_native/extconf.rb +18 -0
  47. data/ext/imprimatur_native/ffi/Cargo.toml +2 -2
  48. data/ext/imprimatur_native/ffi/src/document.rs +6 -0
  49. data/ext/imprimatur_native/ffi/src/lib.rs +7 -0
  50. data/ext/imprimatur_native/ffi/src/pdf.rs +73 -0
  51. data/ext/imprimatur_native/ffi/src/read.rs +18 -12
  52. data/lib/imprimatur/document.rb +4 -2
  53. data/lib/imprimatur/native.rb +7 -0
  54. data/lib/imprimatur/pdf_reader.rb +27 -0
  55. data/lib/imprimatur/reader.rb +48 -11
  56. data/lib/imprimatur/spreadsheet.rb +1 -1
  57. data/lib/imprimatur/version.rb +1 -1
  58. data/lib/imprimatur.rb +28 -3
  59. metadata +19 -4
@@ -0,0 +1,166 @@
1
+ //! Render in a separate, resource-limited process. No untrusted PDF parsing
2
+ //! occurs in the supervisor.
3
+
4
+ mod protocol;
5
+ mod system;
6
+ #[cfg(test)]
7
+ mod tests;
8
+ mod worker;
9
+
10
+ use std::fs::{self, File};
11
+ use std::io::{Read, Write};
12
+ use std::os::unix::process::CommandExt;
13
+ use std::path::Path;
14
+ use std::process::{Child, Command, ExitStatus, Stdio};
15
+ use std::time::{Duration, Instant};
16
+
17
+ use super::RenderOptions;
18
+ use crate::read::{Budget, Image, Limits};
19
+ use crate::{Error, Result};
20
+ pub use worker::worker_main;
21
+
22
+ /// Mandatory process limits, in addition to the reader/render limits.
23
+ #[derive(Debug, Clone)]
24
+ pub struct ProcessLimits {
25
+ /// Address-space cap on Linux/FreeBSD; RSS watchdog threshold on macOS.
26
+ pub memory_bytes: u64,
27
+ /// A finite upper bound even when the reader's timeout is disabled.
28
+ pub wall_time: Duration,
29
+ }
30
+
31
+ impl Default for ProcessLimits {
32
+ fn default() -> Self {
33
+ Self { memory_bytes: 1024 * 1024 * 1024, wall_time: Duration::from_mins(1) }
34
+ }
35
+ }
36
+
37
+ /// Renders through the explicitly selected, trusted worker executable.
38
+ ///
39
+ /// The child is killed and reaped on timeout, cancellation, resource failure
40
+ /// or supervisor errors. Missing workers never fall back to in-process work.
41
+ /// macOS uses a sampled RSS watchdog and may briefly overshoot its threshold.
42
+ ///
43
+ /// # Errors
44
+ /// Invalid limits, failed worker startup/protocol, render errors, or exceeded
45
+ /// input/output, process memory or wall-clock limits.
46
+ pub fn render_page_isolated(
47
+ worker: &Path,
48
+ data: &[u8],
49
+ number: u32,
50
+ options: &RenderOptions,
51
+ limits: &Limits,
52
+ process: &ProcessLimits,
53
+ ) -> Result<Image> {
54
+ let budget = Budget::new(limits, data.len())?;
55
+ budget.check()?;
56
+ let deadline = Instant::now()
57
+ .checked_add(process.wall_time)
58
+ .ok_or(Error::InvalidPdfOption("worker wall time is too large"))?;
59
+ if !worker.is_absolute() || !worker.is_file() {
60
+ return Err(Error::InvalidPdfOption("an absolute path to the render worker is required"));
61
+ }
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"));
64
+ }
65
+ let request = protocol::Request::new(number, options, limits, process)?;
66
+ let directory = tempfile::tempdir()?;
67
+ let input_path = directory.path().join("input.pdf");
68
+ File::create(&input_path)?.write_all(data)?;
69
+ let output_path = directory.path().join("output");
70
+ let error_path = directory.path().join("stderr");
71
+ check(&budget, deadline)?;
72
+ let child = Command::new(worker)
73
+ .args(request.arguments())
74
+ .stdin(Stdio::from(File::open(&input_path)?))
75
+ .stdout(Stdio::from(File::create(&output_path)?))
76
+ .stderr(Stdio::from(File::create(&error_path)?))
77
+ .process_group(0)
78
+ .spawn()?;
79
+ 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);
90
+ if fs::metadata(&output_path)?.len() > maximum {
91
+ return Err(Error::LimitExceeded("render worker output"));
92
+ }
93
+ let mut output = Vec::new();
94
+ 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))
104
+ }
105
+
106
+ fn check(budget: &Budget<'_>, deadline: Instant) -> Result<()> {
107
+ budget.check()?;
108
+ check_finished(budget, deadline)
109
+ }
110
+
111
+ fn check_finished(budget: &Budget<'_>, deadline: Instant) -> Result<()> {
112
+ budget.check_time()?;
113
+ if Instant::now() >= deadline {
114
+ return Err(Error::LimitExceeded("render worker wall time"));
115
+ }
116
+ Ok(())
117
+ }
118
+
119
+ struct Running {
120
+ child: Child,
121
+ finished: bool,
122
+ }
123
+
124
+ impl Running {
125
+ fn poll(&mut self) -> Result<Option<ExitStatus>> {
126
+ let status = self.child.try_wait()?;
127
+ self.finished = status.is_some();
128
+ Ok(status)
129
+ }
130
+ }
131
+
132
+ impl Drop for Running {
133
+ fn drop(&mut self) {
134
+ if !self.finished {
135
+ system::terminate(&mut self.child);
136
+ }
137
+ }
138
+ }
139
+
140
+ fn supervise(
141
+ child: &mut Running,
142
+ budget: &Budget<'_>,
143
+ deadline: Instant,
144
+ memory: u64,
145
+ ) -> Result<ExitStatus> {
146
+ #[cfg(not(target_os = "macos"))]
147
+ let _ = memory;
148
+
149
+ loop {
150
+ // SIGCHLD can invoke Ruby's unblock callback. Prefer a completed
151
+ // worker; the binding checks pending Ruby exceptions before returning.
152
+ if let Some(status) = child.poll()? {
153
+ return Ok(status);
154
+ }
155
+ check(budget, deadline)?;
156
+ #[cfg(target_os = "macos")]
157
+ 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);
161
+ }
162
+ return Err(error);
163
+ }
164
+ std::thread::sleep(Duration::from_millis(5));
165
+ }
166
+ }
@@ -0,0 +1,206 @@
1
+ //! Full-page rasterization. Optional, pure Rust, and independent of text extraction.
2
+
3
+ #[cfg(test)]
4
+ mod tests;
5
+
6
+ use std::panic::{AssertUnwindSafe, catch_unwind};
7
+ use std::sync::Arc;
8
+ use std::sync::atomic::{AtomicBool, Ordering};
9
+
10
+ use hayro::hayro_interpret::InterpreterSettings;
11
+ use hayro::hayro_syntax::{LoadPdfError, Pdf};
12
+ use hayro::vello_cpu::color::palette::css::WHITE;
13
+ use hayro::{PixmapSettings, RenderCache, RenderSettings};
14
+
15
+ use super::bounded;
16
+ use super::content::{Drawing, page_images};
17
+ use crate::read::image::{Image, ImageFormat, ImageLocation, PngColor, png};
18
+ use crate::read::{Budget, Limits};
19
+ use crate::{Error, Result};
20
+
21
+ #[derive(Debug, Clone)]
22
+ pub struct RenderOptions {
23
+ /// Longest output side in pixels; 1568 by default.
24
+ pub max_side: u16,
25
+ /// Maximum output surface, before allocating it.
26
+ pub max_pixels: u64,
27
+ pub max_output_bytes: usize,
28
+ pub grayscale: bool,
29
+ }
30
+
31
+ impl Default for RenderOptions {
32
+ fn default() -> Self {
33
+ Self {
34
+ max_side: 1568,
35
+ max_pixels: 4_000_000,
36
+ max_output_bytes: 8 * 1024 * 1024,
37
+ grayscale: true,
38
+ }
39
+ }
40
+ }
41
+
42
+ impl RenderOptions {
43
+ pub(super) fn check_pixels(&self, width: u32, height: u32) -> Result<()> {
44
+ if width == 0 || height == 0 || u64::from(width) * u64::from(height) > self.max_pixels {
45
+ return Err(Error::LimitExceeded("rendered pixels"));
46
+ }
47
+ Ok(())
48
+ }
49
+
50
+ pub(super) fn image(&self, data: Vec<u8>, number: u32, size: (u32, u32)) -> Result<Image> {
51
+ self.check_pixels(size.0, size.1)?;
52
+ if data.len() > self.max_output_bytes {
53
+ return Err(Error::LimitExceeded("rendered bytes"));
54
+ }
55
+ Ok(Image {
56
+ data,
57
+ format: ImageFormat::Png,
58
+ width: Some(size.0),
59
+ height: Some(size.1),
60
+ location: ImageLocation::Page { number, bounds: None },
61
+ })
62
+ }
63
+ }
64
+
65
+ /// A one-based page, including native text and graphics, as an opaque PNG.
66
+ ///
67
+ /// Output dimensions and encoded bytes are bounded. Hayro has no cancellation
68
+ /// hook: time/cancellation are checked before and after rasterization, not
69
+ /// during it. Use `isolation::render_page_isolated` for untrusted renders.
70
+ /// `max_inflated` bounds our preflight, not Hayro's internal allocations.
71
+ ///
72
+ /// # Errors
73
+ /// As [`super::info`], invalid options/page number, an oversized surface or
74
+ /// output, and renderer warnings (rather than silently missing content).
75
+ pub fn render_page(
76
+ data: &[u8],
77
+ number: u32,
78
+ options: &RenderOptions,
79
+ limits: &Limits,
80
+ ) -> Result<Image> {
81
+ if number == 0
82
+ || options.max_side == 0
83
+ || options.max_pixels == 0
84
+ || options.max_output_bytes == 0
85
+ {
86
+ return Err(Error::InvalidPdfOption("page number and rendering limits must be positive"));
87
+ }
88
+ let budget = Budget::new(limits, data.len())?;
89
+ let inspected = bounded::info(data, limits)?;
90
+ if number > inspected.page_count {
91
+ return Err(Error::InvalidPdfOption("page number is past the end of the document"));
92
+ }
93
+ budget.check()?;
94
+ preflight(data, number, &budget)?;
95
+ catch_unwind(AssertUnwindSafe(|| render(data, number, options, &budget)))
96
+ .unwrap_or_else(|_| Err(Error::malformed("PDF", "the renderer failed on this page")))
97
+ }
98
+
99
+ fn preflight(data: &[u8], number: u32, budget: &Budget<'_>) -> Result<()> {
100
+ let document = bounded::open(data, budget)?;
101
+ let selected = document.select_pages(number, number, 1)?;
102
+ let (_, page) = selected
103
+ .pages
104
+ .into_iter()
105
+ .next()
106
+ .ok_or(Error::InvalidPdfOption("page number is past the end of the document"))?;
107
+ let mut drawn = Vec::new();
108
+ page_images(&document, &page?, &mut drawn)?;
109
+ let mut pixels = 0_u64;
110
+ for image in drawn {
111
+ budget.check()?;
112
+ let dict = match &image.image {
113
+ Drawing::Object { object, .. } => object.as_dict(),
114
+ Drawing::Inline { dict, .. } => Some(dict),
115
+ }
116
+ .ok_or_else(|| Error::malformed("PDF", "invalid image dictionary"))?;
117
+ let dimension = |full: &[u8], short: &[u8]| -> Result<u64> {
118
+ let value = document.entry(dict, full)?.or(document.entry(dict, short)?);
119
+ let value = value
120
+ .and_then(|value| value.as_integer())
121
+ .and_then(|value| u64::try_from(value).ok());
122
+ value
123
+ .filter(|&value| value != 0)
124
+ .ok_or_else(|| Error::malformed("PDF", "invalid image dimensions"))
125
+ };
126
+ let size = dimension(b"Width", b"W")?
127
+ .checked_mul(dimension(b"Height", b"H")?)
128
+ .ok_or(Error::LimitExceeded("source image pixels"))?;
129
+ pixels = pixels.saturating_add(size);
130
+ if pixels > 40_000_000 {
131
+ return Err(Error::LimitExceeded("source image pixels"));
132
+ }
133
+ }
134
+ Ok(())
135
+ }
136
+
137
+ fn render(data: &[u8], number: u32, options: &RenderOptions, budget: &Budget<'_>) -> Result<Image> {
138
+ let pdf = Pdf::new(data.to_vec()).map_err(|error| match error {
139
+ LoadPdfError::Decryption(_) => Error::PasswordRequired,
140
+ LoadPdfError::Invalid => Error::malformed("PDF", "the renderer cannot open this document"),
141
+ })?;
142
+ budget.check()?;
143
+ let page = pdf.pages().get(number as usize - 1).ok_or_else(|| {
144
+ Error::malformed("PDF", "the renderer's page tree differs from the reader's")
145
+ })?;
146
+ let (width, height) = page.render_dimensions();
147
+ if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
148
+ return Err(Error::malformed("PDF", "invalid page dimensions"));
149
+ }
150
+ let scale = f32::from(options.max_side) / width.max(height);
151
+ let pixels = |side: f32| (side * scale).floor().max(1.0).min(f32::from(options.max_side));
152
+ let (target_width, target_height) = (pixels(width), pixels(height));
153
+ #[expect(
154
+ clippy::cast_possible_truncation,
155
+ clippy::cast_sign_loss,
156
+ reason = "bounded by max_side"
157
+ )]
158
+ let (w, h) = (target_width as u32, target_height as u32);
159
+ options.check_pixels(w, h)?;
160
+ let warned = Arc::new(AtomicBool::new(false));
161
+ let warning = Arc::clone(&warned);
162
+ let settings = InterpreterSettings {
163
+ warning_sink: Arc::new(move |_| {
164
+ warning.store(true, Ordering::Relaxed);
165
+ }),
166
+ ..InterpreterSettings::default()
167
+ };
168
+ let pixmap = hayro::render(
169
+ page,
170
+ &RenderCache::new(),
171
+ &settings,
172
+ &RenderSettings::default(),
173
+ &PixmapSettings {
174
+ x_scale: target_width / width,
175
+ y_scale: target_height / height,
176
+ bg_color: WHITE,
177
+ },
178
+ );
179
+ budget.check()?;
180
+ if warned.load(Ordering::Relaxed) {
181
+ return Err(Error::UnsupportedDocument(
182
+ "the renderer could not render all page content".into(),
183
+ ));
184
+ }
185
+ let (w, h) = (u32::from(pixmap.width()), u32::from(pixmap.height()));
186
+ options.check_pixels(w, h)?;
187
+ let rgba = pixmap.data_as_u8_slice();
188
+ let encoded = if options.grayscale {
189
+ let gray: Vec<u8> = rgba
190
+ .as_chunks::<4>()
191
+ .0
192
+ .iter()
193
+ .map(|pixel| {
194
+ let value = u32::from(pixel[0]) * 299
195
+ + u32::from(pixel[1]) * 587
196
+ + u32::from(pixel[2]) * 114;
197
+ u8::try_from(value / 1000).unwrap_or(255)
198
+ })
199
+ .collect();
200
+ png(w, h, PngColor::Gray, 8, None, &gray)
201
+ } else {
202
+ png(w, h, PngColor::Rgba, 8, None, rgba)
203
+ };
204
+ budget.check()?;
205
+ options.image(encoded, number, (w, h))
206
+ }
@@ -1,8 +1,11 @@
1
- //! PDF: the text layer as Markdown. Every page's content runs through a
1
+ //! PDF inspection, bounded text reads and optional full-page rendering.
2
+ //!
3
+ //! Every page's content runs through a
2
4
  //! small interpreter that places each glyph; layout turns the glyphs into
3
5
  //! lines, paragraphs and headings. Scanned pages have no text layer:
4
6
  //! they are images, and need OCR.
5
7
 
8
+ mod bounded;
6
9
  mod cmap;
7
10
  mod content;
8
11
  mod crypt;
@@ -18,20 +21,28 @@ mod object;
18
21
  mod tests;
19
22
  mod xref;
20
23
 
24
+ pub use bounded::{Info, PageResult, Pages, ReadOptions, Status, info, pages};
25
+ #[cfg(feature = "render")]
26
+ mod render;
27
+ #[cfg(feature = "render")]
28
+ pub use render::{RenderOptions, render_page};
29
+ #[cfg(all(feature = "isolation", unix))]
30
+ pub mod isolation;
31
+
21
32
  use super::Budget;
22
33
  use super::content::Page;
23
- use crate::{Error, Result};
34
+ use crate::Result;
24
35
  use content::{Fonts, PageText};
25
36
  use document::Document;
26
- pub use images::images;
37
+ pub(super) use images::images;
27
38
  use xref::is_limit;
28
39
 
29
40
  /// `%PDF-` within the first kilobyte, where readers look for it.
30
- pub fn is_pdf(document: &[u8]) -> bool {
41
+ pub(super) fn is_pdf(document: &[u8]) -> bool {
31
42
  document[..document.len().min(1024)].windows(5).any(|window| window == b"%PDF-")
32
43
  }
33
44
 
34
- pub fn content(data: &[u8], budget: &Budget<'_>) -> Result<Vec<Page>> {
45
+ pub(super) fn content(data: &[u8], budget: &Budget<'_>) -> Result<Vec<Page>> {
35
46
  let document = Document::open(data, budget)?;
36
47
  let pages = document.pages()?;
37
48
  let mut fonts = Fonts::new();
@@ -46,8 +57,5 @@ pub fn content(data: &[u8], budget: &Budget<'_>) -> Result<Vec<Page>> {
46
57
  Err(_) => lines.push(layout::PageLines::default()),
47
58
  }
48
59
  }
49
- if lines.iter().all(|page| page.text.trim().is_empty()) {
50
- return Err(Error::NoTextLayer);
51
- }
52
60
  Ok(layout::pages(&lines))
53
61
  }
@@ -3,6 +3,7 @@
3
3
  //! across `CONTINUE` records.
4
4
 
5
5
  use crate::Result;
6
+ use crate::bytes::u16_le;
6
7
  use crate::pdf::metrics::WIN_ANSI_HIGH;
7
8
  use crate::read::Budget;
8
9
 
@@ -19,7 +20,7 @@ pub enum Encoding {
19
20
  /// the end.
20
21
  pub fn string(bytes: &[u8], at: usize, wide_count: bool, encoding: Encoding) -> Option<String> {
21
22
  let (count, at) = if wide_count {
22
- (usize::from(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?)), at + 2)
23
+ (usize::from(u16_le(bytes, at)?), at + 2)
23
24
  } else {
24
25
  (usize::from(*bytes.get(at)?), at + 1)
25
26
  };
@@ -13,6 +13,7 @@ use super::Budget;
13
13
  use super::content::{Grid, Sheet, Value};
14
14
  use super::numbers::{Shown, built_in, classify, push_number};
15
15
  use super::ole::Ole;
16
+ use crate::bytes::{u16_le as u16_at, u32_le as u32_at};
16
17
  use crate::{Error, Result};
17
18
  use strings::Encoding;
18
19
 
@@ -70,14 +71,6 @@ fn records(data: &[u8], mut at: usize) -> impl Iterator<Item = (u16, &[u8])> {
70
71
  })
71
72
  }
72
73
 
73
- fn u16_at(body: &[u8], at: usize) -> Option<u16> {
74
- Some(u16::from_le_bytes(body.get(at..at + 2)?.try_into().ok()?))
75
- }
76
-
77
- fn u32_at(body: &[u8], at: usize) -> Option<u32> {
78
- Some(u32::from_le_bytes(body.get(at..at + 4)?.try_into().ok()?))
79
- }
80
-
81
74
  /// A worksheet the workbook lists.
82
75
  struct Listed {
83
76
  name: String,
@@ -25,7 +25,7 @@ mod numbers;
25
25
  mod ods;
26
26
  mod ole;
27
27
  mod package;
28
- mod pdf;
28
+ pub mod pdf;
29
29
  mod span;
30
30
  #[cfg(test)]
31
31
  mod tests;
@@ -82,8 +82,7 @@ impl Format {
82
82
  ///
83
83
  /// # Errors
84
84
  /// [`Error::UnsupportedDocument`] for other formats (legacy `.doc`, binary
85
- /// `.xlsb`, presentations, ODF text), [`Error::NoTextLayer`] for a
86
- /// PDF of scanned pages, [`Error::PasswordRequired`] for a document that
85
+ /// `.xlsb`, presentations, ODF text), [`Error::PasswordRequired`] for a document that
87
86
  /// needs one, [`Error::LimitExceeded`] and [`Error::Cancelled`] when the
88
87
  /// [`Limits`] stop the read, and [`Error::Malformed`] for a corrupt document.
89
88
  pub fn content(document: &[u8], limits: &Limits) -> Result<Content> {
@@ -117,9 +116,11 @@ pub fn content(document: &[u8], limits: &Limits) -> Result<Content> {
117
116
  /// [`Content::to_markdown`].
118
117
  ///
119
118
  /// # Errors
120
- /// As [`content`].
119
+ /// As [`content`], and [`Error::NoTextLayer`] for a PDF without text.
121
120
  pub fn markdown(document: &[u8], limits: &Limits) -> Result<String> {
122
- content(document, limits).map(|content| content.to_markdown())
121
+ let content = content(document, limits)?;
122
+ content.ensure_text_layer()?;
123
+ Ok(content.to_markdown())
123
124
  }
124
125
 
125
126
  /// Calls `each` with every image in a document, as a file.
@@ -0,0 +1,37 @@
1
+ //! Font fixture mutations.
2
+
3
+ #![cfg(test)]
4
+
5
+ use read_fonts::{FontRef, types::Tag};
6
+
7
+ pub fn record(data: &[u8], tag: [u8; 4]) -> usize {
8
+ let font = FontRef::new(data).unwrap();
9
+ let index = font
10
+ .table_directory()
11
+ .table_records()
12
+ .iter()
13
+ .position(|record| record.tag() == Tag::new(&tag))
14
+ .unwrap();
15
+ 12 + 16 * index
16
+ }
17
+
18
+ pub fn offset(data: &[u8], tag: [u8; 4]) -> usize {
19
+ let record = record(data, tag);
20
+ u32::from_be_bytes(data[record + 8..record + 12].try_into().unwrap()) as usize
21
+ }
22
+
23
+ pub fn set_u16(data: &mut [u8], tag: [u8; 4], at: usize, value: u16) {
24
+ let start = offset(data, tag) + at;
25
+ data[start..start + 2].copy_from_slice(&value.to_be_bytes());
26
+ }
27
+
28
+ /// Replaces a table, leaving checksums stale for malformed-input tests.
29
+ pub fn replace(data: &mut Vec<u8>, tag: [u8; 4], table: &[u8]) {
30
+ let record = record(data, tag);
31
+ data.resize(data.len().next_multiple_of(4), 0);
32
+ let start = u32::try_from(data.len()).unwrap();
33
+ let length = u32::try_from(table.len()).unwrap();
34
+ data[record + 8..record + 12].copy_from_slice(&start.to_be_bytes());
35
+ data[record + 12..record + 16].copy_from_slice(&length.to_be_bytes());
36
+ data.extend_from_slice(table);
37
+ }
@@ -57,24 +57,36 @@ pub struct Validation {
57
57
  allow_blank: bool,
58
58
  dropdown: bool,
59
59
  alert: Alert,
60
- prompt_title: Option<String>,
61
- prompt: Option<String>,
62
- error_title: Option<String>,
63
- error: Option<String>,
60
+ prompt: Message,
61
+ error: Message,
62
+ }
63
+
64
+ /// A prompt or alert.
65
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
66
+ struct Message {
67
+ title: Option<String>,
68
+ body: Option<String>,
69
+ }
70
+
71
+ impl Message {
72
+ fn new(kind: &str, title: String, body: String) -> Result<Self> {
73
+ Ok(Self {
74
+ title: text(&format!("{kind} title"), title, MAX_TITLE)?,
75
+ body: text(kind, body, MAX_MESSAGE)?,
76
+ })
77
+ }
64
78
  }
65
79
 
66
80
  impl Validation {
67
- const fn new(kind: Kind, formula: String) -> Self {
81
+ fn new(kind: Kind, formula: String) -> Self {
68
82
  Self {
69
83
  kind,
70
84
  formula,
71
85
  allow_blank: true,
72
86
  dropdown: true,
73
87
  alert: Alert::Stop,
74
- prompt_title: None,
75
- prompt: None,
76
- error_title: None,
77
- error: None,
88
+ prompt: Message::default(),
89
+ error: Message::default(),
78
90
  }
79
91
  }
80
92
 
@@ -155,8 +167,7 @@ impl Validation {
155
167
  /// [`Error::InvalidValidation`] for a title over 32 characters or a
156
168
  /// message over 255.
157
169
  pub fn prompt(mut self, title: impl Into<String>, message: impl Into<String>) -> Result<Self> {
158
- self.prompt_title = text("prompt title", title.into(), MAX_TITLE)?;
159
- self.prompt = text("prompt", message.into(), MAX_MESSAGE)?;
170
+ self.prompt = Message::new("prompt", title.into(), message.into())?;
160
171
  Ok(self)
161
172
  }
162
173
 
@@ -166,8 +177,7 @@ impl Validation {
166
177
  /// # Errors
167
178
  /// As [`prompt`](Self::prompt).
168
179
  pub fn error(mut self, title: impl Into<String>, message: impl Into<String>) -> Result<Self> {
169
- self.error_title = text("error title", title.into(), MAX_TITLE)?;
170
- self.error = text("error", message.into(), MAX_MESSAGE)?;
180
+ self.error = Message::new("error", title.into(), message.into())?;
171
181
  Ok(self)
172
182
  }
173
183
 
@@ -193,17 +203,17 @@ impl Validation {
193
203
  if self.kind == Kind::List && !self.dropdown {
194
204
  attrs.push(("showDropDown", "1"));
195
205
  }
196
- if self.prompt_title.is_some() || self.prompt.is_some() {
206
+ if self.prompt.title.is_some() || self.prompt.body.is_some() {
197
207
  attrs.push(("showInputMessage", "1"));
198
208
  }
199
209
  if self.alert != Alert::Off {
200
210
  attrs.push(("showErrorMessage", "1"));
201
211
  }
202
212
  let texts = [
203
- ("errorTitle", &self.error_title),
204
- ("error", &self.error),
205
- ("promptTitle", &self.prompt_title),
206
- ("prompt", &self.prompt),
213
+ ("errorTitle", &self.error.title),
214
+ ("error", &self.error.body),
215
+ ("promptTitle", &self.prompt.title),
216
+ ("prompt", &self.prompt.body),
207
217
  ]
208
218
  .map(|(name, value)| (name, value.as_deref().map(escape)));
209
219
  for (name, value) in &texts {
@@ -8,6 +8,7 @@ mod tests;
8
8
  use super::{
9
9
  CENTRAL_HEADER, END_OF_CENTRAL_DIRECTORY, LOCAL_HEADER, METHOD_DEFLATED, METHOD_STORED,
10
10
  };
11
+ use crate::bytes::{u16_le as u16_at, u32_le as u32_at};
11
12
  use crate::read::Budget;
12
13
  use crate::{Error, Result};
13
14
 
@@ -131,14 +132,6 @@ impl<'a> ZipReader<'a> {
131
132
  }
132
133
  }
133
134
 
134
- fn u16_at(data: &[u8], at: usize) -> Option<u16> {
135
- Some(u16::from_le_bytes(data.get(at..at + 2)?.try_into().ok()?))
136
- }
137
-
138
- fn u32_at(data: &[u8], at: usize) -> Option<u32> {
139
- Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?))
140
- }
141
-
142
135
  fn malformed(reason: &str) -> Error {
143
136
  Error::malformed("zip", reason)
144
137
  }
@@ -23,6 +23,24 @@ create_rust_makefile('imprimatur/imprimatur_native') do |r|
23
23
  r.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
24
24
  end
25
25
 
26
+ # Build an ordinary executable, not a Ruby cdylib with Ruby's linker flags.
27
+ File.open('Makefile', 'a') do |make|
28
+ make.write <<~MAKE
29
+
30
+ .PHONY: render-worker install-render-worker
31
+ RENDER_WORKER_MANIFEST = $(RB_SYS_CARGO_MANIFEST_DIR)/../core/Cargo.toml
32
+ all: render-worker
33
+ render-worker:
34
+ \t$(CARGO) build --manifest-path "$(RENDER_WORKER_MANIFEST)" \\
35
+ \t --bin imprimatur-render-worker --features isolation $(RB_SYS_CARGO_PROFILE_FLAG) \\
36
+ \t --target-dir "$(RB_SYS_CARGO_TARGET_DIR)" $(if $(CARGO_BUILD_TARGET),--target $(CARGO_BUILD_TARGET))
37
+ install: install-render-worker
38
+ install-render-worker: render-worker
39
+ \t$(MAKEDIRS) "$(RUBYARCHDIR)"
40
+ \t$(INSTALL_PROG) "$(RUSTLIBDIR)/imprimatur-render-worker" "$(RUBYARCHDIR)/imprimatur-render-worker"
41
+ MAKE
42
+ end
43
+
26
44
  # BSD make reads BSDmakefile first (GNU make ignores it): delegate to gmake.
27
45
  if BSD
28
46
  File.write('BSDmakefile', <<~MAKE)