imprimatur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/Cargo.lock +835 -0
- data/Cargo.toml +44 -0
- data/LICENSE.txt +29 -0
- data/README.md +314 -0
- data/ext/imprimatur_native/cli/Cargo.toml +30 -0
- data/ext/imprimatur_native/cli/src/app.rs +183 -0
- data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
- data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
- data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
- data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
- data/ext/imprimatur_native/cli/src/cli.rs +71 -0
- data/ext/imprimatur_native/cli/src/main.rs +15 -0
- data/ext/imprimatur_native/core/Cargo.toml +33 -0
- data/ext/imprimatur_native/core/LICENSE +29 -0
- data/ext/imprimatur_native/core/README.md +105 -0
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
- data/ext/imprimatur_native/core/src/doc.rs +210 -0
- data/ext/imprimatur_native/core/src/docx.rs +343 -0
- data/ext/imprimatur_native/core/src/error.rs +113 -0
- data/ext/imprimatur_native/core/src/lib.rs +38 -0
- data/ext/imprimatur_native/core/src/opc.rs +51 -0
- data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
- data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
- data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
- data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
- data/ext/imprimatur_native/core/src/pdf.rs +84 -0
- data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
- data/ext/imprimatur_native/core/src/read/content.rs +266 -0
- data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
- data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
- data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
- data/ext/imprimatur_native/core/src/read/image.rs +233 -0
- data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
- data/ext/imprimatur_native/core/src/read/json.rs +172 -0
- data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
- data/ext/imprimatur_native/core/src/read/package.rs +191 -0
- data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
- data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
- data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
- data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
- data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
- data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
- data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
- data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
- data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
- data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
- data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
- data/ext/imprimatur_native/core/src/read/span.rs +22 -0
- data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
- data/ext/imprimatur_native/core/src/read.rs +145 -0
- data/ext/imprimatur_native/core/src/style.rs +82 -0
- data/ext/imprimatur_native/core/src/test_support.rs +163 -0
- data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
- data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
- data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
- data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
- data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
- data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
- data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
- data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
- data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
- data/ext/imprimatur_native/core/src/xml.rs +95 -0
- data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
- data/ext/imprimatur_native/core/src/zip.rs +161 -0
- data/ext/imprimatur_native/extconf.rb +45 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
- data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
- data/ext/imprimatur_native/ffi/src/document.rs +138 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
- data/ext/imprimatur_native/ffi/src/read.rs +182 -0
- data/ext/imprimatur_native/ffi/src/style.rs +85 -0
- data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
- data/lib/imprimatur/document.rb +127 -0
- data/lib/imprimatur/error.rb +22 -0
- data/lib/imprimatur/formula.rb +13 -0
- data/lib/imprimatur/native.rb +17 -0
- data/lib/imprimatur/reader.rb +59 -0
- data/lib/imprimatur/style.rb +31 -0
- data/lib/imprimatur/validation.rb +35 -0
- data/lib/imprimatur/version.rb +5 -0
- data/lib/imprimatur/xlsx.rb +183 -0
- data/lib/imprimatur.rb +111 -0
- metadata +157 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
use std::collections::VecDeque;
|
|
2
|
+
use std::fmt::Write as _;
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
use std::process::ExitCode;
|
|
5
|
+
|
|
6
|
+
use imprimatur::read::{Block, Content};
|
|
7
|
+
use regex::{Regex, RegexBuilder};
|
|
8
|
+
use serde_json::json;
|
|
9
|
+
use usage_rs::{Args, RunWith};
|
|
10
|
+
|
|
11
|
+
use super::limits::LimitFlags;
|
|
12
|
+
use crate::app::{self, App, Out};
|
|
13
|
+
|
|
14
|
+
/// Search documents for a pattern
|
|
15
|
+
///
|
|
16
|
+
/// Searches what `imprimatur markdown` reads, line by line: a PDF's
|
|
17
|
+
/// paragraphs and headings by page, a workbook's cells and comments by
|
|
18
|
+
/// sheet and cell, a Word document's blocks by section. Each match is
|
|
19
|
+
/// printed with where it is: `p3` (page 3), `Sales!B2` (a cell, or
|
|
20
|
+
/// `Sales!B2 (comment)` its comment), `§Scope` (the heading it falls under)
|
|
21
|
+
/// or `¶12` (the 12th block, before any heading).
|
|
22
|
+
///
|
|
23
|
+
/// The exit status follows grep: 0 when a line matched, 1 when none did,
|
|
24
|
+
/// 2 for a wrong command line or a document that could not be searched (a
|
|
25
|
+
/// scanned PDF, a damaged file); with `--json`, its reason is reported.
|
|
26
|
+
#[derive(Args, Debug)]
|
|
27
|
+
#[expect(clippy::struct_excessive_bools, reason = "grep's switches, each a flag of its own")]
|
|
28
|
+
#[usage(
|
|
29
|
+
effect = "read",
|
|
30
|
+
args_override_self = false,
|
|
31
|
+
example("imprimatur grep -i 'non.?conformity' audits/*.pdf", help = "Regular expression"),
|
|
32
|
+
example("imprimatur grep -F 'ISO 17065' -C 1 cert.pdf", help = "A fixed string, with context"),
|
|
33
|
+
example("imprimatur grep -l GOTS *.docx *.xlsx", help = "Only the documents that match"),
|
|
34
|
+
example("imprimatur --json grep 'Total' sales.xlsx", help = "Matches as JSON, with offsets")
|
|
35
|
+
)]
|
|
36
|
+
pub struct Grep {
|
|
37
|
+
/// A regular expression (Rust syntax), or with `-F` a fixed string
|
|
38
|
+
#[usage(arg, value_name = "PATTERN")]
|
|
39
|
+
pub pattern: String,
|
|
40
|
+
|
|
41
|
+
/// Documents to search; `-` reads standard input
|
|
42
|
+
#[usage(arg, required, value_name = "FILE", value_hint = usage_rs::ValueHint::FilePath)]
|
|
43
|
+
pub files: Vec<PathBuf>,
|
|
44
|
+
|
|
45
|
+
/// Match regardless of case
|
|
46
|
+
#[usage(short = 'i', long)]
|
|
47
|
+
pub ignore_case: bool,
|
|
48
|
+
|
|
49
|
+
/// Take the pattern as a fixed string
|
|
50
|
+
#[usage(short = 'F', long)]
|
|
51
|
+
pub fixed_strings: bool,
|
|
52
|
+
|
|
53
|
+
/// Match whole words only
|
|
54
|
+
#[usage(short = 'w', long)]
|
|
55
|
+
pub word_regexp: bool,
|
|
56
|
+
|
|
57
|
+
/// Lines of context around each match
|
|
58
|
+
#[usage(short = 'C', long, default = "0", value_name = "LINES")]
|
|
59
|
+
pub context: usize,
|
|
60
|
+
|
|
61
|
+
/// Stop each document after this many matching lines
|
|
62
|
+
#[usage(short = 'm', long, value_name = "NUMBER")]
|
|
63
|
+
pub max_count: Option<usize>,
|
|
64
|
+
|
|
65
|
+
/// Print only the documents with a match
|
|
66
|
+
#[usage(short = 'l', long, conflicts("--count"))]
|
|
67
|
+
pub files_with_matches: bool,
|
|
68
|
+
|
|
69
|
+
/// Print how many lines match in each document
|
|
70
|
+
#[usage(short = 'c', long)]
|
|
71
|
+
pub count: bool,
|
|
72
|
+
|
|
73
|
+
/// Print nothing; the exit status tells whether anything matched
|
|
74
|
+
#[usage(short = 'q', long)]
|
|
75
|
+
pub quiet: bool,
|
|
76
|
+
|
|
77
|
+
#[usage(flatten)]
|
|
78
|
+
pub limits: LimitFlags,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Where a line is.
|
|
82
|
+
#[derive(Debug, Clone)]
|
|
83
|
+
enum Location<'c> {
|
|
84
|
+
Page(u32),
|
|
85
|
+
Cell { sheet: &'c str, cell: String },
|
|
86
|
+
Comment { sheet: &'c str, cell: String },
|
|
87
|
+
Section(&'c str),
|
|
88
|
+
Block(usize),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl Location<'_> {
|
|
92
|
+
fn label(&self, out: &mut String) {
|
|
93
|
+
// Writing to a String cannot fail.
|
|
94
|
+
let _ = match self {
|
|
95
|
+
Self::Page(page) => write!(out, "p{page}"),
|
|
96
|
+
Self::Cell { sheet, cell } => write!(out, "{sheet}!{cell}"),
|
|
97
|
+
Self::Comment { sheet, cell } => write!(out, "{sheet}!{cell} (comment)"),
|
|
98
|
+
Self::Section(heading) => write!(out, "§{heading}"),
|
|
99
|
+
Self::Block(index) => write!(out, "¶{index}"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn json(&self) -> serde_json::Value {
|
|
104
|
+
match self {
|
|
105
|
+
Self::Page(page) => json!({ "page": page }),
|
|
106
|
+
Self::Cell { sheet, cell } => json!({ "sheet": sheet, "cell": cell }),
|
|
107
|
+
Self::Comment { sheet, cell } => {
|
|
108
|
+
json!({ "sheet": sheet, "cell": cell, "comment": true })
|
|
109
|
+
}
|
|
110
|
+
Self::Section(heading) => json!({ "section": heading }),
|
|
111
|
+
Self::Block(index) => json!({ "block": index }),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Every line of a document's content, with where it is.
|
|
117
|
+
fn lines<'c>(content: &'c Content, mut each: impl FnMut(Location<'c>, &str) -> bool) {
|
|
118
|
+
match content {
|
|
119
|
+
Content::Pdf(pages) => {
|
|
120
|
+
for page in pages {
|
|
121
|
+
for placed in &page.blocks {
|
|
122
|
+
for line in placed.block.plain_text().lines() {
|
|
123
|
+
if !each(Location::Page(page.number), line) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
Content::Xlsx(sheets) => {
|
|
131
|
+
for sheet in sheets {
|
|
132
|
+
for cell in sheet.cells.cells() {
|
|
133
|
+
let reference = cell.reference();
|
|
134
|
+
for line in sheet.cells.text(cell).lines() {
|
|
135
|
+
let location =
|
|
136
|
+
Location::Cell { sheet: &sheet.name, cell: reference.clone() };
|
|
137
|
+
if !each(location, line) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for note in &sheet.comments {
|
|
143
|
+
for line in note.text.lines() {
|
|
144
|
+
let location =
|
|
145
|
+
Location::Comment { sheet: &sheet.name, cell: note.reference() };
|
|
146
|
+
if !each(location, line) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
Content::Docx(blocks) => {
|
|
154
|
+
let mut section = None;
|
|
155
|
+
for (index, block) in (1..).zip(blocks) {
|
|
156
|
+
if let Block::Heading { text, .. } = block {
|
|
157
|
+
section = Some(text.text.as_str());
|
|
158
|
+
}
|
|
159
|
+
let location = section.map_or(Location::Block(index), Location::Section);
|
|
160
|
+
for line in block.plain_text().lines() {
|
|
161
|
+
if !each(location.clone(), line) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
_ => {}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// grep's statuses.
|
|
172
|
+
const MATCHED: u8 = 0;
|
|
173
|
+
const NO_MATCH: u8 = 1;
|
|
174
|
+
const TROUBLE: u8 = 2;
|
|
175
|
+
|
|
176
|
+
impl RunWith<&App> for Grep {
|
|
177
|
+
type Output = ExitCode;
|
|
178
|
+
|
|
179
|
+
fn run_with(self, app: &App) -> ExitCode {
|
|
180
|
+
let limits = match self.limits.checked() {
|
|
181
|
+
Ok(limits) => limits,
|
|
182
|
+
Err(status) => return status,
|
|
183
|
+
};
|
|
184
|
+
let pattern = match self.regex() {
|
|
185
|
+
Ok(pattern) => pattern,
|
|
186
|
+
Err(message) => {
|
|
187
|
+
eprintln!("imprimatur: {message}");
|
|
188
|
+
return ExitCode::from(TROUBLE);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
let (mut matched, mut trouble) = (false, false);
|
|
192
|
+
let mut out = Out::new();
|
|
193
|
+
let named = self.files.len() > 1;
|
|
194
|
+
for file in &self.files {
|
|
195
|
+
let document = match app::content(file, &limits) {
|
|
196
|
+
Ok(document) => document,
|
|
197
|
+
Err(error) => {
|
|
198
|
+
trouble = true;
|
|
199
|
+
if !self.quiet && !app.report_failure(&mut out, file, &error).1 {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
let search = Search { grep: &self, app: *app, file, named, pattern: &pattern };
|
|
206
|
+
let found = search.run(&document, &mut out);
|
|
207
|
+
matched |= found.count > 0;
|
|
208
|
+
if !found.open || (self.quiet && matched) {
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
ExitCode::from(match (matched, trouble) {
|
|
213
|
+
(true, _) if self.quiet => MATCHED,
|
|
214
|
+
(_, true) => TROUBLE,
|
|
215
|
+
(true, false) => MATCHED,
|
|
216
|
+
(false, false) => NO_MATCH,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
impl Grep {
|
|
222
|
+
fn regex(&self) -> Result<Regex, String> {
|
|
223
|
+
let escaped;
|
|
224
|
+
let pattern = if self.fixed_strings {
|
|
225
|
+
escaped = regex::escape(&self.pattern);
|
|
226
|
+
&escaped
|
|
227
|
+
} else {
|
|
228
|
+
&self.pattern
|
|
229
|
+
};
|
|
230
|
+
let pattern =
|
|
231
|
+
if self.word_regexp { format!(r"\b(?:{pattern})\b") } else { pattern.clone() };
|
|
232
|
+
RegexBuilder::new(&pattern)
|
|
233
|
+
.case_insensitive(self.ignore_case)
|
|
234
|
+
.build()
|
|
235
|
+
.map_err(|error| format!("invalid pattern: {error}"))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
struct Search<'g> {
|
|
240
|
+
grep: &'g Grep,
|
|
241
|
+
app: App,
|
|
242
|
+
file: &'g Path,
|
|
243
|
+
named: bool,
|
|
244
|
+
pattern: &'g Regex,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// What searching one document found.
|
|
248
|
+
struct Found {
|
|
249
|
+
count: usize,
|
|
250
|
+
open: bool,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
impl Search<'_> {
|
|
254
|
+
fn run(&self, content: &Content, out: &mut Out) -> Found {
|
|
255
|
+
let grep = self.grep;
|
|
256
|
+
let listing = grep.quiet || grep.files_with_matches || grep.count;
|
|
257
|
+
let limit = grep.max_count.unwrap_or(usize::MAX);
|
|
258
|
+
let mut found = Found { count: 0, open: true };
|
|
259
|
+
// Lines before the next match, and how many after the last to show.
|
|
260
|
+
let mut before: VecDeque<(Location<'_>, String)> = VecDeque::with_capacity(grep.context);
|
|
261
|
+
let (mut after, mut last_shown) = (0_usize, None::<usize>);
|
|
262
|
+
let mut line_number = 0_usize;
|
|
263
|
+
let mut line = String::new();
|
|
264
|
+
lines(content, |location, text| {
|
|
265
|
+
line_number += 1;
|
|
266
|
+
if found.count >= limit && after == 0 {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
let hit = found.count < limit && self.pattern.is_match(text);
|
|
270
|
+
if hit {
|
|
271
|
+
found.count += 1;
|
|
272
|
+
if listing {
|
|
273
|
+
return !(grep.quiet || grep.files_with_matches);
|
|
274
|
+
}
|
|
275
|
+
if grep.context > 0
|
|
276
|
+
&& let Some(shown) = last_shown
|
|
277
|
+
&& line_number - before.len() > shown + 1
|
|
278
|
+
&& !self.app.json
|
|
279
|
+
{
|
|
280
|
+
found.open &= out.write("--\n");
|
|
281
|
+
}
|
|
282
|
+
let first = line_number - before.len();
|
|
283
|
+
for (offset, (location, text)) in before.iter().enumerate() {
|
|
284
|
+
found.open &= self.print(out, &mut line, location, text, None, first + offset);
|
|
285
|
+
}
|
|
286
|
+
before.clear();
|
|
287
|
+
found.open &=
|
|
288
|
+
self.print(out, &mut line, &location, text, Some(self.pattern), line_number);
|
|
289
|
+
last_shown = Some(line_number);
|
|
290
|
+
after = grep.context;
|
|
291
|
+
} else if after > 0 && !listing {
|
|
292
|
+
after -= 1;
|
|
293
|
+
found.open &= self.print(out, &mut line, &location, text, None, line_number);
|
|
294
|
+
last_shown = Some(line_number);
|
|
295
|
+
} else if grep.context > 0 && !listing {
|
|
296
|
+
if before.len() == grep.context {
|
|
297
|
+
before.pop_front();
|
|
298
|
+
}
|
|
299
|
+
before.push_back((location, text.to_owned()));
|
|
300
|
+
}
|
|
301
|
+
found.open
|
|
302
|
+
});
|
|
303
|
+
if grep.files_with_matches && found.count > 0 {
|
|
304
|
+
found.open &= if self.app.json {
|
|
305
|
+
out.json(&json!({ "file": self.file.to_string_lossy(), "matched": true }))
|
|
306
|
+
} else {
|
|
307
|
+
out.write(&format!("{}\n", self.file.display()))
|
|
308
|
+
};
|
|
309
|
+
} else if grep.count && !grep.quiet {
|
|
310
|
+
found.open &= if self.app.json {
|
|
311
|
+
out.json(&json!({ "file": self.file.to_string_lossy(), "count": found.count }))
|
|
312
|
+
} else if self.named {
|
|
313
|
+
out.write(&format!("{}:{}\n", self.file.display(), found.count))
|
|
314
|
+
} else {
|
|
315
|
+
out.write(&format!("{}\n", found.count))
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
found
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// One line of output: a match (`pattern` given) or context.
|
|
322
|
+
fn print(
|
|
323
|
+
&self,
|
|
324
|
+
out: &mut Out,
|
|
325
|
+
buffer: &mut String,
|
|
326
|
+
location: &Location<'_>,
|
|
327
|
+
text: &str,
|
|
328
|
+
pattern: Option<&Regex>,
|
|
329
|
+
line: usize,
|
|
330
|
+
) -> bool {
|
|
331
|
+
if self.app.json {
|
|
332
|
+
let mut object = json!({
|
|
333
|
+
"file": self.file.to_string_lossy(),
|
|
334
|
+
"type": if pattern.is_some() { "match" } else { "context" },
|
|
335
|
+
"location": location.json(),
|
|
336
|
+
"line": line,
|
|
337
|
+
"text": text,
|
|
338
|
+
});
|
|
339
|
+
if let Some(pattern) = pattern {
|
|
340
|
+
let spans: Vec<_> = pattern
|
|
341
|
+
.find_iter(text)
|
|
342
|
+
.map(|found| json!({ "start": found.start(), "end": found.end() }))
|
|
343
|
+
.collect();
|
|
344
|
+
object["matches"] = spans.into();
|
|
345
|
+
}
|
|
346
|
+
return out.json(&object);
|
|
347
|
+
}
|
|
348
|
+
buffer.clear();
|
|
349
|
+
if self.named {
|
|
350
|
+
buffer.push_str(&self.file.to_string_lossy());
|
|
351
|
+
buffer.push(':');
|
|
352
|
+
}
|
|
353
|
+
location.label(buffer);
|
|
354
|
+
buffer.push_str(if pattern.is_some() { ": " } else { "- " });
|
|
355
|
+
buffer.push_str(text.trim());
|
|
356
|
+
buffer.push('\n');
|
|
357
|
+
out.write(buffer)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
use std::process::ExitCode;
|
|
5
|
+
|
|
6
|
+
use imprimatur::read::{Image, ImageLocation, images, json};
|
|
7
|
+
use usage_rs::{Args, RunWith};
|
|
8
|
+
|
|
9
|
+
use super::limits::LimitFlags;
|
|
10
|
+
use crate::app::{self, App, Out, Status};
|
|
11
|
+
|
|
12
|
+
/// Extract documents' images into a directory
|
|
13
|
+
///
|
|
14
|
+
/// A PDF's images, once each, from the first page that draws them: JPEG
|
|
15
|
+
/// and JPEG 2000 as they are stored, fax-compressed scans as TIFF, raw
|
|
16
|
+
/// pixels as PNG (CMYK converted to RGB, soft masks as alpha). A scanned
|
|
17
|
+
/// PDF gives its page images, ready for OCR. The media of a Word or Excel
|
|
18
|
+
/// document are copied, with the sheet that shows each one.
|
|
19
|
+
///
|
|
20
|
+
/// Files are named after their document: `NAME-p3-1.png` is the first image
|
|
21
|
+
/// of page 3; `NAME-image1.png` is a medium of the same name. The paths
|
|
22
|
+
/// written are printed, one per line; with `--json`, each image is a JSON
|
|
23
|
+
/// object with its format, size and position on the page.
|
|
24
|
+
#[derive(Args, Debug)]
|
|
25
|
+
#[usage(
|
|
26
|
+
effect = "write",
|
|
27
|
+
args_override_self = false,
|
|
28
|
+
after_long_help = super::EXIT_STATUS,
|
|
29
|
+
example("imprimatur images -o figures/ report.pdf", help = "Every image of a PDF"),
|
|
30
|
+
example("imprimatur images -o pages/ --min-size 600 scan.pdf", help = "Page scans, for OCR"),
|
|
31
|
+
example("imprimatur --json images -o media/ deck.docx", help = "With sizes and positions")
|
|
32
|
+
)]
|
|
33
|
+
pub struct Images {
|
|
34
|
+
/// Documents to read; `-` reads standard input
|
|
35
|
+
#[usage(arg, required, value_name = "FILE", value_hint = usage_rs::ValueHint::FilePath)]
|
|
36
|
+
pub files: Vec<PathBuf>,
|
|
37
|
+
|
|
38
|
+
/// Directory to write the images into (created if missing)
|
|
39
|
+
#[usage(short = 'o', long, value_name = "DIR", value_hint = usage_rs::ValueHint::DirPath)]
|
|
40
|
+
pub output: PathBuf,
|
|
41
|
+
|
|
42
|
+
/// Skip images narrower or shorter than this, in pixels
|
|
43
|
+
#[usage(long, default = "0", value_name = "PIXELS")]
|
|
44
|
+
pub min_size: u32,
|
|
45
|
+
|
|
46
|
+
#[usage(flatten)]
|
|
47
|
+
pub limits: LimitFlags,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl RunWith<&App> for Images {
|
|
51
|
+
type Output = ExitCode;
|
|
52
|
+
|
|
53
|
+
fn run_with(self, app: &App) -> ExitCode {
|
|
54
|
+
let limits = match self.limits.checked() {
|
|
55
|
+
Ok(limits) => limits,
|
|
56
|
+
Err(status) => return status,
|
|
57
|
+
};
|
|
58
|
+
if let Err(status) = app::create_dir(&self.output) {
|
|
59
|
+
return status;
|
|
60
|
+
}
|
|
61
|
+
let mut status = Status::default();
|
|
62
|
+
let mut written = HashSet::new();
|
|
63
|
+
let mut out = Out::new();
|
|
64
|
+
for file in &self.files {
|
|
65
|
+
let mut open = true;
|
|
66
|
+
// The page of the last image, and how many it had so far.
|
|
67
|
+
let mut last = (0, 0);
|
|
68
|
+
let result = app::read(file).and_then(|document| {
|
|
69
|
+
images(&document, &limits, |image| {
|
|
70
|
+
if image
|
|
71
|
+
.width
|
|
72
|
+
.zip(image.height)
|
|
73
|
+
.is_some_and(|(width, height)| width.min(height) < self.min_size)
|
|
74
|
+
{
|
|
75
|
+
return Ok(());
|
|
76
|
+
}
|
|
77
|
+
let target = self.target(file, &image, &mut last, &mut written);
|
|
78
|
+
fs::write(&target, &image.data)?;
|
|
79
|
+
open &= if app.json {
|
|
80
|
+
out.json(&describe(file, &target, &image))
|
|
81
|
+
} else {
|
|
82
|
+
out.write(&format!("{}\n", target.display()))
|
|
83
|
+
};
|
|
84
|
+
// A closed pipe ends the run.
|
|
85
|
+
if open { Ok(()) } else { Err(imprimatur::Error::Cancelled) }
|
|
86
|
+
})
|
|
87
|
+
});
|
|
88
|
+
if !open {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if let Err(error) = result {
|
|
92
|
+
let (failure, still_open) = app.report_failure(&mut out, file, &error);
|
|
93
|
+
status.record(failure);
|
|
94
|
+
if !still_open {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
status.exit_code()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
impl Images {
|
|
104
|
+
/// `DIR/NAME-p3-1.png` for a page's images, `DIR/NAME-image1.png` for a
|
|
105
|
+
/// package's; `-2`, `-3`... when a name is taken.
|
|
106
|
+
fn target(
|
|
107
|
+
&self,
|
|
108
|
+
file: &Path,
|
|
109
|
+
image: &Image,
|
|
110
|
+
last: &mut (u32, usize),
|
|
111
|
+
written: &mut HashSet<PathBuf>,
|
|
112
|
+
) -> PathBuf {
|
|
113
|
+
let stem = app::stem(file);
|
|
114
|
+
let base = match &image.location {
|
|
115
|
+
ImageLocation::Page { number, .. } => {
|
|
116
|
+
// Images come page by page.
|
|
117
|
+
*last = if last.0 == *number { (*number, last.1 + 1) } else { (*number, 1) };
|
|
118
|
+
format!("{stem}-p{number}-{}", last.1)
|
|
119
|
+
}
|
|
120
|
+
ImageLocation::Part { name, .. } => {
|
|
121
|
+
let part = Path::new(name)
|
|
122
|
+
.file_stem()
|
|
123
|
+
.map_or_else(|| "image".into(), |part| part.to_string_lossy());
|
|
124
|
+
format!("{stem}-{part}")
|
|
125
|
+
}
|
|
126
|
+
_ => format!("{stem}-image"),
|
|
127
|
+
};
|
|
128
|
+
app::unique_path(&self.output, &base, image.format.extension(), written)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn describe(file: &Path, target: &Path, image: &Image) -> serde_json::Value {
|
|
133
|
+
let mut object = json::image(image);
|
|
134
|
+
object.insert("file".into(), file.to_string_lossy().into());
|
|
135
|
+
object.insert("image".into(), target.to_string_lossy().into());
|
|
136
|
+
serde_json::Value::Object(object)
|
|
137
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
use std::process::ExitCode;
|
|
2
|
+
use std::time::Duration;
|
|
3
|
+
|
|
4
|
+
use imprimatur::read::Limits;
|
|
5
|
+
use usage_rs::Args;
|
|
6
|
+
|
|
7
|
+
/// The bounds a read runs under, shared by every command that reads.
|
|
8
|
+
#[derive(Args, Debug)]
|
|
9
|
+
pub struct LimitFlags {
|
|
10
|
+
/// Seconds one document may take
|
|
11
|
+
#[usage(long, default = "60", value_name = "SECONDS", help_heading = "Limits")]
|
|
12
|
+
pub timeout: f64,
|
|
13
|
+
|
|
14
|
+
/// Largest document accepted, in bytes (512 MiB by default)
|
|
15
|
+
#[usage(long, value_name = "BYTES", help_heading = "Limits")]
|
|
16
|
+
pub max_bytes: Option<usize>,
|
|
17
|
+
|
|
18
|
+
/// Decompressed data allowed per document, in bytes (1 GiB by default)
|
|
19
|
+
#[usage(long, value_name = "BYTES", help_heading = "Limits")]
|
|
20
|
+
pub max_inflated: Option<u64>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl LimitFlags {
|
|
24
|
+
/// The limits, or the exit status of a wrong command line, reported.
|
|
25
|
+
pub fn checked(&self) -> Result<Limits, ExitCode> {
|
|
26
|
+
self.limits().map_err(|message| {
|
|
27
|
+
eprintln!("imprimatur: {message}");
|
|
28
|
+
ExitCode::from(2)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The limits, or what is wrong with the flags.
|
|
33
|
+
pub fn limits(&self) -> Result<Limits, String> {
|
|
34
|
+
let timeout = Duration::try_from_secs_f64(self.timeout)
|
|
35
|
+
.ok()
|
|
36
|
+
.filter(|timeout| !timeout.is_zero())
|
|
37
|
+
.ok_or_else(|| {
|
|
38
|
+
format!("--timeout must be a positive number of seconds, not {}", self.timeout)
|
|
39
|
+
})?;
|
|
40
|
+
let mut limits = Limits::default().timeout(Some(timeout));
|
|
41
|
+
if let Some(bytes) = self.max_bytes {
|
|
42
|
+
limits = limits.max_input(bytes);
|
|
43
|
+
}
|
|
44
|
+
if let Some(bytes) = self.max_inflated {
|
|
45
|
+
limits = limits.max_inflated(bytes);
|
|
46
|
+
}
|
|
47
|
+
Ok(limits)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
use std::process::ExitCode;
|
|
5
|
+
use std::time::Instant;
|
|
6
|
+
|
|
7
|
+
use imprimatur::read::json;
|
|
8
|
+
use serde_json::Value as Json;
|
|
9
|
+
use usage_rs::{Args, RunWith};
|
|
10
|
+
|
|
11
|
+
use super::limits::LimitFlags;
|
|
12
|
+
use crate::app::{self, App, Out, Status};
|
|
13
|
+
|
|
14
|
+
/// Print documents as Markdown
|
|
15
|
+
///
|
|
16
|
+
/// PDF: the text layer, placed glyph by glyph, with headings by font size,
|
|
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: each sheet as a table, values as Excel shows them, then its
|
|
20
|
+
/// comments. DOCX: headings, lists, emphasis, links and tables.
|
|
21
|
+
///
|
|
22
|
+
/// With several documents, each is introduced by `<!-- file: PATH -->`. A
|
|
23
|
+
/// document that fails is reported on standard error and the others are
|
|
24
|
+
/// still read; the exit status is the first failure's.
|
|
25
|
+
///
|
|
26
|
+
/// With `--json`, each document is one JSON object instead: its structure,
|
|
27
|
+
/// not Markdown. A PDF's pages of blocks (heading, paragraph) with their
|
|
28
|
+
/// position on the page; a workbook's sheets of rows of typed cells
|
|
29
|
+
/// (number, date, bool, text, error, formula) and comments; a Word
|
|
30
|
+
/// document's blocks (heading, paragraph, list item, table) with the byte
|
|
31
|
+
/// ranges of their bold, italic and linked text.
|
|
32
|
+
#[derive(Args, Debug)]
|
|
33
|
+
#[usage(
|
|
34
|
+
effect = "read",
|
|
35
|
+
// Per command: the root's setting covers only the root's own flags.
|
|
36
|
+
args_override_self = false,
|
|
37
|
+
after_long_help = super::EXIT_STATUS,
|
|
38
|
+
example("imprimatur markdown audit.pdf", help = "Print a PDF's text"),
|
|
39
|
+
example("imprimatur md -o notes/ *.docx", help = "Write notes/NAME.md for each"),
|
|
40
|
+
example("curl -s $URL | imprimatur md -", help = "Read standard input"),
|
|
41
|
+
example("imprimatur --json md sales.xlsx", help = "Typed cells, as JSON")
|
|
42
|
+
)]
|
|
43
|
+
pub struct Markdown {
|
|
44
|
+
/// Documents to read; `-` reads standard input
|
|
45
|
+
#[usage(arg, required, value_name = "FILE", value_hint = usage_rs::ValueHint::FilePath)]
|
|
46
|
+
pub files: Vec<PathBuf>,
|
|
47
|
+
|
|
48
|
+
/// Write each document to DIR/NAME.md instead of standard output
|
|
49
|
+
#[usage(short = 'o', long, value_name = "DIR", value_hint = usage_rs::ValueHint::DirPath)]
|
|
50
|
+
pub output: Option<PathBuf>,
|
|
51
|
+
|
|
52
|
+
#[usage(flatten)]
|
|
53
|
+
pub limits: LimitFlags,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl RunWith<&App> for Markdown {
|
|
57
|
+
type Output = ExitCode;
|
|
58
|
+
|
|
59
|
+
fn run_with(self, app: &App) -> ExitCode {
|
|
60
|
+
let limits = match self.limits.checked() {
|
|
61
|
+
Ok(limits) => limits,
|
|
62
|
+
Err(status) => return status,
|
|
63
|
+
};
|
|
64
|
+
if let Some(directory) = &self.output
|
|
65
|
+
&& let Err(status) = app::create_dir(directory)
|
|
66
|
+
{
|
|
67
|
+
return status;
|
|
68
|
+
}
|
|
69
|
+
let mut status = Status::default();
|
|
70
|
+
let mut written = HashSet::new();
|
|
71
|
+
let mut out = Out::new();
|
|
72
|
+
let introduce = self.files.len() > 1 && self.output.is_none() && !app.json;
|
|
73
|
+
for file in &self.files {
|
|
74
|
+
let started = Instant::now();
|
|
75
|
+
let read = app::content(file, &limits);
|
|
76
|
+
let milliseconds = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
|
77
|
+
let shown = read.and_then(|content| {
|
|
78
|
+
let target = match &self.output {
|
|
79
|
+
Some(directory) => {
|
|
80
|
+
let target =
|
|
81
|
+
app::unique_path(directory, &app::stem(file), "md", &mut written);
|
|
82
|
+
fs::write(&target, content.to_markdown())?;
|
|
83
|
+
Some(target)
|
|
84
|
+
}
|
|
85
|
+
None => None,
|
|
86
|
+
};
|
|
87
|
+
Ok((content, target))
|
|
88
|
+
});
|
|
89
|
+
let open = match shown {
|
|
90
|
+
Ok((content, target)) if app.json => {
|
|
91
|
+
let mut object = json::content(&content);
|
|
92
|
+
object.insert("file".into(), file.to_string_lossy().into());
|
|
93
|
+
object.insert("ok".into(), true.into());
|
|
94
|
+
object.insert("milliseconds".into(), milliseconds.into());
|
|
95
|
+
if let Some(target) = target {
|
|
96
|
+
object.insert("written".into(), target.to_string_lossy().into());
|
|
97
|
+
}
|
|
98
|
+
out.json(&Json::Object(object))
|
|
99
|
+
}
|
|
100
|
+
Ok((_, Some(_))) => true,
|
|
101
|
+
Ok((content, None)) => out.write(&printed(file, &content.to_markdown(), introduce)),
|
|
102
|
+
Err(error) => {
|
|
103
|
+
let (failure, open) = app.report_failure(&mut out, file, &error);
|
|
104
|
+
status.record(failure);
|
|
105
|
+
open
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
if !open {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
status.exit_code()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn printed(file: &Path, text: &str, introduce: bool) -> String {
|
|
117
|
+
let mut shown = String::with_capacity(text.len() + 64);
|
|
118
|
+
if introduce {
|
|
119
|
+
shown.push_str("<!-- file: ");
|
|
120
|
+
shown.push_str(&file.to_string_lossy());
|
|
121
|
+
shown.push_str(" -->\n\n");
|
|
122
|
+
}
|
|
123
|
+
shown.push_str(text);
|
|
124
|
+
if !text.ends_with('\n') {
|
|
125
|
+
shown.push('\n');
|
|
126
|
+
}
|
|
127
|
+
shown
|
|
128
|
+
}
|