imprimatur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/Cargo.lock +835 -0
- data/Cargo.toml +44 -0
- data/LICENSE.txt +29 -0
- data/README.md +314 -0
- data/ext/imprimatur_native/cli/Cargo.toml +30 -0
- data/ext/imprimatur_native/cli/src/app.rs +183 -0
- data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
- data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
- data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
- data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
- data/ext/imprimatur_native/cli/src/cli.rs +71 -0
- data/ext/imprimatur_native/cli/src/main.rs +15 -0
- data/ext/imprimatur_native/core/Cargo.toml +33 -0
- data/ext/imprimatur_native/core/LICENSE +29 -0
- data/ext/imprimatur_native/core/README.md +105 -0
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
- data/ext/imprimatur_native/core/src/doc.rs +210 -0
- data/ext/imprimatur_native/core/src/docx.rs +343 -0
- data/ext/imprimatur_native/core/src/error.rs +113 -0
- data/ext/imprimatur_native/core/src/lib.rs +38 -0
- data/ext/imprimatur_native/core/src/opc.rs +51 -0
- data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
- data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
- data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
- data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
- data/ext/imprimatur_native/core/src/pdf.rs +84 -0
- data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
- data/ext/imprimatur_native/core/src/read/content.rs +266 -0
- data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
- data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
- data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
- data/ext/imprimatur_native/core/src/read/image.rs +233 -0
- data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
- data/ext/imprimatur_native/core/src/read/json.rs +172 -0
- data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
- data/ext/imprimatur_native/core/src/read/package.rs +191 -0
- data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
- data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
- data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
- data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
- data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
- data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
- data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
- data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
- data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
- data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
- data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
- data/ext/imprimatur_native/core/src/read/span.rs +22 -0
- data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
- data/ext/imprimatur_native/core/src/read.rs +145 -0
- data/ext/imprimatur_native/core/src/style.rs +82 -0
- data/ext/imprimatur_native/core/src/test_support.rs +163 -0
- data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
- data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
- data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
- data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
- data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
- data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
- data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
- data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
- data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
- data/ext/imprimatur_native/core/src/xml.rs +95 -0
- data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
- data/ext/imprimatur_native/core/src/zip.rs +161 -0
- data/ext/imprimatur_native/extconf.rb +45 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
- data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
- data/ext/imprimatur_native/ffi/src/document.rs +138 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
- data/ext/imprimatur_native/ffi/src/read.rs +182 -0
- data/ext/imprimatur_native/ffi/src/style.rs +85 -0
- data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
- data/lib/imprimatur/document.rb +127 -0
- data/lib/imprimatur/error.rb +22 -0
- data/lib/imprimatur/formula.rb +13 -0
- data/lib/imprimatur/native.rb +17 -0
- data/lib/imprimatur/reader.rb +59 -0
- data/lib/imprimatur/style.rb +31 -0
- data/lib/imprimatur/validation.rb +35 -0
- data/lib/imprimatur/version.rb +5 -0
- data/lib/imprimatur/xlsx.rb +183 -0
- data/lib/imprimatur.rb +111 -0
- metadata +157 -0
|
@@ -0,0 +1,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,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
|