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,383 @@
1
+ module Receipts
2
+ module PDF
3
+ # A minimal PDF document with a Prawn-like API for flowing text, images and tables.
4
+ #
5
+ # Coordinates follow PDF conventions: the origin is the bottom left of the page
6
+ # and units are points (1/72 inch).
7
+ class Document
8
+ PAGE_SIZES = {
9
+ "A3" => [841.89, 1190.55],
10
+ "A4" => [595.28, 841.89],
11
+ "A5" => [419.53, 595.28],
12
+ "LEGAL" => [612.0, 1008.0],
13
+ "LETTER" => [612.0, 792.0],
14
+ "TABLOID" => [792.0, 1224.0]
15
+ }.freeze
16
+
17
+ FONTS_PATH = File.expand_path("../fonts", __dir__)
18
+ DEFAULT_FONT_FAMILY = "Inter"
19
+ DEFAULT_FONT = {
20
+ normal: File.join(FONTS_PATH, "Inter-Regular.ttf"),
21
+ bold: File.join(FONTS_PATH, "Inter-Bold.ttf")
22
+ }.freeze
23
+
24
+ # The area inside the page margins. Like Prawn, `left`, `right`, `top` and
25
+ # `bottom` are relative to the box, while the absolute_* values are page coordinates.
26
+ Bounds = Struct.new(:absolute_left, :absolute_bottom, :width, :height) do
27
+ def left
28
+ 0
29
+ end
30
+
31
+ def bottom
32
+ 0
33
+ end
34
+
35
+ def right
36
+ width
37
+ end
38
+
39
+ def top
40
+ height
41
+ end
42
+
43
+ def absolute_right
44
+ absolute_left + width
45
+ end
46
+
47
+ def absolute_top
48
+ absolute_bottom + height
49
+ end
50
+ end
51
+
52
+ Page = Struct.new(:content, :annotations)
53
+
54
+ attr_reader :bounds, :font_families, :page_width, :page_height
55
+ attr_accessor :y, :fill_color, :stroke_color, :line_width
56
+
57
+ def initialize(page_size: "LETTER", page_layout: :portrait, margin: 36, info: {})
58
+ width, height = page_size.is_a?(Array) ? page_size : PAGE_SIZES.fetch(page_size.to_s.upcase) {
59
+ raise ArgumentError, "unknown page size #{page_size.inspect}, use one of #{PAGE_SIZES.keys.join(", ")} or [width, height]"
60
+ }
61
+ width, height = height, width if page_layout == :landscape
62
+ @page_width = width
63
+ @page_height = height
64
+
65
+ top, right, bottom, left = Geometry.expand_box(margin)
66
+ @bounds = Bounds.new(left, bottom, width - left - right, height - top - bottom)
67
+
68
+ @info = info
69
+ @font_families = {DEFAULT_FONT_FAMILY => DEFAULT_FONT.dup}
70
+ @font_family = DEFAULT_FONT_FAMILY
71
+ @font_size = 12
72
+ @fill_color = "000000"
73
+ @stroke_color = "000000"
74
+ @line_width = 1
75
+ @fonts = {}
76
+ @font_resources = {}.compare_by_identity
77
+ @images = {}.compare_by_identity
78
+ @pages = []
79
+
80
+ start_new_page
81
+ end
82
+
83
+ def start_new_page
84
+ @pages << Page.new(String.new(encoding: Encoding::BINARY), [])
85
+ @y = @bounds.absolute_top
86
+ end
87
+
88
+ # Starts a new page if there isn't room for height, unless already at the top of one.
89
+ # Returns true when a new page was started.
90
+ def start_new_page_if_needed(height)
91
+ return false unless @y - height < @bounds.absolute_bottom && @y < @bounds.absolute_top
92
+ start_new_page
93
+ true
94
+ end
95
+
96
+ def page_count
97
+ @pages.size
98
+ end
99
+
100
+ # Distance from the current position to the bottom margin
101
+ def cursor
102
+ @y - @bounds.absolute_bottom
103
+ end
104
+
105
+ def move_down(amount)
106
+ @y -= amount
107
+ end
108
+
109
+ def move_up(amount)
110
+ @y += amount
111
+ end
112
+
113
+ def move_cursor_to(position)
114
+ @y = @bounds.absolute_bottom + position
115
+ end
116
+
117
+ # Sets the font family (or a path to a .ttf file), optionally just for the given block
118
+ def font(name = nil, size: nil)
119
+ return @font_family if name.nil? && size.nil?
120
+
121
+ previous = [@font_family, @font_size]
122
+ @font_family = name.to_s if name
123
+ @font_size = size if size
124
+ return unless block_given?
125
+
126
+ begin
127
+ yield
128
+ ensure
129
+ @font_family, @font_size = previous
130
+ end
131
+ end
132
+
133
+ def font_size(size = nil, &block)
134
+ return @font_size if size.nil?
135
+ return @font_size = size unless block
136
+ font(nil, size: size, &block)
137
+ end
138
+
139
+ def width_of(string, options = {})
140
+ TextLayout.natural_width(text_chunks(string, options), text_style(options))
141
+ end
142
+
143
+ def height_of(string, options = {})
144
+ TextLayout.height_of(text_lines(string, options, @bounds.width), options.fetch(:leading, 0))
145
+ end
146
+
147
+ # Writes flowing text at the cursor, wrapping lines and starting new pages as needed.
148
+ #
149
+ # Options: :size, :style (:bold, :italic, :bold_italic), :align (:left, :center, :right),
150
+ # :color (hex RGB), :font, :character_spacing, :leading, :inline_format
151
+ def text(string, options = {})
152
+ lines = text_lines(string, options, @bounds.width)
153
+ leading = options.fetch(:leading, 0)
154
+
155
+ lines.each_with_index do |line, index|
156
+ start_new_page_if_needed(line.height)
157
+ draw_text_line(line, @bounds.absolute_left, @y - line.ascender, @bounds.width, options.fetch(:align, :left))
158
+ @y -= line.height
159
+ @y -= line.line_gap + leading if index < lines.size - 1
160
+ end
161
+ nil
162
+ end
163
+
164
+ # Draws an image at the cursor. Given only a width or height, the other is
165
+ # scaled proportionally. Given neither, images wider than the bounds are scaled down to fit.
166
+ #
167
+ # Position is :left, :center, :right or an x offset from the left bound.
168
+ def image(source, width: nil, height: nil, position: :left)
169
+ image = Image.load(source)
170
+ width ||= height ? image.width * height.to_f / image.height : [image.width, @bounds.width].min.to_f
171
+ height ||= image.height * width.to_f / image.width
172
+ x = @bounds.absolute_left + Geometry.align_offset(position, @bounds.width, width)
173
+
174
+ start_new_page_if_needed(height)
175
+
176
+ name = @images[image] ||= :"I#{@images.size + 1}"
177
+ add_content "q #{n(width)} 0 0 #{n(height)} #{n(x)} #{n(@y - height)} cm /#{name} Do Q"
178
+ @y -= height
179
+ nil
180
+ end
181
+
182
+ # Draws a table at the cursor. See Receipts::PDF::Table for options.
183
+ def table(data, options = {}, &block)
184
+ Table.new(self, data, options, &block).draw
185
+ end
186
+
187
+ def stroke_horizontal_rule
188
+ stroke_line(@bounds.absolute_left, @y, @bounds.absolute_right, @y)
189
+ end
190
+
191
+ def stroke_line(x1, y1, x2, y2, color: @stroke_color, width: @line_width)
192
+ add_content "q #{color_operator(color, stroke: true)} #{n(width)} w #{n(x1)} #{n(y1)} m #{n(x2)} #{n(y2)} l S Q"
193
+ end
194
+
195
+ def fill_rectangle(x, y, width, height, color: @fill_color)
196
+ add_content "q #{color_operator(color)} #{n(x)} #{n(y)} #{n(width)} #{n(height)} re f Q"
197
+ end
198
+
199
+ def render
200
+ writer = Writer.new
201
+
202
+ fonts = @font_resources.map { |font, name| [name, font.build(writer)] }.to_h
203
+ images = @images.map { |image, name| [name, image.build(writer)] }.to_h
204
+ resources = {}
205
+ resources[:Font] = fonts if fonts.any?
206
+ resources[:XObject] = images if images.any?
207
+ resources = writer.add(resources)
208
+
209
+ pages = writer.reserve
210
+ kids = @pages.map do |page|
211
+ dictionary = {
212
+ Type: :Page,
213
+ Parent: pages,
214
+ MediaBox: [0, 0, @page_width, @page_height],
215
+ Resources: resources,
216
+ Contents: writer.add(Stream.new(page.content))
217
+ }
218
+ dictionary[:Annots] = page.annotations.map { |annotation| writer.add(annotation) } if page.annotations.any?
219
+ writer.add(dictionary)
220
+ end
221
+ writer.set(pages, Type: :Pages, Kids: kids, Count: kids.size)
222
+
223
+ info = {Producer: "Receipts"}.merge(@info).transform_values { |value| Serializer.text_string(value) }
224
+ writer.render(root: writer.add(Type: :Catalog, Pages: pages), info: writer.add(info))
225
+ end
226
+
227
+ def render_file(path)
228
+ File.binwrite(path, render)
229
+ end
230
+
231
+ # [natural width, widest word] of text, used to size table columns
232
+ def measure_text(string, options = {})
233
+ chunks = text_chunks(string, options)
234
+ style = text_style(options)
235
+ [TextLayout.natural_width(chunks, style), TextLayout.minimum_width(chunks, style)]
236
+ end
237
+
238
+ # Lays out text into lines for the given width without drawing it
239
+ def text_lines(string, options, width)
240
+ TextLayout.wrap(text_chunks(string, options), width, text_style(options))
241
+ end
242
+
243
+ # Draws lines of text top-down from top, returning the height used
244
+ def draw_text_lines(lines, x, top, width, align: :left, leading: 0)
245
+ y = top
246
+ lines.each_with_index do |line, index|
247
+ draw_text_line(line, x, y - line.ascender, width, align)
248
+ y -= line.height
249
+ y -= line.line_gap + leading if index < lines.size - 1
250
+ end
251
+ top - y
252
+ end
253
+
254
+ private
255
+
256
+ def add_content(operators)
257
+ @pages.last.content << operators << "\n"
258
+ end
259
+
260
+ def n(value)
261
+ Serializer.number(value)
262
+ end
263
+
264
+ def text_style(options, fragment = {})
265
+ styles = style_list(options[:style]) | Array(fragment[:styles])
266
+ size = fragment[:size] || options[:size] || @font_size
267
+ rise = 0
268
+
269
+ if styles.include?(:superscript)
270
+ rise = size * 0.33
271
+ size *= 0.583
272
+ elsif styles.include?(:subscript)
273
+ rise = -size * 0.2
274
+ size *= 0.583
275
+ end
276
+
277
+ font, fake_bold, oblique = resolve_font(fragment[:font] || options[:font] || @font_family, styles)
278
+
279
+ TextLayout::Style.new(
280
+ font: font,
281
+ size: size,
282
+ color: fragment[:color] || options[:color] || @fill_color,
283
+ link: fragment[:link],
284
+ underline: styles.include?(:underline),
285
+ strikethrough: styles.include?(:strikethrough),
286
+ rise: rise,
287
+ character_spacing: fragment[:character_spacing] || options[:character_spacing] || 0,
288
+ oblique: oblique,
289
+ fake_bold: fake_bold
290
+ )
291
+ end
292
+
293
+ def text_chunks(string, options)
294
+ string = string.to_s
295
+ string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
296
+ string = string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub.delete("\r")
297
+
298
+ fragments = options[:inline_format] ? InlineFormat.parse(string) : [{text: string}]
299
+ fragments.map { |fragment| [fragment[:text], text_style(options, fragment)] }
300
+ end
301
+
302
+ def style_list(style)
303
+ case style
304
+ when nil, :normal then []
305
+ when :bold_italic then [:bold, :italic]
306
+ when Array then style
307
+ else [style.to_sym]
308
+ end
309
+ end
310
+
311
+ # Picks the font for a style, faking bold or italic when the family doesn't include it.
312
+ # Returns [font, fake_bold, oblique]
313
+ def resolve_font(name, styles)
314
+ name = name.to_s
315
+ family = @font_families[name] || (name.end_with?(".ttf") && File.exist?(name) && {normal: name})
316
+ raise ArgumentError, "unknown font #{name.inspect}, register it with font_families.update(#{name.inspect} => {normal: \"path/to/font.ttf\"})" unless family
317
+
318
+ family = family.transform_keys(&:to_sym)
319
+ bold = styles.include?(:bold)
320
+ italic = styles.include?(:italic)
321
+ key = [(:bold_italic if bold && italic), (:bold if bold), (:italic if italic), :normal].compact.find { |style| family[style] }
322
+ raise ArgumentError, "font family #{name.inspect} needs a :normal font" unless key
323
+
324
+ path = File.expand_path(family[key].to_s)
325
+ font = @fonts[path] ||= Font.new(path).tap { |f| @font_resources[f] = :"F#{@font_resources.size + 1}" }
326
+ [font, bold && !key.to_s.include?("bold"), italic && !key.to_s.include?("italic")]
327
+ end
328
+
329
+ def draw_text_line(line, x, baseline, width, align)
330
+ x += Geometry.align_offset(align, width, line.width)
331
+
332
+ line.runs.each do |run|
333
+ draw_run(run, x, baseline)
334
+ x += run.width
335
+ end
336
+ end
337
+
338
+ def draw_run(run, x, baseline)
339
+ style = run.style
340
+ y = baseline + style.rise
341
+
342
+ ops = ["q BT", "/#{@font_resources.fetch(style.font)} #{n(style.size)} Tf", color_operator(style.color)]
343
+ ops << "#{n(style.character_spacing)} Tc" unless style.character_spacing.zero?
344
+ ops << "2 Tr #{n(style.size * 0.03)} w #{color_operator(style.color, stroke: true)}" if style.fake_bold
345
+ ops << "1 0 #{style.oblique ? n(Font::OBLIQUE_SKEW) : 0} 1 #{n(x)} #{n(y)} Tm"
346
+ ops << "<#{style.font.encode(run.text).unpack1("H*")}> Tj ET Q"
347
+ add_content ops.join(" ")
348
+
349
+ font = style.font
350
+ stroke_decoration(run, x, y, font.underline_position(style.size), font.underline_thickness(style.size)) if style.underline
351
+ stroke_decoration(run, x, y, font.strikeout_position(style.size), font.strikeout_size(style.size)) if style.strikethrough
352
+
353
+ if style.link
354
+ @pages.last.annotations << {
355
+ Type: :Annot,
356
+ Subtype: :Link,
357
+ Rect: [x, y - style.font.descender(style.size), x + run.width, y + style.font.ascender(style.size)],
358
+ Border: [0, 0, 0],
359
+ A: {Type: :Action, S: :URI, URI: style.link.to_s}
360
+ }
361
+ end
362
+ end
363
+
364
+ # Draws an underline or strikethrough line across a run at an offset from its baseline
365
+ def stroke_decoration(run, x, baseline, offset, thickness)
366
+ y = baseline + offset
367
+ stroke_line(x, y, x + run.width, y, color: run.style.color, width: thickness)
368
+ end
369
+
370
+ # Hex RGB ("ff0000" or "#ff0000") or CMYK percentages ([0, 100, 100, 0])
371
+ def color_operator(color, stroke: false)
372
+ if color.is_a?(Array)
373
+ "#{color.map { |c| n(c / 100.0) }.join(" ")} #{stroke ? "K" : "k"}"
374
+ else
375
+ hex = color.to_s.delete("#")
376
+ hex = hex.chars.map { |c| c * 2 }.join if hex.size == 3
377
+ raise ArgumentError, "invalid color #{color.inspect}" unless hex.match?(/\A\h{6}\z/)
378
+ "#{[hex].pack("H*").bytes.map { |c| n(c / 255.0) }.join(" ")} #{stroke ? "RG" : "rg"}"
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
@@ -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