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.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Cargo.lock +835 -0
  4. data/Cargo.toml +44 -0
  5. data/LICENSE.txt +29 -0
  6. data/README.md +314 -0
  7. data/ext/imprimatur_native/cli/Cargo.toml +30 -0
  8. data/ext/imprimatur_native/cli/src/app.rs +183 -0
  9. data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
  10. data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
  11. data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
  12. data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
  13. data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
  14. data/ext/imprimatur_native/cli/src/cli.rs +71 -0
  15. data/ext/imprimatur_native/cli/src/main.rs +15 -0
  16. data/ext/imprimatur_native/core/Cargo.toml +33 -0
  17. data/ext/imprimatur_native/core/LICENSE +29 -0
  18. data/ext/imprimatur_native/core/README.md +105 -0
  19. data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
  20. data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
  21. data/ext/imprimatur_native/core/src/doc.rs +210 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +343 -0
  23. data/ext/imprimatur_native/core/src/error.rs +113 -0
  24. data/ext/imprimatur_native/core/src/lib.rs +38 -0
  25. data/ext/imprimatur_native/core/src/opc.rs +51 -0
  26. data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
  27. data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
  28. data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
  29. data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
  30. data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
  31. data/ext/imprimatur_native/core/src/pdf.rs +84 -0
  32. data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
  33. data/ext/imprimatur_native/core/src/read/content.rs +266 -0
  34. data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
  35. data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
  36. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
  37. data/ext/imprimatur_native/core/src/read/image.rs +233 -0
  38. data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
  39. data/ext/imprimatur_native/core/src/read/json.rs +172 -0
  40. data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
  41. data/ext/imprimatur_native/core/src/read/package.rs +191 -0
  42. data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
  43. data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
  44. data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
  45. data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
  46. data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
  47. data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
  48. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
  49. data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
  50. data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
  51. data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
  52. data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
  53. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
  54. data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
  55. data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
  56. data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
  57. data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
  58. data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
  59. data/ext/imprimatur_native/core/src/read/span.rs +22 -0
  60. data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
  61. data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
  62. data/ext/imprimatur_native/core/src/read.rs +145 -0
  63. data/ext/imprimatur_native/core/src/style.rs +82 -0
  64. data/ext/imprimatur_native/core/src/test_support.rs +163 -0
  65. data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
  66. data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
  67. data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
  68. data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
  69. data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
  70. data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
  71. data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
  72. data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
  73. data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
  74. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
  75. data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
  76. data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
  77. data/ext/imprimatur_native/core/src/xml.rs +95 -0
  78. data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
  79. data/ext/imprimatur_native/core/src/zip.rs +161 -0
  80. data/ext/imprimatur_native/extconf.rb +45 -0
  81. data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
  82. data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
  83. data/ext/imprimatur_native/ffi/src/document.rs +138 -0
  84. data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
  85. data/ext/imprimatur_native/ffi/src/read.rs +182 -0
  86. data/ext/imprimatur_native/ffi/src/style.rs +85 -0
  87. data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
  88. data/lib/imprimatur/document.rb +127 -0
  89. data/lib/imprimatur/error.rb +22 -0
  90. data/lib/imprimatur/formula.rb +13 -0
  91. data/lib/imprimatur/native.rb +17 -0
  92. data/lib/imprimatur/reader.rb +59 -0
  93. data/lib/imprimatur/style.rb +31 -0
  94. data/lib/imprimatur/validation.rb +35 -0
  95. data/lib/imprimatur/version.rb +5 -0
  96. data/lib/imprimatur/xlsx.rb +183 -0
  97. data/lib/imprimatur.rb +111 -0
  98. 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
+ }