receipts 2.3.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,242 @@
1
+ module Receipts
2
+ module PDF
3
+ class UnsupportedImageError < StandardError; end
4
+
5
+ module Image
6
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b
7
+
8
+ # Recently used images, keyed by content, so a logo used on every receipt is only decoded once
9
+ CACHE = {}
10
+ CACHE_MUTEX = Mutex.new
11
+ CACHE_SIZE = 16
12
+
13
+ # Accepts a path, Pathname, or IO-like object
14
+ def self.load(source)
15
+ data = if source.respond_to?(:read)
16
+ source.binmode if source.respond_to?(:binmode)
17
+ source.rewind if source.respond_to?(:rewind)
18
+ source.read
19
+ else
20
+ File.binread(source.to_s)
21
+ end.b
22
+
23
+ key = Digest::MD5.digest(data)
24
+ CACHE_MUTEX.synchronize do
25
+ CACHE[key] ||= parse(data)
26
+ CACHE.shift while CACHE.size > CACHE_SIZE
27
+ CACHE[key]
28
+ end
29
+ end
30
+
31
+ def self.parse(data)
32
+ if data.start_with?("\xFF\xD8".b)
33
+ JPEG.new(data)
34
+ elsif data.start_with?(PNG_SIGNATURE)
35
+ PNG.new(data)
36
+ else
37
+ raise UnsupportedImageError, "only PNG and JPEG images are supported"
38
+ end
39
+ end
40
+
41
+ def self.xobject(width, height, color_space, bits)
42
+ {Type: :XObject, Subtype: :Image, Width: width, Height: height, ColorSpace: color_space, BitsPerComponent: bits}
43
+ end
44
+
45
+ class JPEG
46
+ START_OF_FRAME = [0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF].freeze
47
+ COLOR_SPACES = {1 => :DeviceGray, 3 => :DeviceRGB, 4 => :DeviceCMYK}.freeze
48
+
49
+ attr_reader :width, :height
50
+
51
+ def initialize(data)
52
+ @data = data
53
+ pos = 2
54
+
55
+ while pos + 4 <= data.bytesize
56
+ raise UnsupportedImageError, "invalid JPEG image" unless data.getbyte(pos) == 0xFF
57
+ marker = data.getbyte(pos + 1)
58
+ if marker == 0xFF # fill byte
59
+ pos += 1
60
+ next
61
+ end
62
+
63
+ length = data.byteslice(pos + 2, 2).unpack1("n")
64
+ @adobe = true if marker == 0xEE && data.byteslice(pos + 4, 5) == "Adobe"
65
+
66
+ if START_OF_FRAME.any? { |range| range.cover?(marker) }
67
+ @bits, @height, @width, @components = data.byteslice(pos + 4, 6).unpack("CnnC")
68
+ break
69
+ end
70
+
71
+ pos += 2 + length
72
+ end
73
+
74
+ raise UnsupportedImageError, "invalid JPEG image" unless @width
75
+ raise UnsupportedImageError, "unsupported JPEG color components: #{@components}" unless COLOR_SPACES.key?(@components)
76
+ end
77
+
78
+ def build(writer)
79
+ dictionary = Image.xobject(@width, @height, COLOR_SPACES.fetch(@components), @bits).merge(Filter: :DCTDecode)
80
+ # Adobe CMYK JPEGs store inverted values
81
+ dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
82
+ writer.add(Stream.new(@data, dictionary))
83
+ end
84
+ end
85
+
86
+ class PNG
87
+ CHANNELS = {0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4}.freeze
88
+
89
+ attr_reader :width, :height
90
+
91
+ def initialize(data)
92
+ @idat = String.new(encoding: Encoding::BINARY)
93
+ pos = PNG_SIGNATURE.bytesize
94
+
95
+ while pos + 8 <= data.bytesize
96
+ length, type = data.byteslice(pos, 8).unpack("Na4")
97
+ chunk = data.byteslice(pos + 8, length)
98
+
99
+ case type
100
+ when "IHDR" then @width, @height, @bit_depth, @color_type, _, _, @interlace = chunk.unpack("NNCCCCC")
101
+ when "PLTE" then @palette = chunk
102
+ when "tRNS" then @transparency = chunk
103
+ when "IDAT" then @idat << chunk
104
+ when "IEND" then break
105
+ end
106
+
107
+ pos += 12 + length
108
+ end
109
+
110
+ raise UnsupportedImageError, "invalid PNG image" unless @width && CHANNELS.key?(@color_type)
111
+ raise UnsupportedImageError, "interlaced PNG images are not supported" if @interlace == 1
112
+ end
113
+
114
+ def build(writer)
115
+ if @color_type == 4 || @color_type == 6
116
+ dictionary = Image.xobject(@width, @height, color_space, 8).merge(Filter: :FlateDecode, SMask: soft_mask(writer))
117
+ return writer.add(Stream.new(decoded[:color], dictionary))
118
+ end
119
+
120
+ # Compressed PNG data can be embedded directly using the PNG predictor
121
+ dictionary = Image.xobject(@width, @height, color_space, @bit_depth).merge(
122
+ Filter: :FlateDecode,
123
+ DecodeParms: {Predictor: 15, Colors: CHANNELS[@color_type], BitsPerComponent: @bit_depth, Columns: @width}
124
+ )
125
+
126
+ if @transparency && @color_type == 3
127
+ dictionary[:SMask] = soft_mask(writer)
128
+ elsif @transparency
129
+ # Color key masking: [min max] per channel
130
+ dictionary[:Mask] = @transparency.unpack("n*").first(CHANNELS[@color_type]).flat_map { |v| [v, v] }
131
+ end
132
+
133
+ writer.add(Stream.new(@idat, dictionary))
134
+ end
135
+
136
+ private
137
+
138
+ def color_space
139
+ case @color_type
140
+ when 0, 4 then :DeviceGray
141
+ when 2, 6 then :DeviceRGB
142
+ when 3 then [:Indexed, :DeviceRGB, @palette.bytesize / 3 - 1, HexString.new(@palette)]
143
+ end
144
+ end
145
+
146
+ def soft_mask(writer)
147
+ dictionary = Image.xobject(@width, @height, :DeviceGray, 8).merge(Filter: :FlateDecode)
148
+ writer.add(Stream.new(decoded[:alpha], dictionary))
149
+ end
150
+
151
+ # Compressed color and alpha data, memoized since images are cached across documents
152
+ def decoded
153
+ @decoded ||= begin
154
+ color, alpha = (@color_type == 3) ? [nil, palette_alpha] : split_alpha
155
+ {color: color && Zlib::Deflate.deflate(color), alpha: Zlib::Deflate.deflate(alpha)}
156
+ end
157
+ end
158
+
159
+ # Alpha channel for palette images from the per-palette-entry tRNS values
160
+ def palette_alpha
161
+ alphas = @transparency.bytes
162
+ alpha = String.new(capacity: @width * @height, encoding: Encoding::BINARY)
163
+
164
+ each_row(1, (@width * @bit_depth + 7) / 8) do |row|
165
+ indexes = if @bit_depth == 8
166
+ row
167
+ else
168
+ row.pack("C*").unpack1("B*").scan(/.{#{@bit_depth}}/).first(@width).map { |bits| bits.to_i(2) }
169
+ end
170
+ alpha << indexes.map { |index| alphas[index] || 255 }.pack("C*")
171
+ end
172
+ alpha
173
+ end
174
+
175
+ # Splits gray+alpha or RGBA pixels into color and alpha, reduced to 8 bits per sample
176
+ def split_alpha
177
+ channels = CHANNELS[@color_type]
178
+ sample = @bit_depth / 8
179
+ step = channels * sample
180
+ # Positions of each sample's high byte within a row
181
+ color_bytes = Array.new(@width) { |x| Array.new(channels - 1) { |c| x * step + c * sample } }.flatten
182
+ alpha_bytes = Array.new(@width) { |x| x * step + (channels - 1) * sample }
183
+
184
+ color = String.new(capacity: @width * @height * (channels - 1), encoding: Encoding::BINARY)
185
+ alpha = String.new(capacity: @width * @height, encoding: Encoding::BINARY)
186
+ each_row(step, @width * step) do |row|
187
+ color << row.values_at(*color_bytes).pack("C*")
188
+ alpha << row.values_at(*alpha_bytes).pack("C*")
189
+ end
190
+ [color, alpha]
191
+ end
192
+
193
+ # Decompresses the image data and reverses the per-row PNG filters, yielding each row's bytes
194
+ def each_row(bpp, row_bytes)
195
+ data = Zlib::Inflate.inflate(@idat)
196
+ previous = Array.new(row_bytes, 0)
197
+
198
+ @height.times do |y|
199
+ pos = y * (row_bytes + 1)
200
+ filter = data.getbyte(pos)
201
+ row = data.byteslice(pos + 1, row_bytes).bytes
202
+
203
+ case filter
204
+ when 0
205
+ nil
206
+ when 1
207
+ (bpp...row_bytes).each { |i| row[i] = (row[i] + row[i - bpp]) & 0xFF }
208
+ when 2
209
+ row_bytes.times { |i| row[i] = (row[i] + previous[i]) & 0xFF }
210
+ when 3
211
+ row_bytes.times do |i|
212
+ left = (i >= bpp) ? row[i - bpp] : 0
213
+ row[i] = (row[i] + ((left + previous[i]) >> 1)) & 0xFF
214
+ end
215
+ when 4
216
+ row_bytes.times do |i|
217
+ a = (i >= bpp) ? row[i - bpp] : 0
218
+ b = previous[i]
219
+ c = (i >= bpp) ? previous[i - bpp] : 0
220
+ p = a + b - c
221
+ pa = (p - a).abs
222
+ pb = (p - b).abs
223
+ pc = (p - c).abs
224
+ predictor = if pa <= pb && pa <= pc
225
+ a
226
+ else
227
+ (pb <= pc) ? b : c
228
+ end
229
+ row[i] = (row[i] + predictor) & 0xFF
230
+ end
231
+ else
232
+ raise UnsupportedImageError, "invalid PNG filter type: #{filter}"
233
+ end
234
+
235
+ yield row
236
+ previous = row
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,79 @@
1
+ module Receipts
2
+ module PDF
3
+ # Parses HTML-like inline formatting into styled text fragments:
4
+ # <b> <strong> <i> <em> <u> <strikethrough> <sub> <sup> <br>
5
+ # <font name="..." size="..." character_spacing="...">
6
+ # <color rgb="#ff0000"> or <color c="0" m="100" y="100" k="0">
7
+ # <link href="..."> or <a href="...">
8
+ #
9
+ # Each fragment is a Hash with :text and any of :styles, :color, :link,
10
+ # :font, :size and :character_spacing.
11
+ module InlineFormat
12
+ TAG = %r{<(/?)(b|strong|i|em|u|strikethrough|sub|sup|font|color|link|a|br)(\s[^>]*?)?\s*/?>}i
13
+ ATTRIBUTE = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
14
+ ENTITIES = {"&lt;" => "<", "&gt;" => ">", "&amp;" => "&"}.freeze
15
+ STYLES = {
16
+ "b" => :bold, "strong" => :bold, "i" => :italic, "em" => :italic, "u" => :underline,
17
+ "strikethrough" => :strikethrough, "sub" => :subscript, "sup" => :superscript
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def parse(string)
23
+ fragments = []
24
+ stack = [[nil, {}]]
25
+ pos = 0
26
+
27
+ while (match = TAG.match(string, pos))
28
+ add(fragments, string[pos...match.begin(0)], stack.last.last)
29
+ name = match[2].downcase
30
+
31
+ if name == "br"
32
+ add(fragments, "\n", stack.last.last)
33
+ elsif match[1] == "/"
34
+ index = stack.rindex { |tag, _| tag == name }
35
+ stack = stack.first(index) if index
36
+ else
37
+ stack.push([name, apply(stack.last.last, name, attributes(match[3].to_s))])
38
+ end
39
+
40
+ pos = match.end(0)
41
+ end
42
+
43
+ add(fragments, string[pos..], stack.last.last)
44
+ fragments
45
+ end
46
+
47
+ def add(fragments, text, style)
48
+ return if text.empty?
49
+ fragments << style.merge(text: text.gsub(/&(?:lt|gt|amp);/, ENTITIES))
50
+ end
51
+
52
+ def attributes(string)
53
+ string.scan(ATTRIBUTE).map { |key, double, single| [key.downcase, double || single] }.to_h
54
+ end
55
+
56
+ def apply(style, name, attributes)
57
+ style = style.dup
58
+
59
+ if STYLES.key?(name)
60
+ style[:styles] = Array(style[:styles]) | [STYLES[name]]
61
+ elsif name == "font"
62
+ style[:font] = attributes["name"] if attributes["name"]
63
+ style[:size] = attributes["size"].to_f if attributes["size"]
64
+ style[:character_spacing] = attributes["character_spacing"].to_f if attributes["character_spacing"]
65
+ elsif name == "color"
66
+ if attributes["rgb"]
67
+ style[:color] = attributes["rgb"].delete("#")
68
+ elsif %w[c m y k].all? { |key| attributes.key?(key) }
69
+ style[:color] = attributes.values_at("c", "m", "y", "k").map(&:to_f)
70
+ end
71
+ elsif attributes["href"]
72
+ style[:link] = attributes["href"]
73
+ end
74
+
75
+ style
76
+ end
77
+ end
78
+ end
79
+ end
@@ -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