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,168 @@
1
+ module Receipts
2
+ module PDF
3
+ # A TrueType font used in a document. Tracks which glyphs are used so only
4
+ # those are embedded when the document is rendered.
5
+ class Font
6
+ # Skew applied to fake an italic style when a font family has no italic font
7
+ OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
8
+
9
+ attr_reader :ttf
10
+
11
+ def initialize(path)
12
+ @ttf = TrueType.load(path)
13
+ @used = {}
14
+ @widths = {}
15
+ end
16
+
17
+ def width_of(text, size, character_spacing: 0)
18
+ units = text.each_char.sum { |char| @widths[char] ||= @ttf.advance(@ttf.glyph_id(char.ord)) }
19
+ scale(units, size) + character_spacing * text.length
20
+ end
21
+
22
+ def ascender(size)
23
+ scale(@ttf.ascender, size)
24
+ end
25
+
26
+ # Distance below the baseline, as a positive number
27
+ def descender(size)
28
+ -scale(@ttf.descender, size)
29
+ end
30
+
31
+ def line_gap(size)
32
+ scale(@ttf.line_gap, size)
33
+ end
34
+
35
+ def underline_position(size)
36
+ scale(@ttf.underline_position, size)
37
+ end
38
+
39
+ def underline_thickness(size)
40
+ scale(@ttf.underline_thickness, size)
41
+ end
42
+
43
+ def strikeout_position(size)
44
+ scale(@ttf.strikeout_position, size)
45
+ end
46
+
47
+ def strikeout_size(size)
48
+ scale(@ttf.strikeout_size, size)
49
+ end
50
+
51
+ def bold?
52
+ @ttf.weight >= 600
53
+ end
54
+
55
+ # Encodes text as 2-byte original glyph IDs (Identity-H encoding)
56
+ def encode(text)
57
+ text.each_char.map do |char|
58
+ gid = @ttf.glyph_id(char.ord)
59
+ @used[gid] ||= char
60
+ gid
61
+ end.pack("n*")
62
+ end
63
+
64
+ def build(writer)
65
+ gids = @used.keys.sort
66
+ subset, mapping = @ttf.subset(gids)
67
+ name = :"#{subset_tag(gids)}+#{@ttf.postscript_name}"
68
+
69
+ font_file = writer.add(Stream.new(subset, {Length1: subset.bytesize}))
70
+ descriptor = writer.add(
71
+ Type: :FontDescriptor,
72
+ FontName: name,
73
+ Flags: flags,
74
+ FontBBox: @ttf.bbox.map { |v| to_glyph_space(v) },
75
+ ItalicAngle: @ttf.italic_angle,
76
+ Ascent: to_glyph_space(@ttf.ascender),
77
+ Descent: to_glyph_space(@ttf.descender),
78
+ CapHeight: to_glyph_space(@ttf.cap_height),
79
+ XHeight: to_glyph_space(@ttf.x_height),
80
+ StemV: bold? ? 120 : 80,
81
+ FontFile2: font_file
82
+ )
83
+ cid_font = writer.add(
84
+ Type: :Font,
85
+ Subtype: :CIDFontType2,
86
+ BaseFont: name,
87
+ CIDSystemInfo: {Registry: "Adobe", Ordering: "Identity", Supplement: 0},
88
+ FontDescriptor: descriptor,
89
+ DW: to_glyph_space(@ttf.advance(0)),
90
+ W: glyph_widths(gids),
91
+ CIDToGIDMap: writer.add(Stream.new(cid_to_gid_map(gids, mapping)))
92
+ )
93
+ writer.add(
94
+ Type: :Font,
95
+ Subtype: :Type0,
96
+ BaseFont: name,
97
+ Encoding: :"Identity-H",
98
+ DescendantFonts: [cid_font],
99
+ ToUnicode: writer.add(Stream.new(to_unicode_cmap))
100
+ )
101
+ end
102
+
103
+ private
104
+
105
+ def scale(units, size)
106
+ units * size / @ttf.units_per_em.to_f
107
+ end
108
+
109
+ def to_glyph_space(units)
110
+ (units * 1000.0 / @ttf.units_per_em).round
111
+ end
112
+
113
+ def flags
114
+ flags = 32 # Nonsymbolic
115
+ flags |= 1 if @ttf.fixed_pitch?
116
+ flags |= 64 unless @ttf.italic_angle.zero?
117
+ flags
118
+ end
119
+
120
+ # Subset fonts are named with a tag of 6 uppercase letters, like ABCDEF+Inter-Regular
121
+ def subset_tag(gids)
122
+ Digest::MD5.digest(gids.pack("n*") + @ttf.postscript_name).bytes.first(6).map { |b| (65 + b % 26).chr }.join
123
+ end
124
+
125
+ # Text is encoded with the original glyph IDs as CIDs. This maps them to the renumbered subset glyphs.
126
+ def cid_to_gid_map(gids, mapping)
127
+ map = Array.new((gids.max || 0) + 1, 0)
128
+ gids.each { |gid| map[gid] = mapping.fetch(gid) }
129
+ map.pack("n*")
130
+ end
131
+
132
+ # Widths array grouping consecutive glyph IDs: [first [w1 w2 ...] first [w1 ...]]
133
+ def glyph_widths(gids)
134
+ gids.slice_when { |a, b| b != a + 1 }.flat_map do |run|
135
+ [run.first, run.map { |gid| to_glyph_space(@ttf.advance(gid)) }]
136
+ end
137
+ end
138
+
139
+ # Maps glyph IDs back to Unicode so text can be copied and searched
140
+ def to_unicode_cmap
141
+ mappings = @used.sort.map do |gid, char|
142
+ format("<%04X> <%s>", gid, char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
143
+ end
144
+
145
+ blocks = mappings.each_slice(100).map do |slice|
146
+ "#{slice.size} beginbfchar\n#{slice.join("\n")}\nendbfchar"
147
+ end
148
+
149
+ <<~CMAP
150
+ /CIDInit /ProcSet findresource begin
151
+ 12 dict begin
152
+ begincmap
153
+ /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
154
+ /CMapName /Adobe-Identity-UCS def
155
+ /CMapType 2 def
156
+ 1 begincodespacerange
157
+ <0000> <FFFF>
158
+ endcodespacerange
159
+ #{blocks.join("\n")}
160
+ endcmap
161
+ CMapName currentdict /CMap defineresource pop
162
+ end
163
+ end
164
+ CMAP
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,29 @@
1
+ module Receipts
2
+ module PDF
3
+ module Geometry
4
+ module_function
5
+
6
+ # [top, right, bottom, left] from a number, [vertical, horizontal], [top, horizontal, bottom] or [top, right, bottom, left]
7
+ def expand_box(value)
8
+ values = Array(value)
9
+ case values.size
10
+ when 1 then values * 4
11
+ when 2 then [values[0], values[1], values[0], values[1]]
12
+ when 3 then [values[0], values[1], values[2], values[1]]
13
+ else values.first(4)
14
+ end
15
+ end
16
+
17
+ # Offset that aligns content of the used width within the available width.
18
+ # Position is :left, :center, :right or an offset.
19
+ def align_offset(position, available, used)
20
+ case position
21
+ when :center then (available - used) / 2.0
22
+ when :right then available - used
23
+ when Numeric then position
24
+ else 0
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -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