receipts 2.4.0 → 3.0.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 +4 -4
- data/.standard.yml +1 -0
- data/CHANGELOG.md +13 -1
- data/Gemfile +0 -1
- data/README.md +29 -10
- data/examples/invoice.pdf +0 -0
- data/examples/receipt.pdf +0 -0
- data/examples/statement.pdf +0 -0
- data/lib/receipts/base.rb +3 -7
- data/lib/receipts/fonts/Inter-Bold.ttf +0 -0
- data/lib/receipts/fonts/Inter-Regular.ttf +0 -0
- data/lib/receipts/fonts/OFL.txt +92 -0
- data/lib/receipts/pdf/document.rb +383 -0
- data/lib/receipts/pdf/font.rb +168 -0
- data/lib/receipts/pdf/geometry.rb +29 -0
- data/lib/receipts/pdf/image.rb +242 -0
- data/lib/receipts/pdf/inline_format.rb +79 -0
- data/lib/receipts/pdf/table.rb +250 -0
- data/lib/receipts/pdf/text_layout.rb +175 -0
- data/lib/receipts/pdf/true_type.rb +387 -0
- data/lib/receipts/pdf/writer.rb +119 -0
- data/lib/receipts/version.rb +1 -1
- data/lib/receipts.rb +9 -2
- metadata +18 -43
- data/receipts.gemspec +0 -33
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
# Simple tables with text cells, borders, padding and automatic column widths.
|
|
4
|
+
#
|
|
5
|
+
# table(data, width: bounds.width, column_widths: [200, nil, 100], header: true,
|
|
6
|
+
# cell_style: {padding: 6, borders: [:bottom], border_color: "eeeeee", inline_format: true}) do
|
|
7
|
+
# row(0).font_style = :bold
|
|
8
|
+
# column(-1).align = :right
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Cells may be strings, nil, or hashes with :content and cell options.
|
|
12
|
+
class Table
|
|
13
|
+
class Cell
|
|
14
|
+
attr_accessor :borders, :border_color, :border_width, :align, :font_style,
|
|
15
|
+
:size, :text_color, :background_color, :inline_format, :font, :leading
|
|
16
|
+
attr_reader :content, :padding
|
|
17
|
+
|
|
18
|
+
def initialize(document, options = {})
|
|
19
|
+
@document = document
|
|
20
|
+
@content = ""
|
|
21
|
+
@padding = [5, 5, 5, 5]
|
|
22
|
+
@borders = [:top, :right, :bottom, :left]
|
|
23
|
+
@border_color = "000000"
|
|
24
|
+
@border_width = 1
|
|
25
|
+
@align = :left
|
|
26
|
+
@inline_format = false
|
|
27
|
+
@leading = 0
|
|
28
|
+
options.each { |key, value| public_send(:"#{key}=", value) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def content=(value)
|
|
32
|
+
@content = value.to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def padding=(value)
|
|
36
|
+
@padding = Geometry.expand_box(value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def horizontal_padding
|
|
40
|
+
@padding[1] + @padding[3]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def vertical_padding
|
|
44
|
+
@padding[0] + @padding[2]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text_options
|
|
48
|
+
{inline_format: @inline_format, style: @font_style, size: @size, color: @text_color, font: @font}.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def natural_width
|
|
52
|
+
measure.first + horizontal_padding
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def minimum_width
|
|
56
|
+
measure.last + horizontal_padding
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def lines(width)
|
|
60
|
+
@document.text_lines(@content, text_options, [width - horizontal_padding, 0].max)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def measure
|
|
66
|
+
@measure ||= @document.measure_text(@content, text_options)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A selection of cells. Assigning an attribute sets it on every cell: `row(0).borders = [:bottom]`
|
|
71
|
+
class Cells < Array
|
|
72
|
+
def style(options)
|
|
73
|
+
options.each { |key, value| public_send(:"#{key}=", value) }
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def method_missing(name, *args, &block)
|
|
78
|
+
if name.to_s.end_with?("=") && Cell.method_defined?(name)
|
|
79
|
+
each { |cell| cell.public_send(name, *args) }
|
|
80
|
+
else
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def respond_to_missing?(name, include_private = false)
|
|
86
|
+
(name.to_s.end_with?("=") && Cell.method_defined?(name)) || super
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def initialize(document, data, options = {}, &block)
|
|
91
|
+
@document = document
|
|
92
|
+
@width = options[:width]
|
|
93
|
+
@column_widths = options[:column_widths]
|
|
94
|
+
@header = options[:header]
|
|
95
|
+
@position = options.fetch(:position, :left)
|
|
96
|
+
cell_style = options.fetch(:cell_style, {})
|
|
97
|
+
|
|
98
|
+
columns = data.map { |row| Array(row).size }.max || 0
|
|
99
|
+
@rows = data.map do |row|
|
|
100
|
+
row = Array(row)
|
|
101
|
+
Array.new(columns) do |index|
|
|
102
|
+
value = row[index]
|
|
103
|
+
Cell.new(document, cell_style.merge(value.is_a?(Hash) ? value : {content: value}))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
return unless block
|
|
108
|
+
(block.arity == 1) ? block.call(self) : instance_exec(&block)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cells
|
|
112
|
+
Cells.new(@rows.flatten)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def row(selector)
|
|
116
|
+
Cells.new(select(@rows, selector).flatten)
|
|
117
|
+
end
|
|
118
|
+
alias_method :rows, :row
|
|
119
|
+
|
|
120
|
+
def column(selector)
|
|
121
|
+
Cells.new(@rows.flat_map { |row| select(row, selector) })
|
|
122
|
+
end
|
|
123
|
+
alias_method :columns, :column
|
|
124
|
+
|
|
125
|
+
def column_widths
|
|
126
|
+
@computed_column_widths ||= compute_column_widths
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def draw
|
|
130
|
+
widths = column_widths
|
|
131
|
+
bounds = @document.bounds
|
|
132
|
+
x = bounds.absolute_left + Geometry.align_offset(@position, bounds.width, widths.sum)
|
|
133
|
+
|
|
134
|
+
header_count = case @header
|
|
135
|
+
when true then 1
|
|
136
|
+
when Integer then @header
|
|
137
|
+
else 0
|
|
138
|
+
end
|
|
139
|
+
headers = @rows.first(header_count).map { |row| [row, *layout_row(row, widths)] }
|
|
140
|
+
|
|
141
|
+
@rows.each_with_index do |row, index|
|
|
142
|
+
lines, height = layout_row(row, widths)
|
|
143
|
+
|
|
144
|
+
if @document.start_new_page_if_needed(height) && index >= header_count
|
|
145
|
+
headers.each { |header, header_lines, header_height| draw_row(header, header_lines, header_height, widths, x) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
draw_row(row, lines, height, widths, x)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def select(list, selector)
|
|
157
|
+
selector.is_a?(Integer) ? [list[selector]].compact : Array(list[selector])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def layout_row(row, widths)
|
|
161
|
+
lines = row.each_with_index.map { |cell, index| cell.lines(widths[index]) }
|
|
162
|
+
height = row.each_with_index.map do |cell, index|
|
|
163
|
+
TextLayout.height_of(lines[index], cell.leading) + cell.vertical_padding
|
|
164
|
+
end.max || 0
|
|
165
|
+
[lines, height]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def draw_row(row, lines, height, widths, x)
|
|
169
|
+
top = @document.y
|
|
170
|
+
|
|
171
|
+
row.each_with_index do |cell, index|
|
|
172
|
+
width = widths[index]
|
|
173
|
+
bottom = top - height
|
|
174
|
+
|
|
175
|
+
@document.fill_rectangle(x, bottom, width, height, color: cell.background_color) if cell.background_color
|
|
176
|
+
|
|
177
|
+
@document.draw_text_lines(lines[index], x + cell.padding[3], top - cell.padding[0],
|
|
178
|
+
width - cell.horizontal_padding, align: cell.align, leading: cell.leading)
|
|
179
|
+
|
|
180
|
+
edges = {top: [x, top, x + width, top], bottom: [x, bottom, x + width, bottom], left: [x, top, x, bottom], right: [x + width, top, x + width, bottom]}
|
|
181
|
+
edges.values_at(*Array(cell.borders)).compact.each do |edge|
|
|
182
|
+
@document.stroke_line(*edge, color: cell.border_color, width: cell.border_width)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
x += width
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@document.y = top - height
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Columns start at their natural (unwrapped) width. When the table is too wide,
|
|
192
|
+
# narrow columns keep their width and wider columns shrink proportionally, never past the table width.
|
|
193
|
+
# When a table width is given and there's space left over, it's split evenly between columns.
|
|
194
|
+
def compute_column_widths
|
|
195
|
+
count = @rows.first&.size || 0
|
|
196
|
+
natural = Array.new(count) { |i| @rows.map { |row| row[i].natural_width }.max }
|
|
197
|
+
minimum = Array.new(count) { |i| @rows.map { |row| row[i].minimum_width }.max }
|
|
198
|
+
|
|
199
|
+
fixed = case @column_widths
|
|
200
|
+
when Array then @column_widths.each_with_index.map { |width, i| [i, width] }.reject { |_, width| width.nil? }.to_h
|
|
201
|
+
when Hash then @column_widths
|
|
202
|
+
when Numeric then (0...count).map { |i| [i, @column_widths] }.to_h
|
|
203
|
+
else {}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
widths = natural.dup
|
|
207
|
+
fixed.each { |i, width| widths[i] = width }
|
|
208
|
+
flexible = (0...count).reject { |i| fixed.key?(i) }
|
|
209
|
+
return widths if flexible.empty?
|
|
210
|
+
|
|
211
|
+
target = @width || [widths.sum, @document.bounds.width].min
|
|
212
|
+
available = target - fixed.values.sum
|
|
213
|
+
flexible_natural = flexible.sum { |i| natural[i] }
|
|
214
|
+
|
|
215
|
+
if flexible_natural > available
|
|
216
|
+
budget = available
|
|
217
|
+
columns = flexible.dup
|
|
218
|
+
loop do
|
|
219
|
+
share = budget / columns.size.to_f
|
|
220
|
+
narrow = columns.select { |i| natural[i] <= share }
|
|
221
|
+
break if narrow.empty?
|
|
222
|
+
budget -= narrow.sum { |i| natural[i] }
|
|
223
|
+
columns -= narrow
|
|
224
|
+
break if columns.empty?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The rest share the budget in proportion to their natural width, but not below their widest
|
|
228
|
+
# word. If the widest words can't all fit, words are broken rather than overflowing the table.
|
|
229
|
+
loop do
|
|
230
|
+
total = columns.sum { |i| natural[i] }
|
|
231
|
+
break if total.zero?
|
|
232
|
+
short = columns.select { |i| budget * natural[i] / total < minimum[i] }
|
|
233
|
+
break if short.empty? || short.sum { |i| minimum[i] } >= budget
|
|
234
|
+
short.each { |i| widths[i] = minimum[i] }
|
|
235
|
+
budget -= short.sum { |i| minimum[i] }
|
|
236
|
+
columns -= short
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
total = columns.sum { |i| natural[i] }
|
|
240
|
+
columns.each { |i| widths[i] = total.zero? ? 0 : [budget * natural[i] / total, 0].max }
|
|
241
|
+
elsif @width && flexible_natural < available
|
|
242
|
+
extra = (available - flexible_natural) / flexible.size.to_f
|
|
243
|
+
flexible.each { |i| widths[i] += extra }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
widths
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
# Wraps styled text into lines that fit a given width.
|
|
4
|
+
#
|
|
5
|
+
# Input is an Array of [text, Style] chunks. Lines break at spaces, after
|
|
6
|
+
# hyphens, and at newlines. Words wider than the line are broken anywhere.
|
|
7
|
+
module TextLayout
|
|
8
|
+
Style = Struct.new(:font, :size, :color, :link, :underline, :strikethrough, :rise,
|
|
9
|
+
:character_spacing, :oblique, :fake_bold, keyword_init: true)
|
|
10
|
+
Run = Struct.new(:text, :style, :width)
|
|
11
|
+
Line = Struct.new(:runs, :width, :ascender, :descender, :line_gap) do
|
|
12
|
+
def height
|
|
13
|
+
ascender + descender
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
EPSILON = 0.0001
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def wrap(chunks, max_width, default_style)
|
|
22
|
+
lines = []
|
|
23
|
+
|
|
24
|
+
paragraphs(chunks, default_style).each do |paragraph_style, paragraph|
|
|
25
|
+
line = []
|
|
26
|
+
line_width = 0
|
|
27
|
+
pending = []
|
|
28
|
+
pending_width = 0
|
|
29
|
+
|
|
30
|
+
items(paragraph).each do |type, pieces|
|
|
31
|
+
width = measure_pieces(pieces)
|
|
32
|
+
|
|
33
|
+
if type == :space
|
|
34
|
+
next if line.empty?
|
|
35
|
+
pending.concat(pieces)
|
|
36
|
+
pending_width += width
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if line.any? && line_width + pending_width + width > max_width + EPSILON
|
|
41
|
+
lines << build_line(line, paragraph_style)
|
|
42
|
+
line = []
|
|
43
|
+
line_width = 0
|
|
44
|
+
else
|
|
45
|
+
line.concat(pending)
|
|
46
|
+
line_width += pending_width
|
|
47
|
+
end
|
|
48
|
+
pending = []
|
|
49
|
+
pending_width = 0
|
|
50
|
+
|
|
51
|
+
if line.empty? && width > max_width + EPSILON
|
|
52
|
+
pieces, width = break_word(pieces, max_width, paragraph_style, lines)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
line.concat(pieces)
|
|
56
|
+
line_width += width
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
lines << build_line(line, paragraph_style)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
lines
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Width of the text without wrapping
|
|
66
|
+
def natural_width(chunks, default_style)
|
|
67
|
+
paragraphs(chunks, default_style).map do |_, paragraph|
|
|
68
|
+
measure_pieces(paragraph)
|
|
69
|
+
end.max || 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Width of the widest word, the narrowest the text can wrap without breaking words
|
|
73
|
+
def minimum_width(chunks, default_style)
|
|
74
|
+
paragraphs(chunks, default_style).flat_map do |_, paragraph|
|
|
75
|
+
items(paragraph).map { |type, pieces| (type == :word) ? measure_pieces(pieces) : 0 }
|
|
76
|
+
end.max || 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def height_of(lines, leading = 0)
|
|
80
|
+
return 0 if lines.empty?
|
|
81
|
+
lines.sum(&:height) + lines[0...-1].sum { |line| line.line_gap + leading }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def measure(text, style)
|
|
85
|
+
style.font.width_of(text, style.size, character_spacing: style.character_spacing)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def measure_pieces(pieces)
|
|
89
|
+
pieces.sum { |text, style| measure(text, style) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Splits chunks at newlines. Empty paragraphs keep the style of the text around them for their height.
|
|
93
|
+
def paragraphs(chunks, default_style)
|
|
94
|
+
paragraphs = [[default_style, []]]
|
|
95
|
+
chunks.each do |text, style|
|
|
96
|
+
paragraphs.last[0] = style if paragraphs.last[1].empty?
|
|
97
|
+
text.split("\n", -1).each_with_index do |part, index|
|
|
98
|
+
paragraphs << [style, []] if index > 0
|
|
99
|
+
paragraphs.last[1] << [part, style] unless part.empty?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
paragraphs
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Groups text into [:word, pieces] and [:space, pieces] items. A word can span
|
|
106
|
+
# several chunks with different styles, like "<b>Total</b>:".
|
|
107
|
+
def items(chunks)
|
|
108
|
+
items = []
|
|
109
|
+
word = nil
|
|
110
|
+
|
|
111
|
+
chunks.each do |text, style|
|
|
112
|
+
text.scan(/[ \t]+|[^ \t]+/) do |token|
|
|
113
|
+
if token.start_with?(" ", "\t")
|
|
114
|
+
items << [:space, [[token.tr("\t", " "), style]]]
|
|
115
|
+
word = nil
|
|
116
|
+
else
|
|
117
|
+
token.scan(/[^-]*-+|[^-]+/) do |piece|
|
|
118
|
+
unless word
|
|
119
|
+
word = []
|
|
120
|
+
items << [:word, word]
|
|
121
|
+
end
|
|
122
|
+
word << [piece, style]
|
|
123
|
+
word = nil if piece.end_with?("-")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
items
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Breaks a word too wide for a line into full lines, returning the remainder
|
|
133
|
+
def break_word(pieces, max_width, paragraph_style, lines)
|
|
134
|
+
current = []
|
|
135
|
+
current_width = 0
|
|
136
|
+
|
|
137
|
+
pieces.each do |text, style|
|
|
138
|
+
text.each_char do |char|
|
|
139
|
+
width = measure(char, style)
|
|
140
|
+
if current.any? && current_width + width > max_width + EPSILON
|
|
141
|
+
lines << build_line(current, paragraph_style)
|
|
142
|
+
current = []
|
|
143
|
+
current_width = 0
|
|
144
|
+
end
|
|
145
|
+
current << [char, style]
|
|
146
|
+
current_width += width
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
[current, current_width]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_line(pieces, fallback_style)
|
|
154
|
+
runs = []
|
|
155
|
+
pieces.each do |text, style|
|
|
156
|
+
if runs.any? && runs.last.style.equal?(style)
|
|
157
|
+
runs.last.text += text
|
|
158
|
+
else
|
|
159
|
+
runs << Run.new(text.dup, style, 0)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
runs.each { |run| run.width = measure(run.text, run.style) }
|
|
163
|
+
|
|
164
|
+
styles = runs.empty? ? [fallback_style] : runs.map(&:style)
|
|
165
|
+
Line.new(
|
|
166
|
+
runs,
|
|
167
|
+
runs.sum(&:width),
|
|
168
|
+
styles.map { |s| s.font.ascender(s.size) + s.rise }.max,
|
|
169
|
+
styles.map { |s| s.font.descender(s.size) - s.rise }.max,
|
|
170
|
+
styles.map { |s| s.font.line_gap(s.size) }.max
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|