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.
@@ -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