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.
- checksums.yaml +4 -4
- data/.standard.yml +1 -0
- data/CHANGELOG.md +29 -0
- data/Gemfile +0 -1
- data/README.md +31 -10
- data/Rakefile +3 -1
- data/examples/invoice.pdf +0 -0
- data/examples/receipt.pdf +0 -0
- data/examples/statement.pdf +0 -0
- data/lib/receipts/base.rb +6 -13
- 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,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 = {"<" => "<", ">" => ">", "&" => "&"}.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
|