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
data/Cargo.toml ADDED
@@ -0,0 +1,44 @@
1
+ [workspace]
2
+ members = ["ext/imprimatur_native/cli", "ext/imprimatur_native/core", "ext/imprimatur_native/ffi"]
3
+ resolver = "3"
4
+
5
+ [workspace.package]
6
+ edition = "2024"
7
+ rust-version = "1.99"
8
+ authors = ["Abdelkader Boudih <oss@seuros.com>"]
9
+ license = "BSD-3-Clause"
10
+ repository = "https://github.com/seuros/imprimatur"
11
+
12
+ [workspace.dependencies]
13
+ imprimatur = { path = "ext/imprimatur_native/core", version = "0.1.0" }
14
+ magnus = { version = "0.9.2", default-features = false }
15
+ # For rb_thread_call_without_gvl2, which magnus does not wrap; magnus's own
16
+ # rb-sys, features and all.
17
+ rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api"] }
18
+ regex = "1.13.1"
19
+ serde_json = "1.0.154"
20
+ tempfile = "3"
21
+ usage-parser = { package = "usage-lib", version = "7.0.1" }
22
+ usage-rs = "7.0.1"
23
+
24
+ [workspace.lints.rust]
25
+ unsafe_code = "deny"
26
+ unused_must_use = "warn"
27
+
28
+ [workspace.lints.clippy]
29
+ all = { level = "warn", priority = -1 }
30
+ pedantic = { level = "warn", priority = -1 }
31
+ nursery = { level = "warn", priority = -1 }
32
+ expect_used = "deny"
33
+ needless_borrow = "deny"
34
+ redundant_clone = "deny"
35
+ redundant_closure = "deny"
36
+ redundant_closure_for_method_calls = "deny"
37
+ trivially_copy_pass_by_ref = "deny"
38
+ uninlined_format_args = "deny"
39
+ unwrap_used = "deny"
40
+
41
+ [profile.release]
42
+ codegen-units = 1
43
+ lto = true
44
+ strip = true
data/LICENSE.txt ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Abdelkader Boudih
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,314 @@
1
+ # imprimatur
2
+
3
+ *Imprimatur*: "let it be printed", the stamp a censor put on a book before it
4
+ could go to press. This one stamps spreadsheets, PDFs and Word documents. No heresy reaches the printer: an invalid sheet name, a NaN or
5
+ a 40,000-character cell is refused at the door, not discovered by Excel's
6
+ "We found a problem with some content".
7
+
8
+ - **A Ruby DSL over a native core.** The core is also published on its own,
9
+ as `imprimatur` on crates.io; the gem wraps it.
10
+ - **No third-party format stack.** The XML writer and the zip container are
11
+ written here, from ECMA-376 and the zip specification; only deflate
12
+ (`miniz_oxide`) and CRC-32 (`crc32fast`) come from crates.
13
+ - **Deterministic.** The same document always gives the same bytes.
14
+ - **Native only.** CRuby 4.0+. No pure Ruby fallback, no JRuby, no TruffleRuby.
15
+
16
+ ```ruby
17
+ require "imprimatur"
18
+
19
+ Imprimatur.xlsx("budget.xlsx") do |book|
20
+ book.sheet("Budget") do |sheet|
21
+ sheet.row "Line item", "Promised", "Delivered"
22
+ sheet.row "High-speed rail", 1_000_000_000, 0
23
+ sheet.row "Bridge to nowhere", 398_000_000, 398_000_000
24
+ sheet.row "Total", Imprimatur.formula("SUM(B2:B3)"), Imprimatur.formula("SUM(C2:C3)")
25
+ end
26
+ end
27
+ ```
28
+
29
+ ## Installation
30
+
31
+ ```ruby
32
+ gem "imprimatur"
33
+ ```
34
+
35
+ Precompiled gems ship for Linux (x86_64 and aarch64, glibc 2.34+ and musl),
36
+ macOS 13+ (arm64, x86_64) and FreeBSD 15.1 (amd64): nothing to compile.
37
+ Anywhere else, the source gem compiles the extension on install, with the
38
+ toolchain from [rustup.rs](https://rustup.rs) (1.99+).
39
+
40
+ ## Workbooks
41
+
42
+ ```ruby
43
+ bytes = Imprimatur.xlsx do |book| # no path: returns the .xlsx bytes
44
+ sheet = book.sheet("Sales")
45
+ sheet.row "Region", "Q1", "On target"
46
+ sheet.row "North", 12.5, true
47
+ sheet << ["South", nil, false] # nil leaves a cell empty
48
+ sheet.write(9, 2, "C10") # zero-based row and column
49
+ sheet.next_row # => 10
50
+ end
51
+
52
+ send_data bytes, filename: "sales.xlsx", type: :xlsx
53
+ ```
54
+
55
+ | Ruby value | Cell |
56
+ |---|---|
57
+ | `String` | text, up to 32,767 characters |
58
+ | `Integer` | number, up to 2\*\*53 (beyond, Excel would store a different number: `RangeError`) |
59
+ | `Float` | number; NaN and infinity are refused |
60
+ | `BigDecimal`, `Rational` | number, as its `to_f`: Excel keeps every number as a double |
61
+ | `true`, `false` | boolean |
62
+ | `Imprimatur.formula("SUM(A1:A3)")` | formula, computed when the workbook opens |
63
+ | `Date`, `Time`, `DateTime` | date; its wall-clock fields, no time zone (1900-01-01 to 9999-12-31) |
64
+ | `ActiveSupport::TimeWithZone` | as `Time`, the wall clock of its zone |
65
+ | `nil` | nothing |
66
+
67
+ Dates without a number format of their own show as `yyyy-mm-dd`, or
68
+ `yyyy-mm-dd hh:mm:ss` with a time of day.
69
+
70
+ Anything else raises `TypeError`. Excel's own limits raise `Imprimatur::Error`:
71
+ sheet names (31 characters, no `[ ] : * ? / \`, unique ignoring case, not
72
+ `History`), 1,048,576 rows, 16,384 columns. A rejected row writes nothing.
73
+
74
+ ## Styles
75
+
76
+ ```ruby
77
+ header = Imprimatur.style(bold: true, color: "#FFFFFF", fill: "#1F4E79", align: :center, border: :thin)
78
+ money = Imprimatur.style(number_format: "#,##0.00")
79
+
80
+ Imprimatur.xlsx("report.xlsx") do |book|
81
+ book.sheet("Report") do |sheet|
82
+ sheet.column_widths 24, 14 # characters; nil skips a column
83
+ sheet.row "Line item", "Amount", style: header
84
+ sheet.row "Consulting", nil
85
+ sheet.write(1, 1, 98_000, style: money)
86
+ end
87
+ end
88
+ ```
89
+
90
+ `Imprimatur.style` takes `number_format:`, `bold:`, `italic:`, `underline:`,
91
+ `color:` and `fill:` (`"#RRGGBB"`), `font:` (`"Arial"`; Calibri by default),
92
+ `size:` (points, in steps of 0.5; 11 by default), `align:` (`:left`,
93
+ `:center`, `:right`), `valign:` (`:top`, `:center`, `:bottom`), `direction:`
94
+ (`:ltr`, `:rtl`), `wrap:` and `border:` (`:thin`, `:medium`, `:thick`). A
95
+ style is checked once, when it is built, and every cell using it shares one
96
+ entry in the workbook.
97
+
98
+ `style:` also takes an Array, one style (or `nil`) per column from A; the
99
+ columns past its end are left unstyled:
100
+
101
+ ```ruby
102
+ sheet.row "", "", "", "Notes", style: [yellow] * 3
103
+ ```
104
+
105
+ ## Validation and comments
106
+
107
+ ```ruby
108
+ yes_no = Imprimatur.validation(list: %w[Yes No]) # built once, shared
109
+
110
+ book.sheet("Items") do |sheet|
111
+ sheet.row "Name", "Unit", "Supplier", "Raw material"
112
+ sheet.comment "A1", "Required. Up to 100 characters.", author: "Instructions"
113
+ sheet.validate "A2:A1000", formula: "LEN(A2)<=100", alert: :warning, prompt: "Up to 100 characters"
114
+ sheet.validate "B2:B1000", list: %w[g kg m l ea]
115
+ sheet.validate "C2:C1000", list: "Suppliers!$A$2:$A$500",
116
+ error_title: "Unknown supplier", error: "Pick one from the list."
117
+ sheet.validate "D2:D1000", yes_no
118
+ end
119
+ ```
120
+
121
+ `list:` is the drop-down's values, or a range holding them (another sheet's
122
+ too). Values typed into the rule stay under Excel's 255 characters, commas
123
+ between them included, and cannot contain a comma; longer lists belong in a
124
+ range. `formula:` accepts a value when it is true, its references relative to
125
+ the range's top-left cell. `alert:` decides what an invalid value meets:
126
+ `:stop` (refused, the default), `:warning`, `:information`, or `:off` (accepted;
127
+ a list still offers its drop-down). `prompt:` and `prompt_title:` show while a
128
+ cell is selected, `error:` and `error_title:` in the alert. Ranges are A1 text;
129
+ one overlapping a range already validated is refused, since Excel applies a
130
+ single rule per cell.
131
+
132
+ A comment is an Excel note: hidden until the pointer rests on its cell, or
133
+ shown all the time with `visible: true`. The cell need not hold a value.
134
+
135
+ ## Right to left
136
+
137
+ ```ruby
138
+ rtl = Imprimatur.style(direction: :rtl)
139
+
140
+ book.sheet("مبيعات") do |sheet| # Arabic
141
+ sheet.right_to_left! # column A on the right
142
+ sheet.row "المنطقة", "الشمال", style: rtl
143
+ end
144
+
145
+ book.sheet("מכירות") do |sheet| # Hebrew
146
+ sheet.right_to_left!
147
+ sheet.row "אזור", "צפון", style: rtl
148
+ end
149
+ ```
150
+
151
+ Text in any right-to-left language needs nothing special: Hebrew, Yiddish,
152
+ Arabic, Persian, Urdu, Pashto, Sindhi, Kurdish (Sorani), Uyghur, Syriac,
153
+ Dhivehi (Thaana), N'Ko, Adlam and the rest. `right_to_left!` lays the sheet
154
+ out the way Excel does for them, and `direction: :rtl` fixes a cell's reading
155
+ order instead of letting Excel infer it from its first letter.
156
+
157
+ Not yet: merged cells, frozen panes, streaming for very large sheets.
158
+
159
+ ## Documents: PDF and DOCX
160
+
161
+ One document, two formats:
162
+
163
+ ```ruby
164
+ doc = Imprimatur::Document.new(page_size: :a4, page_numbers: true)
165
+ doc.heading "Quarterly report"
166
+ doc.heading "Sales", level: 2
167
+ doc.paragraph do |p|
168
+ p.text "Revenue was "
169
+ p.text "up 12%", bold: true, color: "#1F7A1F"
170
+ p.text " this quarter."
171
+ end
172
+ doc.paragraph "Figures are unaudited.", italic: true, align: :center
173
+ doc.table [["Region", "Q1"], ["North", 12_500.5]], header: true
174
+ doc.page_break
175
+ doc.paragraph "Appendix."
176
+
177
+ doc.save_pdf("report.pdf")
178
+ doc.save_docx("report.docx")
179
+
180
+ Imprimatur.pdf("memo.pdf") { it.paragraph "Or in one go." } # Imprimatur.docx alike
181
+ ```
182
+
183
+ - Headings 1 to 3 (real Word heading styles in DOCX), paragraphs of runs
184
+ (bold, italic, underline, color), alignment, `\n` line breaks and tabs.
185
+ - Tables: equal columns, borders, a shaded header row that repeats on every
186
+ page; cells are converted with `to_s`.
187
+ - Page breaks, A4 or Letter, `1 / N` page numbers.
188
+ - `direction: :rtl` makes a right-to-left paragraph (right aligned, and
189
+ `w:bidi` in DOCX).
190
+
191
+ **PDF text is Latin only for now.** PDFs use the standard Helvetica fonts,
192
+ which carry Windows-1252: English and Western European text, curly quotes,
193
+ dashes, the euro sign. Anything else (Arabic, Hebrew, Greek, CJK, emoji) raises
194
+ `Imprimatur::Error` naming the character, never a `?`. DOCX carries any
195
+ script. Embedded TrueType fonts, and with them every script and proper RTL in
196
+ PDF, are the next step.
197
+
198
+ ## Reading: documents as Markdown
199
+
200
+ The other direction: a PDF, XLSX or DOCX printed as Markdown, for search
201
+ indexes and language models.
202
+
203
+ ```ruby
204
+ Imprimatur.markdown("audit.pdf") # a path
205
+ Imprimatur.markdown(upload.download) # bytes: the format is in the content
206
+ Imprimatur.markdown(File.open("sales.xlsx")) # an IO
207
+
208
+ begin
209
+ Imprimatur.markdown(scan)
210
+ rescue Imprimatur::NoTextLayer
211
+ # scanned pages: images, which need OCR
212
+ end
213
+ ```
214
+
215
+ - **PDF**: the text layer, placed glyph by glyph (fonts, encodings and
216
+ ToUnicode maps, forms, rotated pages), in reading order (right-to-left
217
+ lines too: Hebrew, Arabic, Persian, Urdu, Syriac, Dhivehi, N'Ko, Adlam,
218
+ their vowel marks kept on their letters), with headings by font size, hyphenated line breaks rejoined,
219
+ presentation forms (`fi`, Arabic contextual forms) as plain letters, and
220
+ running headers and footers left out. Pages after the first start with
221
+ `<!-- page N -->`. Encrypted PDFs open when the user password is empty, as
222
+ it is for documents that only restrict printing or copying (RC4, AES-128,
223
+ AES-256).
224
+ - **XLSX**: each sheet as a table under its name, values as Excel shows them
225
+ (dates as dates, percentages, 15 significant digits), formulas without a
226
+ cached result as `=FORMULA`, then the sheet's comments.
227
+ - **DOCX**: headings (from styles and outline levels), bulleted and numbered
228
+ lists, bold, italic, links, tables, text boxes; deleted text and field
229
+ codes left out.
230
+
231
+ Escaping is minimal: only what would break a table (`|`, line breaks). The
232
+ text is meant to be searched and read, and `snake\_case` would be noise.
233
+
234
+ Documents are untrusted input, so reads are bounded: `max_bytes:` (512 MiB),
235
+ `max_inflated:` (1 GiB of decompressed data, zip bombs included) and
236
+ `timeout:` (60 seconds), plus a nesting limit; past one,
237
+ `Imprimatur::LimitExceeded`. Reading runs without the GVL: other threads and
238
+ fibers keep running, and `Timeout` or `Thread#raise` cancels it.
239
+ `Imprimatur::PasswordRequired` and `Imprimatur::UnsupportedDocument` (legacy
240
+ `.xls` and `.doc`, `.xlsb`, presentations) are the other errors to expect.
241
+
242
+ ### Structure and images
243
+
244
+ Markdown is one rendering of what a read finds. `Imprimatur.content` gives
245
+ the structure itself, the same as `imprimatur --json md` prints: a PDF's
246
+ pages of headings and paragraphs with their position, a workbook's typed
247
+ cells and comments, a Word document's blocks with their bold, italic and
248
+ linked stretches.
249
+
250
+ ```ruby
251
+ sheet = Imprimatur.content("sales.xlsx")[:sheets].first
252
+ sheet[:rows][1][:cells]
253
+ # => [{cell: "A2", type: "text", value: "North"},
254
+ # {cell: "B2", type: "number", value: 12.5, text: "12.5"}]
255
+
256
+ Imprimatur.content("audit.pdf")[:pages].first[:blocks].first
257
+ # => {type: "heading", level: 1, text: "Audit report",
258
+ # bounds: {left: 72.0, top: 61.4, right: 290.1, bottom: 85.0}}
259
+ ```
260
+
261
+ `Imprimatur.images` hands over every image as a file: a PDF's JPEG and JPEG
262
+ 2000 as stored, fax scans as TIFF, raw pixels as PNG (soft masks as alpha),
263
+ with the page and place each is drawn at; a Word or Excel document's media,
264
+ with the sheet that shows each. A scanned PDF gives its pages, ready for
265
+ OCR.
266
+
267
+ ```ruby
268
+ Imprimatur.images("scan.pdf") do |image|
269
+ File.binwrite("page-#{image.page}.#{image.format}", image.data)
270
+ end
271
+ ```
272
+
273
+ ## Command line
274
+
275
+ The same reader as a static binary, for scripts and agents that would
276
+ otherwise reach for Python to read a PDF:
277
+
278
+ ```sh
279
+ cargo install imprimatur-cli
280
+ imprimatur md audit.pdf sales.xlsx # Markdown on stdout
281
+ imprimatur --json md sales.xlsx # the structure: typed cells, blocks, positions
282
+ imprimatur grep -i 'gots' certs/*.pdf # matches by page, cell or section
283
+ imprimatur images -o figures/ report.pdf # every image, as a file
284
+ ```
285
+
286
+ See [`ext/imprimatur_native/cli`](ext/imprimatur_native/cli/README.md).
287
+
288
+ ## Core library
289
+
290
+ The core works without Ruby too; see
291
+ [`ext/imprimatur_native/core`](ext/imprimatur_native/core/README.md).
292
+
293
+ ## Development
294
+
295
+ ```sh
296
+ bundle install
297
+ rake test:all # cargo test, then the Ruby suite on the compiled extension
298
+ rake lint # dictator, cargo fmt --check, clippy
299
+ rake gems:native # the precompiled gem for this machine, in pkg/
300
+ ```
301
+
302
+ Releases come from release-please: merging its release PR tags `vX.Y.Z`,
303
+ bumps the gem and the crates together, and the release workflow attaches the
304
+ source gem and every platform gem to the GitHub release. Publishing is local:
305
+
306
+ ```sh
307
+ rake gems:pull_all # the release's gems into pkg/, every platform checked
308
+ rake gems:push_all # to rubygems.org, skipping platforms already published
309
+ ```
310
+
311
+ ## License
312
+
313
+ BSD 3-Clause. The PDF font metrics in `pdf/metrics.rs` are derived from Adobe's Core 14
314
+ AFM files, under the notice reproduced in that file.
@@ -0,0 +1,30 @@
1
+ [package]
2
+ name = "imprimatur-cli"
3
+ version = "0.1.0"
4
+ description = "Read PDF, XLSX and DOCX documents as Markdown, from the command line."
5
+ keywords = ["pdf", "xlsx", "docx", "markdown", "cli"]
6
+ categories = ["command-line-utilities", "text-processing"]
7
+ readme = "README.md"
8
+ edition.workspace = true
9
+ rust-version.workspace = true
10
+ authors.workspace = true
11
+ license.workspace = true
12
+ repository.workspace = true
13
+
14
+ [[bin]]
15
+ name = "imprimatur"
16
+ path = "src/main.rs"
17
+
18
+ [lints]
19
+ workspace = true
20
+
21
+ [dependencies]
22
+ imprimatur = { workspace = true, features = ["json"] }
23
+ regex.workspace = true
24
+ serde_json.workspace = true
25
+ usage-rs = { workspace = true, features = ["completions"] }
26
+
27
+ [dev-dependencies]
28
+ tempfile.workspace = true
29
+ usage-parser.workspace = true
30
+ usage-rs = { workspace = true, features = ["completions", "test"] }
@@ -0,0 +1,183 @@
1
+ //! What every command shares: the output mode, reading a document, writing
2
+ //! to standard output, and the exit status each kind of failure maps to.
3
+
4
+ use std::borrow::Cow;
5
+ use std::collections::HashSet;
6
+ use std::fs;
7
+ use std::io::{self, Read, StdoutLock, Write};
8
+ use std::path::{Path, PathBuf};
9
+ use std::process::ExitCode;
10
+
11
+ use imprimatur::read::{Content, Limits};
12
+
13
+ /// The context handed to commands.
14
+ #[derive(Debug, Clone, Copy)]
15
+ pub struct App {
16
+ /// One JSON object per document instead of text.
17
+ pub json: bool,
18
+ }
19
+
20
+ /// How a document failed, as scripts branch on it: exit status and the
21
+ /// name `--json` reports.
22
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23
+ pub enum Failure {
24
+ Damaged,
25
+ NoTextLayer,
26
+ PasswordRequired,
27
+ Unsupported,
28
+ LimitExceeded,
29
+ }
30
+
31
+ impl Failure {
32
+ pub const fn of(error: &imprimatur::Error) -> Self {
33
+ match error {
34
+ imprimatur::Error::NoTextLayer => Self::NoTextLayer,
35
+ imprimatur::Error::PasswordRequired => Self::PasswordRequired,
36
+ imprimatur::Error::UnsupportedDocument(_) => Self::Unsupported,
37
+ imprimatur::Error::LimitExceeded(_) | imprimatur::Error::Cancelled => {
38
+ Self::LimitExceeded
39
+ }
40
+ _ => Self::Damaged,
41
+ }
42
+ }
43
+
44
+ /// The exit status, as `imprimatur --help` lists them.
45
+ pub const fn code(self) -> u8 {
46
+ match self {
47
+ Self::Damaged => 1,
48
+ Self::NoTextLayer => 3,
49
+ Self::PasswordRequired => 4,
50
+ Self::Unsupported => 5,
51
+ Self::LimitExceeded => 6,
52
+ }
53
+ }
54
+
55
+ pub const fn name(self) -> &'static str {
56
+ match self {
57
+ Self::Damaged => "damaged",
58
+ Self::NoTextLayer => "no_text_layer",
59
+ Self::PasswordRequired => "password_required",
60
+ Self::Unsupported => "unsupported",
61
+ Self::LimitExceeded => "limit_exceeded",
62
+ }
63
+ }
64
+ }
65
+
66
+ /// The exit status of a run: success, or the first failure met.
67
+ #[derive(Debug, Default, Clone, Copy)]
68
+ pub struct Status(Option<Failure>);
69
+
70
+ impl Status {
71
+ pub fn record(&mut self, failure: Failure) {
72
+ self.0.get_or_insert(failure);
73
+ }
74
+
75
+ pub fn exit_code(self) -> ExitCode {
76
+ self.0.map_or(ExitCode::SUCCESS, |failure| ExitCode::from(failure.code()))
77
+ }
78
+ }
79
+
80
+ /// The document's bytes: the file, or standard input for `-`.
81
+ pub fn read(file: &Path) -> imprimatur::Result<Vec<u8>> {
82
+ if file.as_os_str() == "-" {
83
+ let mut document = Vec::new();
84
+ io::stdin().lock().read_to_end(&mut document)?;
85
+ return Ok(document);
86
+ }
87
+ Ok(fs::read(file)?)
88
+ }
89
+
90
+ /// A document's content: the file read, then the document.
91
+ pub fn content(file: &Path, limits: &Limits) -> imprimatur::Result<Content> {
92
+ read(file).and_then(|document| imprimatur::read::content(&document, limits))
93
+ }
94
+
95
+ /// What a document's outputs are named after: its file's stem, `stdin`
96
+ /// for `-`.
97
+ pub fn stem(file: &Path) -> Cow<'_, str> {
98
+ if file.as_os_str() == "-" {
99
+ return "stdin".into();
100
+ }
101
+ file.file_stem().map_or_else(|| "document".into(), |stem| stem.to_string_lossy())
102
+ }
103
+
104
+ /// `DIRECTORY/BASE.EXTENSION`, or `BASE-2`, `BASE-3`... when this run
105
+ /// already wrote that path.
106
+ pub fn unique_path(
107
+ directory: &Path,
108
+ base: &str,
109
+ extension: &str,
110
+ written: &mut HashSet<PathBuf>,
111
+ ) -> PathBuf {
112
+ let mut target = directory.join(format!("{base}.{extension}"));
113
+ let mut number = 2;
114
+ while !written.insert(target.clone()) {
115
+ target = directory.join(format!("{base}-{number}.{extension}"));
116
+ number += 1;
117
+ }
118
+ target
119
+ }
120
+
121
+ /// Creates the directory outputs go to, or reports why it cannot be and
122
+ /// gives the exit status.
123
+ pub fn create_dir(directory: &Path) -> Result<(), ExitCode> {
124
+ fs::create_dir_all(directory).map_err(|error| {
125
+ eprintln!("imprimatur: {}: {error}", directory.display());
126
+ ExitCode::from(Failure::Damaged.code())
127
+ })
128
+ }
129
+
130
+ /// Standard output. A closed pipe (`| head`) is not an error: writes after
131
+ /// it report `false`, and the command stops quietly.
132
+ pub struct Out {
133
+ stdout: StdoutLock<'static>,
134
+ }
135
+
136
+ impl Out {
137
+ pub fn new() -> Self {
138
+ Self { stdout: io::stdout().lock() }
139
+ }
140
+
141
+ /// Whether the text could be written.
142
+ pub fn write(&mut self, text: &str) -> bool {
143
+ self.stdout.write_all(text.as_bytes()).is_ok()
144
+ }
145
+
146
+ /// One JSON value on a line.
147
+ pub fn json(&mut self, value: &serde_json::Value) -> bool {
148
+ let mut line = value.to_string();
149
+ line.push('\n');
150
+ self.write(&line)
151
+ }
152
+ }
153
+
154
+ impl Drop for Out {
155
+ fn drop(&mut self) {
156
+ let _ = self.stdout.flush(); // Nothing left to tell a closed pipe.
157
+ }
158
+ }
159
+
160
+ impl App {
161
+ /// Reports a document that failed: on standard error, or as a JSON line
162
+ /// with `--json`. Returns the failure, and whether output still works.
163
+ pub fn report_failure(
164
+ self,
165
+ out: &mut Out,
166
+ file: &Path,
167
+ error: &imprimatur::Error,
168
+ ) -> (Failure, bool) {
169
+ let failure = Failure::of(error);
170
+ let open = if self.json {
171
+ out.json(&serde_json::json!({
172
+ "file": file.to_string_lossy(),
173
+ "ok": false,
174
+ "error": failure.name(),
175
+ "message": error.to_string(),
176
+ }))
177
+ } else {
178
+ eprintln!("imprimatur: {}: {error}", file.display());
179
+ true
180
+ };
181
+ (failure, open)
182
+ }
183
+ }
@@ -0,0 +1,26 @@
1
+ use std::process::ExitCode;
2
+
3
+ use usage_rs::complete::Shell;
4
+ use usage_rs::{Args, Run};
5
+
6
+ /// Print a shell completion script
7
+ #[derive(Args, Debug)]
8
+ pub struct Completion {
9
+ /// Shell to generate for
10
+ #[usage(arg, choices("bash", "zsh", "fish", "elvish", "nu", "powershell"))]
11
+ pub shell: String,
12
+ }
13
+
14
+ impl Run for Completion {
15
+ type Output = ExitCode;
16
+
17
+ fn run(self) -> ExitCode {
18
+ // The choices above are the names Shell::from_name knows.
19
+ let Some(shell) = Shell::from_name(&self.shell) else {
20
+ eprintln!("imprimatur: unsupported shell: {}", self.shell);
21
+ return ExitCode::from(2);
22
+ };
23
+ print!("{}", super::Imprimatur::completion_script(shell));
24
+ ExitCode::SUCCESS
25
+ }
26
+ }