imprimatur 0.1.0-aarch64-linux-gnu

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 96b43fb07eaa3f15a32bc14ecfce9131ae1391369cec190e8b4458b56f40e14f
4
+ data.tar.gz: fd7e35e9d7d87c1523cb563acd16b189a3168e816daeac1495bd507e0e4d6235
5
+ SHA512:
6
+ metadata.gz: 5a5443f9a772157fea30caaa758cec11fdeaa42a262c7b0fe0f260d67d5cd0892ab96e02cb16bb69e5bd1316d7b5acf7f8aaebba01fcba8a40b5292f4f33f657
7
+ data.tar.gz: e02d379d59b7682b24f86f51408f3f1a932b2f49579b57959cd3e8cc8954f4141533f0d46727c8887bb6e832c1ad1d5f56d1af3b745e2435a73d6fa76c1c428b
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * extract from forgejo ([d6c0937](https://github.com/seuros/imprimatur/commit/d6c0937b8cbdda8f6770f43e0423ef6aa4c8c646))
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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ # A flowing document (headings, paragraphs, tables, page breaks) that
5
+ # renders to PDF and DOCX from the same content.
6
+ #
7
+ # doc = Imprimatur::Document.new(page_numbers: true)
8
+ # doc.heading 'Quarterly report'
9
+ # doc.paragraph do |p|
10
+ # p.text 'Revenue was '
11
+ # p.text 'up 12%', bold: true, color: '#1F7A1F'
12
+ # p.text ' this quarter.'
13
+ # end
14
+ # doc.table [%w[Region Q1], ['North', 12.5]], header: true
15
+ # doc.save_pdf('report.pdf')
16
+ # doc.save_docx('report.docx')
17
+ #
18
+ # PDF uses the standard Helvetica fonts for now: Windows-1252 (Latin) text
19
+ # only, anything else raises {Imprimatur::Error}. DOCX carries any script.
20
+ class Document
21
+ # @param page_size [:a4, :letter]
22
+ # @param page_numbers [Boolean] number pages "1 / N" in the footer
23
+ # @raise [ArgumentError] an unknown page size
24
+ def initialize(page_size: :a4, page_numbers: false)
25
+ @native = Native::Document.new
26
+ @native.set_page(page_size.to_s, page_numbers ? true : false)
27
+ end
28
+
29
+ # @param text [String]
30
+ # @param level [Integer] 1 (the title) to 3
31
+ # @return [self]
32
+ # @raise [Imprimatur::Error] a level out of range, or a control character
33
+ def heading(text, level: 1)
34
+ @native.heading(level, String(text))
35
+ self
36
+ end
37
+
38
+ # A paragraph, from plain text or a block of runs. +\n+ breaks the line,
39
+ # +\t+ is a tab.
40
+ #
41
+ # @param text [String, nil] plain text, styled by the keywords below
42
+ # @param align [:left, :center, :right, nil]
43
+ # @param direction [:ltr, :rtl, nil] right to left aligns right unless told otherwise
44
+ # @yieldparam paragraph [Paragraph] add runs with {Paragraph#text}
45
+ # @return [self]
46
+ def paragraph(text = nil, align: nil, direction: nil, **look)
47
+ paragraph = Paragraph.new
48
+ paragraph.text(text, **look) if text
49
+ yield paragraph if block_given?
50
+ @native.paragraph(paragraph.runs, align&.to_s, direction&.to_s)
51
+ self
52
+ end
53
+
54
+ # @param rows [Array<Array>] cells are converted with +to_s+; nil is empty
55
+ # @param header [Boolean] the first row is a header: bold, shaded, repeated on every page
56
+ # @return [self]
57
+ # @raise [Imprimatur::Error] no rows or no columns
58
+ def table(rows, header: false)
59
+ @native.table(rows.map { |row| row.map { it.nil? ? '' : it.to_s } }, header ? true : false)
60
+ self
61
+ end
62
+
63
+ # @return [self]
64
+ def page_break
65
+ @native.page_break
66
+ self
67
+ end
68
+
69
+ # @return [String] the PDF bytes (binary)
70
+ # @raise [Imprimatur::Error] text the standard fonts cannot encode, or a table row taller than a page
71
+ def to_pdf = @native.render('pdf')
72
+
73
+ # @return [String] the DOCX bytes (binary)
74
+ def to_docx = @native.render('docx')
75
+
76
+ # @param path [String, Pathname]
77
+ # @return [self]
78
+ def save_pdf(path)
79
+ @native.save('pdf', File.expand_path(path))
80
+ self
81
+ end
82
+
83
+ # @param path [String, Pathname]
84
+ # @return [self]
85
+ def save_docx(path)
86
+ @native.save('docx', File.expand_path(path))
87
+ self
88
+ end
89
+
90
+ # @api private
91
+ def self.build(format, path, **options)
92
+ document = new(**options)
93
+ yield document
94
+ return document.public_send(:"to_#{format}") unless path
95
+
96
+ document.public_send(:"save_#{format}", path)
97
+ path
98
+ end
99
+ end
100
+
101
+ # Runs of text that make up one {Document#paragraph}.
102
+ class Paragraph
103
+ # @api private
104
+ attr_reader :runs
105
+
106
+ def initialize
107
+ @runs = []
108
+ end
109
+
110
+ # @param text [String]
111
+ # @param bold [Boolean]
112
+ # @param italic [Boolean]
113
+ # @param underline [Boolean]
114
+ # @param color [String, nil] "#RRGGBB"
115
+ # @return [self]
116
+ def text(text, bold: false, italic: false, underline: false, color: nil)
117
+ @runs << [String(text), bold ? true : false, italic ? true : false, underline ? true : false, color&.to_s]
118
+ self
119
+ end
120
+
121
+ # @return [self]
122
+ def bold(text) = text(text, bold: true)
123
+
124
+ # @return [self]
125
+ def italic(text) = text(text, italic: true)
126
+ end
127
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ # A document Excel (or Word, or a PDF reader) would reject: an invalid or
5
+ # duplicate sheet name, a cell out of range, text over a limit, NaN. Also
6
+ # a document {Imprimatur.markdown} cannot read. Raised by the native core.
7
+ class Error < StandardError; end
8
+
9
+ # A PDF without a text layer: its pages are images (scans), which need OCR.
10
+ class NoTextLayer < Error; end
11
+
12
+ # A PDF encrypted with a user password; only an empty one is tried.
13
+ class PasswordRequired < Error; end
14
+
15
+ # A format {Imprimatur.markdown} does not read: legacy +.xls+ and +.doc+,
16
+ # binary +.xlsb+, presentations, anything not a PDF, XLSX or DOCX.
17
+ class UnsupportedDocument < Error; end
18
+
19
+ # The read hit one of its limits (size, decompressed size, nesting, time),
20
+ # or was cancelled.
21
+ class LimitExceeded < Error; end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ # A cell formula such as +SUM(B2:B9)+, with or without the leading +=+.
5
+ # Excel computes it when the workbook opens.
6
+ Formula = Data.define(:expression) do
7
+ def initialize(expression:)
8
+ raise TypeError, "formula expression must be a String, got #{expression.class}" unless expression.is_a?(String)
9
+
10
+ super
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # imprimatur is native only: there is no pure Ruby engine to fall back to.
4
+ #
5
+ # Platform gems ship the binary under the Ruby ABI version; a dev build (rake
6
+ # compile) or a source install leaves it next to this file.
7
+ shipped = File.join(__dir__, RUBY_VERSION[/\A\d+\.\d+/], "imprimatur_native.#{RbConfig::CONFIG['DLEXT']}")
8
+ begin
9
+ if File.exist?(shipped)
10
+ require shipped
11
+ else
12
+ require 'imprimatur/imprimatur_native'
13
+ end
14
+ rescue LoadError => e
15
+ raise LoadError, "imprimatur: the native extension did not load (#{e.message}). " \
16
+ 'Install a platform gem, or build it with `rake compile` (toolchain: https://rustup.rs).'
17
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Imprimatur
6
+ # An image found in a document, as a file: {#data} is what a file of
7
+ # {#format} holds. On a PDF page, {#page} and {#bounds} (points from the
8
+ # page's top-left corner) say where; in a Word or Excel package, {#part}
9
+ # names the medium, and {#sheet} the sheet that shows it.
10
+ Image = Data.define(:data, :format, :width, :height, :page, :bounds, :part, :sheet)
11
+
12
+ # Documents in: Markdown, their structure, their images. See
13
+ # {Imprimatur.markdown}, {Imprimatur.content} and {Imprimatur.images}.
14
+ module Reader
15
+ # File signatures a document's bytes start with; a String that starts
16
+ # with neither is taken for a path.
17
+ SIGNATURES = ["%PDF-".b, "PK\x03\x04".b, "PK\x05\x06".b].freeze
18
+ private_constant :SIGNATURES
19
+
20
+ # @see Imprimatur.markdown
21
+ def self.markdown(document, **) = Native.markdown(read(document), *limits(**))
22
+
23
+ # @see Imprimatur.content
24
+ def self.content(document, **)
25
+ JSON.parse(Native.content(read(document), *limits(**)), symbolize_names: true)
26
+ end
27
+
28
+ # @see Imprimatur.images
29
+ def self.images(document, **)
30
+ Native.images(read(document), *limits(**)).map do |data, description|
31
+ found = JSON.parse(description, symbolize_names: true)
32
+ Image.new(data:, **Image.members.drop(1).to_h { [it, found[it]] })
33
+ end
34
+ end
35
+
36
+ # @api private
37
+ def self.limits(timeout: 60, max_bytes: nil, max_inflated: nil)
38
+ [max_bytes, max_inflated, timeout && Float(timeout)]
39
+ end
40
+
41
+ # @api private
42
+ def self.read(document)
43
+ case document
44
+ when Pathname then File.binread(document)
45
+ when String then bytes?(document) ? document : File.binread(document)
46
+ else
47
+ raise TypeError, "expected a path, bytes or an IO, not #{document.class}" unless document.respond_to?(:read)
48
+
49
+ document.read.to_s.b
50
+ end
51
+ end
52
+
53
+ # @api private
54
+ def self.bytes?(string)
55
+ head = string.byteslice(0, 1024).b
56
+ SIGNATURES.any? { head.start_with?(it) } || head.include?("%PDF-") || head.include?("\0")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ # How a cell looks. Built once with {Imprimatur.style}, validated then, and
5
+ # shared by every cell that uses it. The class itself is native.
6
+ class Style
7
+ # @param number_format [String, nil] an Excel format code: "0.00", "#,##0", "0%", "yyyy-mm-dd"
8
+ # @param bold [Boolean]
9
+ # @param italic [Boolean]
10
+ # @param underline [Boolean]
11
+ # @param color [String, nil] font color, "#RRGGBB"
12
+ # @param fill [String, nil] background color, "#RRGGBB"
13
+ # @param align [:left, :center, :right, nil]
14
+ # @param direction [:ltr, :rtl, nil] reading order; without one Excel infers it from the text
15
+ # @param wrap [Boolean] wrap text within the column width
16
+ # @param border [:thin, :medium, :thick, nil] on all four sides
17
+ # @param font [String, nil] typeface, Calibri by default: "Arial"
18
+ # @param size [Numeric, nil] in points, 1 to 409 in steps of 0.5; 11 by default
19
+ # @param valign [:top, :center, :bottom, nil] vertical alignment; Excel's default is the bottom
20
+ # @return [Style]
21
+ # @raise [Imprimatur::Error] an invalid number format, color, font or size
22
+ # @raise [ArgumentError] an unknown align, direction, border or valign
23
+ def self.build(number_format: nil, bold: false, italic: false, underline: false, color: nil, fill: nil,
24
+ align: nil, direction: nil, wrap: false, border: nil, font: nil, size: nil, valign: nil)
25
+ __build(number_format&.to_s, bold, italic, underline, color&.to_s, fill&.to_s,
26
+ align&.to_s, direction&.to_s, wrap, border&.to_s, font&.to_s, size && Float(size), valign&.to_s)
27
+ end
28
+
29
+ private_class_method :__build
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ # A data validation rule: a drop-down list, or a formula a value must
5
+ # satisfy. Built once with {Imprimatur.validation}, validated then, and
6
+ # applied to any number of ranges with {Xlsx::Sheet#validate}. The class
7
+ # itself is native.
8
+ class Validation
9
+ # @param list [Array<#to_s>, String, nil] the drop-down's values, or the
10
+ # range holding them: "Units!$A$2:$A$20"
11
+ # @param formula [String, nil] true for a valid value; references are
12
+ # relative to the top-left cell of the range: "LEN(B2)<=100"
13
+ # @param allow_blank [Boolean] whether an empty cell passes
14
+ # @param dropdown [Boolean] whether a list shows its in-cell arrow
15
+ # @param alert [:stop, :warning, :information, :off] what an invalid
16
+ # value meets: refused, confirmed, accepted with a note, or accepted
17
+ # @param prompt [String, nil] shown while a cell of the range is selected
18
+ # @param prompt_title [String, nil] up to 32 characters
19
+ # @param error [String, nil] the alert's message, up to 255 characters
20
+ # @param error_title [String, nil] up to 32 characters
21
+ # @return [Validation]
22
+ # @raise [ArgumentError] not exactly one of +list:+ and +formula:+, or an unknown alert
23
+ # @raise [Imprimatur::Error] a rule or text Excel would refuse
24
+ def self.build(list: nil, formula: nil, allow_blank: true, dropdown: true, alert: :stop,
25
+ prompt: nil, prompt_title: nil, error: nil, error_title: nil)
26
+ raise ArgumentError, 'a validation takes one of list: or formula:' unless list.nil? ^ formula.nil?
27
+
28
+ values, source = list.is_a?(Array) ? [list.map(&:to_s), nil] : [nil, list&.to_s]
29
+ __build(values, source, formula&.to_s, allow_blank ? true : false, dropdown ? true : false,
30
+ alert&.to_s, [prompt_title.to_s, prompt.to_s], [error_title.to_s, error.to_s])
31
+ end
32
+
33
+ private_class_method :__build
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imprimatur
4
+ # Excel workbooks (+.xlsx+).
5
+ #
6
+ # header = Imprimatur.style(bold: true, fill: '#1F4E79', color: '#FFFFFF')
7
+ # money = Imprimatur.style(number_format: '#,##0.00')
8
+ #
9
+ # Imprimatur.xlsx('report.xlsx') do |book|
10
+ # book.sheet('Sales') do |sheet|
11
+ # sheet.row 'Region', 'Closed', 'Amount', style: header
12
+ # sheet.row 'North', Date.today, 12.5
13
+ # sheet.write(2, 2, Imprimatur.formula('SUM(C2:C2)'), style: money)
14
+ # sheet.column_widths 18, 12, 14
15
+ # end
16
+ # end
17
+ #
18
+ # Cells take String, Integer (up to 2**53), Float, true, false, {Formula},
19
+ # Time, Date and DateTime (their wall-clock fields; no time zone); +nil+
20
+ # leaves a cell empty.
21
+ module Xlsx
22
+ # Builds a workbook. With a path, saves it there and returns the path;
23
+ # without, returns the +.xlsx+ bytes as a binary String.
24
+ #
25
+ # @yieldparam book [Workbook]
26
+ # @raise [Imprimatur::Error] the workbook has no sheets, or the file cannot be written
27
+ def self.build(path = nil)
28
+ book = Workbook.new
29
+ yield book
30
+ return book.render unless path
31
+
32
+ book.save(path)
33
+ path
34
+ end
35
+
36
+ # An ordered set of uniquely named sheets.
37
+ class Workbook
38
+ def initialize
39
+ @native = Native::Workbook.new
40
+ @sheets = []
41
+ end
42
+
43
+ # @return [Array<Sheet>]
44
+ attr_reader :sheets
45
+
46
+ # Adds a sheet after the existing ones.
47
+ #
48
+ # @param name [String] up to 31 characters, unique ignoring case
49
+ # @yieldparam sheet [Sheet]
50
+ # @return [Sheet]
51
+ # @raise [Imprimatur::Error] the name is invalid or taken
52
+ def sheet(name)
53
+ sheet = Sheet.new(@native, @native.add_sheet(String(name)), name)
54
+ @sheets << sheet
55
+ yield sheet if block_given?
56
+ sheet
57
+ end
58
+
59
+ # @return [String] the +.xlsx+ bytes (binary)
60
+ def render = @native.render
61
+
62
+ # Writes the workbook to +path+, replacing any file there.
63
+ #
64
+ # @param path [String, Pathname]
65
+ def save(path)
66
+ @native.save(File.expand_path(path))
67
+ self
68
+ end
69
+ end
70
+
71
+ # One worksheet. Rows and columns are zero-based.
72
+ class Sheet
73
+ # @return [String]
74
+ attr_reader :name
75
+
76
+ # @api private
77
+ def initialize(native, index, name)
78
+ @native = native
79
+ @index = index
80
+ @name = name
81
+ end
82
+
83
+ # Appends a row below the last row written, from column A.
84
+ #
85
+ # @param cells [Array] values; +nil+ leaves a cell empty
86
+ # @param style [Style, Array<Style, nil>, nil] applied to every cell
87
+ # written, or one per column from A; columns past the Array's end
88
+ # are left unstyled
89
+ # @return [self]
90
+ # @raise [TypeError] a value of an unsupported type
91
+ # @raise [RangeError] an Integer beyond 2**53
92
+ # @raise [ArgumentError] more styles than cells
93
+ # @raise [Imprimatur::Error] the row is invalid; nothing is written
94
+ def row(*cells, style: nil)
95
+ @native.append_row(@index, cells, style)
96
+ self
97
+ end
98
+
99
+ # Appends an Array as a row: +sheet << ['North', 12.5]+.
100
+ #
101
+ # @param cells [Array]
102
+ # @return [self]
103
+ def <<(cells) = row(*cells)
104
+
105
+ # Sets one cell, replacing what was there.
106
+ #
107
+ # @param row [Integer] zero-based
108
+ # @param column [Integer] zero-based
109
+ # @param style [Style, nil]
110
+ # @return [self]
111
+ def write(row, column, value, style: nil)
112
+ @native.write([@index, row, column], value, style)
113
+ self
114
+ end
115
+
116
+ # Sets one column's width, in characters of the default font (0 to 255).
117
+ #
118
+ # @param column [Integer] zero-based
119
+ # @param width [Numeric]
120
+ # @return [self]
121
+ def column_width(column, width)
122
+ @native.set_column_width(@index, column, Float(width))
123
+ self
124
+ end
125
+
126
+ # Sets widths from column A on; +nil+ skips a column.
127
+ #
128
+ # @param widths [Array<Numeric, nil>]
129
+ # @return [self]
130
+ def column_widths(*widths)
131
+ widths.each_with_index { |width, column| column_width(column, width) if width }
132
+ self
133
+ end
134
+
135
+ # Applies a data validation to every cell of a range, written or not:
136
+ #
137
+ # sheet.validate 'G2:G1000', list: %w[Yes No]
138
+ # sheet.validate 'F2:F1000', list: 'Connections!$A$2:$A$1000', error: 'Pick a connection.'
139
+ # sheet.validate 'B2:B1000', units # a Validation built once, shared
140
+ #
141
+ # @param range [String] an A1 range, "B2:B100", or a cell, "B2"
142
+ # @param validation [Validation, nil] a rule built with {Imprimatur.validation}
143
+ # @param options [Hash] or the options to build one, see {Validation.build}
144
+ # @return [self]
145
+ # @raise [Imprimatur::Error] an invalid range, or one overlapping a range
146
+ # already validated (Excel applies a single rule per cell)
147
+ def validate(range, validation = nil, **options)
148
+ raise ArgumentError, 'pass a Validation or its options, not both' if validation && options.any?
149
+
150
+ validation ||= Validation.build(**options)
151
+ @native.add_validation(@index, String(range), validation)
152
+ self
153
+ end
154
+
155
+ # Attaches a comment (an Excel note) to a cell, replacing any it had.
156
+ # The cell need not hold a value.
157
+ #
158
+ # @param reference [String] an A1 reference: "C1"
159
+ # @param text [String] up to 32,767 characters; newlines are kept
160
+ # @param author [String, nil]
161
+ # @param visible [Boolean] shown all the time, not only on hover
162
+ # @return [self]
163
+ # @raise [Imprimatur::Error] an invalid reference or text too long
164
+ def comment(reference, text, author: nil, visible: false)
165
+ @native.set_comment([@index, String(reference)], String(text), author&.to_s, visible ? true : false)
166
+ self
167
+ end
168
+
169
+ # Lays the sheet out right to left, column A on the right, as Excel
170
+ # shows sheets in right-to-left languages: Hebrew, Arabic, Persian,
171
+ # Urdu and the rest.
172
+ #
173
+ # @return [self]
174
+ def right_to_left!(enabled = true)
175
+ @native.set_right_to_left(@index, enabled ? true : false)
176
+ self
177
+ end
178
+
179
+ # @return [Integer] the zero-based row the next {#row} writes
180
+ def next_row = @native.next_row(@index)
181
+ end
182
+ end
183
+ end
data/lib/imprimatur.rb ADDED
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'imprimatur/version'
4
+ require_relative 'imprimatur/error'
5
+ require_relative 'imprimatur/formula'
6
+ require_relative 'imprimatur/native'
7
+ require_relative 'imprimatur/style'
8
+ require_relative 'imprimatur/validation'
9
+ require_relative 'imprimatur/xlsx'
10
+ require_relative 'imprimatur/document'
11
+ require_relative 'imprimatur/reader'
12
+
13
+ # Document authoring with a native core: XLSX workbooks, and PDF and DOCX from
14
+ # one document model.
15
+ #
16
+ # Imprimatur.xlsx('report.xlsx') do |book|
17
+ # book.sheet('Sales') { it.row 'Region', 'Q1' }
18
+ # end
19
+ #
20
+ # Imprimatur.pdf('report.pdf', page_numbers: true) do |doc|
21
+ # doc.heading 'Report'
22
+ # doc.paragraph 'It was printed.'
23
+ # end
24
+ module Imprimatur
25
+ class << self
26
+ # @see Xlsx.build
27
+ def xlsx(path = nil, &) = Xlsx.build(path, &)
28
+
29
+ # @param expression [String] such as +"SUM(A1:A3)"+ or +"=A1*2"+
30
+ # @return [Formula]
31
+ def formula(expression) = Formula.new(expression:)
32
+
33
+ # @see Style.build
34
+ def style(**) = Style.build(**)
35
+
36
+ # @see Validation.build
37
+ def validation(**) = Validation.build(**)
38
+
39
+ # Builds a {Document} and renders it as PDF. With a path, saves it there
40
+ # and returns the path; without, returns the bytes.
41
+ #
42
+ # @param options [Hash] +page_size:+ and +page_numbers:+, see {Document#initialize}
43
+ # @yieldparam document [Document]
44
+ def pdf(path = nil, **options, &) = Document.build(:pdf, path, **options, &)
45
+
46
+ # As {.pdf}, rendered as DOCX.
47
+ def docx(path = nil, **options, &) = Document.build(:docx, path, **options, &)
48
+
49
+ # A PDF, XLSX or DOCX as Markdown: a PDF's text layer (headings by font
50
+ # size, running headers and footers left out, pages marked with
51
+ # +<!-- page N -->+), each sheet of a workbook as a table, a Word
52
+ # document's headings, lists, emphasis, links and tables. The format is
53
+ # told by the content, not the name.
54
+ #
55
+ # Reading runs without the GVL: other threads keep running, and
56
+ # +Thread#raise+ or +Timeout+ cancels it.
57
+ #
58
+ # Imprimatur.markdown('audit.pdf')
59
+ # Imprimatur.markdown(upload.download) # bytes
60
+ # Imprimatur.markdown(File.open('sales.xlsx'))
61
+ #
62
+ # @param document [String, Pathname, #read] a path, the document's bytes
63
+ # (told apart by their signature), or an IO
64
+ # @param timeout [Numeric, nil] seconds the read may take (60), +nil+ for none
65
+ # @param max_bytes [Integer, nil] the largest document accepted (512 MiB)
66
+ # @param max_inflated [Integer, nil] decompressed bytes allowed in all (1 GiB)
67
+ # @return [String] Markdown, UTF-8
68
+ # @raise [NoTextLayer] a PDF of scanned pages: run OCR instead
69
+ # @raise [PasswordRequired] a PDF that needs a password to open
70
+ # @raise [UnsupportedDocument] not a PDF, XLSX or DOCX
71
+ # @raise [LimitExceeded] the document is too large, too deep or too slow
72
+ # @raise [Error] a damaged document
73
+ def markdown(document, ...) = Reader.markdown(document, ...)
74
+
75
+ # What {.markdown} renders, as data: the same structure as
76
+ # +imprimatur --json md+ prints. A PDF's +:pages+ of +:blocks+
77
+ # (+heading+, +paragraph+) with their +:bounds+ in points from the
78
+ # page's top-left corner; a workbook's +:sheets+ of +:rows+ of typed
79
+ # +:cells+ (+number+, +date+, +bool+, +text+, +error+, +formula+) and
80
+ # +:comments+; a Word document's +:blocks+ (+heading+, +paragraph+,
81
+ # +list_item+, +table+), their bold, italic and linked +:marks+ as byte
82
+ # ranges. Dates are ISO 8601 strings: Excel's have no time zone.
83
+ #
84
+ # sheet = Imprimatur.content('sales.xlsx')[:sheets].first
85
+ # sheet[:rows][1][:cells] # => [{cell: "A2", type: "text", value: "North"}, ...]
86
+ #
87
+ # Takes the arguments of {.markdown}, and raises as it does.
88
+ #
89
+ # @return [Hash] with symbol keys; +:format+ is +"pdf"+, +"xlsx"+ or +"docx"+
90
+ def content(document, ...) = Reader.content(document, ...)
91
+
92
+ # A document's images, as files: a PDF's, once each, from the first page
93
+ # that draws them (JPEG and JPEG 2000 as stored, fax scans as TIFF, raw
94
+ # pixels as PNG); a Word or Excel document's media. A scanned PDF gives
95
+ # its pages' images, ready for OCR, instead of raising {NoTextLayer}.
96
+ #
97
+ # Imprimatur.images('report.pdf').each do |image|
98
+ # File.binwrite("p#{image.page}.#{image.format}", image.data)
99
+ # end
100
+ #
101
+ # Takes the arguments of {.markdown}, and raises as it does (but for
102
+ # {NoTextLayer}).
103
+ #
104
+ # @yieldparam image [Image] each image, when a block is given
105
+ # @return [Array<Image>]
106
+ def images(document, ...)
107
+ found = Reader.images(document, ...)
108
+ block_given? ? found.each { yield it } : found
109
+ end
110
+ end
111
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imprimatur
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: aarch64-linux-gnu
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'A Ruby DSL over a native core that writes Excel workbooks, and PDF and
13
+ Word documents from one model, with its own XML, zip and PDF writers: no third-party
14
+ format stack. Precompiled for Linux, macOS and FreeBSD; CRuby only.'
15
+ email:
16
+ - terminale@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - lib/imprimatur.rb
25
+ - lib/imprimatur/4.0/imprimatur_native.so
26
+ - lib/imprimatur/document.rb
27
+ - lib/imprimatur/error.rb
28
+ - lib/imprimatur/formula.rb
29
+ - lib/imprimatur/native.rb
30
+ - lib/imprimatur/reader.rb
31
+ - lib/imprimatur/style.rb
32
+ - lib/imprimatur/validation.rb
33
+ - lib/imprimatur/version.rb
34
+ - lib/imprimatur/xlsx.rb
35
+ homepage: https://github.com/seuros/imprimatur
36
+ licenses:
37
+ - BSD-3-Clause
38
+ metadata:
39
+ source_code_uri: https://github.com/seuros/imprimatur
40
+ bug_tracker_uri: https://github.com/seuros/imprimatur/issues
41
+ changelog_uri: https://github.com/seuros/imprimatur/blob/master/CHANGELOG.md
42
+ rubygems_mfa_required: 'true'
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '4.0'
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: 4.1.dev
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 4.0.20
61
+ specification_version: 4
62
+ summary: XLSX, PDF and DOCX authoring with a Ruby DSL. Let it be printed.
63
+ test_files: []