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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/Cargo.lock +472 -5
- data/Cargo.toml +16 -1
- data/README.md +59 -7
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/cli/src/app.rs +3 -1
- data/ext/imprimatur_native/core/Cargo.toml +29 -13
- data/ext/imprimatur_native/core/README.md +13 -1
- data/ext/imprimatur_native/core/src/bin/imprimatur-render-worker.rs +10 -0
- data/ext/imprimatur_native/core/src/bytes.rs +16 -0
- data/ext/imprimatur_native/core/src/doc/picture.rs +3 -7
- data/ext/imprimatur_native/core/src/doc/size.rs +14 -0
- data/ext/imprimatur_native/core/src/doc/symbol.rs +3 -5
- data/ext/imprimatur_native/core/src/doc.rs +24 -0
- data/ext/imprimatur_native/core/src/docx.rs +6 -5
- data/ext/imprimatur_native/core/src/error.rs +6 -0
- data/ext/imprimatur_native/core/src/font.rs +12 -0
- data/ext/imprimatur_native/core/src/lib.rs +6 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +13 -5
- data/ext/imprimatur_native/core/src/pdf/objects.rs +1 -1
- data/ext/imprimatur_native/core/src/pdf/truetype/descriptor.rs +1 -0
- data/ext/imprimatur_native/core/src/read/budget.rs +10 -5
- data/ext/imprimatur_native/core/src/read/content.rs +16 -0
- data/ext/imprimatur_native/core/src/read/image/dimensions.rs +2 -2
- data/ext/imprimatur_native/core/src/read/ole.rs +1 -8
- data/ext/imprimatur_native/core/src/read/pdf/bounded.rs +190 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/state.rs +50 -0
- data/ext/imprimatur_native/core/src/read/pdf/content.rs +43 -54
- data/ext/imprimatur_native/core/src/read/pdf/document/pages.rs +140 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +20 -110
- data/ext/imprimatur_native/core/src/read/pdf/filter.rs +25 -13
- data/ext/imprimatur_native/core/src/read/pdf/font.rs +4 -3
- data/ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs +155 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/system.rs +71 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs +43 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation.rs +166 -0
- data/ext/imprimatur_native/core/src/read/pdf/render.rs +206 -0
- data/ext/imprimatur_native/core/src/read/pdf.rs +16 -8
- data/ext/imprimatur_native/core/src/read/xls/strings.rs +2 -1
- data/ext/imprimatur_native/core/src/read/xls.rs +1 -8
- data/ext/imprimatur_native/core/src/read.rs +6 -5
- data/ext/imprimatur_native/core/src/test_support/font.rs +37 -0
- data/ext/imprimatur_native/core/src/xlsx/validation.rs +28 -18
- data/ext/imprimatur_native/core/src/zip/reader.rs +1 -8
- data/ext/imprimatur_native/extconf.rb +18 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +2 -2
- data/ext/imprimatur_native/ffi/src/document.rs +6 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +7 -0
- data/ext/imprimatur_native/ffi/src/pdf.rs +73 -0
- data/ext/imprimatur_native/ffi/src/read.rs +18 -12
- data/lib/imprimatur/document.rb +4 -2
- data/lib/imprimatur/native.rb +7 -0
- data/lib/imprimatur/pdf_reader.rb +27 -0
- data/lib/imprimatur/reader.rb +48 -11
- data/lib/imprimatur/spreadsheet.rb +1 -1
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur.rb +28 -3
- 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
|
|
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::
|
|
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(
|
|
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::
|
|
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)
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
204
|
-
("error", &self.error),
|
|
205
|
-
("promptTitle", &self.
|
|
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)
|