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,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
@@ -0,0 +1,387 @@
1
+ require "set"
2
+
3
+ module Receipts
4
+ module PDF
5
+ class UnsupportedFontError < StandardError; end
6
+
7
+ # Reads the tables of a TrueType (.ttf) font needed to measure text and
8
+ # embed a subset of the font in a PDF.
9
+ class TrueType
10
+ REQUIRED_TABLES = %w[head hhea maxp hmtx cmap loca glyf].freeze
11
+
12
+ # Hinting tables copied unchanged into subsets when present
13
+ HINTING_TABLES = ["cvt ", "fpgm", "prep"].freeze
14
+
15
+ CACHE = {}
16
+ CACHE_MUTEX = Mutex.new
17
+ CACHE_SIZE = 16
18
+
19
+ attr_reader :units_per_em, :bbox, :ascender, :descender, :line_gap, :num_glyphs, :cmap,
20
+ :italic_angle, :underline_position, :underline_thickness, :strikeout_position, :strikeout_size,
21
+ :cap_height, :x_height, :weight, :postscript_name
22
+
23
+ # Recently used fonts are cached by path since they're reused across documents,
24
+ # and parsed again if the file changes
25
+ def self.load(path)
26
+ path = File.expand_path(path.to_s)
27
+ mtime = File.mtime(path)
28
+
29
+ CACHE_MUTEX.synchronize do
30
+ cached_mtime, font = CACHE[path]
31
+ unless cached_mtime == mtime
32
+ font = new(File.binread(path))
33
+ CACHE.delete(path)
34
+ CACHE[path] = [mtime, font]
35
+ CACHE.shift while CACHE.size > CACHE_SIZE
36
+ end
37
+ font
38
+ end
39
+ end
40
+
41
+ def initialize(data)
42
+ @data = data.b
43
+
44
+ case @data.byteslice(0, 4)
45
+ when "\x00\x01\x00\x00".b, "true"
46
+ nil
47
+ when "OTTO"
48
+ raise UnsupportedFontError, "OpenType fonts with CFF outlines are not supported, use a TrueType (.ttf) font"
49
+ when "ttcf"
50
+ raise UnsupportedFontError, "TrueType collections (.ttc) are not supported, use a single TrueType (.ttf) font"
51
+ else
52
+ raise UnsupportedFontError, "not a TrueType font"
53
+ end
54
+
55
+ @tables = {}
56
+ u16(4).times do |i|
57
+ record = 12 + i * 16
58
+ @tables[@data.byteslice(record, 4)] = [u32(record + 8), u32(record + 12)]
59
+ end
60
+
61
+ REQUIRED_TABLES.each do |tag|
62
+ raise UnsupportedFontError, "font is missing the #{tag} table" unless @tables[tag]
63
+ end
64
+
65
+ parse_head
66
+ parse_hhea
67
+ parse_maxp
68
+ parse_hmtx
69
+ parse_loca
70
+ parse_cmap
71
+ parse_post
72
+ parse_os2
73
+ parse_name
74
+ end
75
+
76
+ def glyph_id(codepoint)
77
+ @cmap.fetch(codepoint, 0)
78
+ end
79
+
80
+ def advance(gid)
81
+ @advances[gid] || @advances.last
82
+ end
83
+
84
+ def fixed_pitch?
85
+ @fixed_pitch
86
+ end
87
+
88
+ # Builds a font containing only the given glyphs (plus composite glyph parts),
89
+ # renumbered compactly. Returns the font data and a Hash of original to new glyph IDs.
90
+ def subset(gids)
91
+ glyphs = glyph_closure(Set.new(gids) << 0).sort
92
+ mapping = glyphs.each_with_index.to_h
93
+ count = glyphs.size
94
+
95
+ glyf = String.new(encoding: Encoding::BINARY)
96
+ loca = []
97
+ glyphs.each do |gid|
98
+ loca << glyf.bytesize
99
+ data = remap_components(glyph_data(gid), mapping)
100
+ glyf << data << padding(data)
101
+ end
102
+ loca << glyf.bytesize
103
+
104
+ head = table_data("head").dup
105
+ head[8, 4] = [0].pack("N") # checkSumAdjustment, set below
106
+ head[50, 2] = [1].pack("n") # indexToLocFormat: long offsets
107
+
108
+ hhea = table_data("hhea").dup
109
+ hhea[34, 2] = [count].pack("n") # numberOfHMetrics
110
+
111
+ maxp = table_data("maxp").dup
112
+ maxp[4, 2] = [count].pack("n") # numGlyphs
113
+
114
+ hmtx = glyphs.flat_map { |gid| [advance(gid), left_side_bearing(gid)] }.pack("ns>" * count)
115
+
116
+ tables = {"head" => head, "hhea" => hhea, "maxp" => maxp, "hmtx" => hmtx, "loca" => loca.pack("N*"), "glyf" => glyf}
117
+ HINTING_TABLES.each { |tag| tables[tag] = table_data(tag) if @tables[tag] }
118
+
119
+ if @tables["post"]
120
+ # Version 3.0 post table: metrics only, no glyph names
121
+ post = table_data("post").byteslice(0, 32).dup
122
+ post[0, 4] = [0x00030000].pack("N")
123
+ tables["post"] = post
124
+ end
125
+
126
+ [build_font(tables), mapping]
127
+ end
128
+
129
+ private
130
+
131
+ def u16(offset)
132
+ @data.byteslice(offset, 2).unpack1("n")
133
+ end
134
+
135
+ def i16(offset)
136
+ @data.byteslice(offset, 2).unpack1("s>")
137
+ end
138
+
139
+ def u32(offset)
140
+ @data.byteslice(offset, 4).unpack1("N")
141
+ end
142
+
143
+ def table_offset(tag)
144
+ @tables[tag]&.first
145
+ end
146
+
147
+ def table_data(tag)
148
+ offset, length = @tables.fetch(tag)
149
+ @data.byteslice(offset, length)
150
+ end
151
+
152
+ def padding(data)
153
+ "\0".b * (-data.bytesize % 4)
154
+ end
155
+
156
+ def parse_head
157
+ t = table_offset("head")
158
+ @units_per_em = u16(t + 18)
159
+ @bbox = [i16(t + 36), i16(t + 38), i16(t + 40), i16(t + 42)]
160
+ @index_to_loc_format = i16(t + 50)
161
+ end
162
+
163
+ def parse_hhea
164
+ t = table_offset("hhea")
165
+ @ascender = i16(t + 4)
166
+ @descender = i16(t + 6)
167
+ @line_gap = i16(t + 8)
168
+ @number_of_hmetrics = u16(t + 34)
169
+ end
170
+
171
+ def parse_maxp
172
+ @num_glyphs = u16(table_offset("maxp") + 4)
173
+ end
174
+
175
+ def parse_hmtx
176
+ @hmtx_offset = table_offset("hmtx")
177
+ @advances = @data.byteslice(@hmtx_offset, @number_of_hmetrics * 4).unpack("n*").each_slice(2).map(&:first)
178
+ end
179
+
180
+ def left_side_bearing(gid)
181
+ if gid < @number_of_hmetrics
182
+ i16(@hmtx_offset + gid * 4 + 2)
183
+ else
184
+ i16(@hmtx_offset + @number_of_hmetrics * 4 + (gid - @number_of_hmetrics) * 2)
185
+ end
186
+ end
187
+
188
+ def parse_loca
189
+ t = table_offset("loca")
190
+ @loca = if @index_to_loc_format.zero?
191
+ @data.byteslice(t, (@num_glyphs + 1) * 2).unpack("n*").map { |offset| offset * 2 }
192
+ else
193
+ @data.byteslice(t, (@num_glyphs + 1) * 4).unpack("N*")
194
+ end
195
+ @glyf_offset = table_offset("glyf")
196
+ end
197
+
198
+ def glyph_data(gid)
199
+ return "".b if gid >= @num_glyphs
200
+ @data.byteslice(@glyf_offset + @loca[gid], @loca[gid + 1] - @loca[gid])
201
+ end
202
+
203
+ def glyph_closure(gids)
204
+ glyphs = Set.new
205
+ queue = gids.to_a
206
+ until queue.empty?
207
+ gid = queue.pop
208
+ next if gid >= @num_glyphs || !glyphs.add?(gid)
209
+ each_component(glyph_data(gid)) { |_, component| queue << component }
210
+ end
211
+ glyphs
212
+ end
213
+
214
+ # Points composite glyphs at the renumbered IDs of their components
215
+ def remap_components(data, mapping)
216
+ copy = nil
217
+ each_component(data) do |pos, component|
218
+ copy ||= data.dup
219
+ copy[pos, 2] = [mapping.fetch(component, 0)].pack("n")
220
+ end
221
+ copy || data
222
+ end
223
+
224
+ # Composite glyphs reference other glyphs. Yields the byte offset of each
225
+ # component's glyph ID and the ID.
226
+ def each_component(data)
227
+ return if data.bytesize < 10 || data.unpack1("s>") >= 0
228
+
229
+ pos = 10
230
+ loop do
231
+ flags, component = data.byteslice(pos, 4).unpack("nn")
232
+ yield pos + 2, component
233
+ pos += 4
234
+ pos += (flags & 0x0001).zero? ? 2 : 4 # ARG_1_AND_2_ARE_WORDS
235
+ if flags & 0x0008 != 0 # WE_HAVE_A_SCALE
236
+ pos += 2
237
+ elsif flags & 0x0040 != 0 # WE_HAVE_AN_X_AND_Y_SCALE
238
+ pos += 4
239
+ elsif flags & 0x0080 != 0 # WE_HAVE_A_TWO_BY_TWO
240
+ pos += 8
241
+ end
242
+ break if (flags & 0x0020).zero? # MORE_COMPONENTS
243
+ end
244
+ end
245
+
246
+ def parse_cmap
247
+ t = table_offset("cmap")
248
+ subtables = Array.new(u16(t + 2)) do |i|
249
+ record = t + 4 + i * 8
250
+ offset = t + u32(record + 4)
251
+ [u16(record), u16(record + 2), offset, u16(offset)]
252
+ end
253
+
254
+ # Prefer full Unicode (format 12) over the Basic Multilingual Plane only (format 4)
255
+ best = subtables.find { |platform, encoding, _, format| platform == 3 && encoding == 10 && format == 12 } ||
256
+ subtables.find { |platform, _, _, format| platform == 0 && format == 12 } ||
257
+ subtables.find { |platform, encoding, _, format| platform == 3 && encoding == 1 && format == 4 } ||
258
+ subtables.find { |platform, _, _, format| platform == 0 && format == 4 }
259
+ raise UnsupportedFontError, "font has no Unicode character map" unless best
260
+
261
+ @cmap = (best[3] == 12) ? parse_cmap_format12(best[2]) : parse_cmap_format4(best[2])
262
+ end
263
+
264
+ def parse_cmap_format4(offset)
265
+ map = {}
266
+ seg_count = u16(offset + 6) / 2
267
+ ends = offset + 14
268
+ starts = ends + seg_count * 2 + 2
269
+ deltas = starts + seg_count * 2
270
+ range_offsets = deltas + seg_count * 2
271
+
272
+ seg_count.times do |i|
273
+ first = u16(starts + i * 2)
274
+ last = u16(ends + i * 2)
275
+ delta = u16(deltas + i * 2)
276
+ range_offset_pos = range_offsets + i * 2
277
+ range_offset = u16(range_offset_pos)
278
+
279
+ (first..last).each do |codepoint|
280
+ next if codepoint == 0xFFFF
281
+ gid = if range_offset.zero?
282
+ (codepoint + delta) & 0xFFFF
283
+ else
284
+ glyph = u16(range_offset_pos + range_offset + (codepoint - first) * 2)
285
+ glyph.zero? ? 0 : (glyph + delta) & 0xFFFF
286
+ end
287
+ map[codepoint] = gid unless gid.zero?
288
+ end
289
+ end
290
+ map
291
+ end
292
+
293
+ def parse_cmap_format12(offset)
294
+ map = {}
295
+ u32(offset + 12).times do |i|
296
+ group = offset + 16 + i * 12
297
+ first = u32(group)
298
+ last = u32(group + 4)
299
+ gid = u32(group + 8)
300
+ (first..last).each { |codepoint| map[codepoint] = gid + codepoint - first }
301
+ end
302
+ map
303
+ end
304
+
305
+ def parse_post
306
+ if (t = table_offset("post"))
307
+ @italic_angle = i16(t + 4) + u16(t + 6) / 65536.0
308
+ @underline_position = i16(t + 8)
309
+ @underline_thickness = i16(t + 10)
310
+ @fixed_pitch = u32(t + 12) != 0
311
+ else
312
+ @italic_angle = 0
313
+ @underline_position = -@units_per_em / 10
314
+ @underline_thickness = @units_per_em / 20
315
+ @fixed_pitch = false
316
+ end
317
+ end
318
+
319
+ def parse_os2
320
+ if (t = table_offset("OS/2"))
321
+ version = u16(t)
322
+ @weight = u16(t + 4)
323
+ @strikeout_size = i16(t + 26)
324
+ @strikeout_position = i16(t + 28)
325
+ if version >= 2
326
+ @x_height = i16(t + 86)
327
+ @cap_height = i16(t + 88)
328
+ end
329
+ end
330
+
331
+ @weight ||= 400
332
+ @strikeout_size ||= @underline_thickness
333
+ @strikeout_position ||= (@ascender * 0.25).round
334
+ @x_height ||= (@ascender * 0.5).round
335
+ @cap_height ||= (@ascender * 0.7).round
336
+ end
337
+
338
+ def parse_name
339
+ if (t = table_offset("name"))
340
+ strings = t + u16(t + 4)
341
+ u16(t + 2).times do |i|
342
+ record = t + 6 + i * 12
343
+ next unless u16(record + 6) == 6 # PostScript name
344
+
345
+ platform = u16(record)
346
+ raw = @data.byteslice(strings + u16(record + 10), u16(record + 8)).dup
347
+ name = if platform == 1
348
+ raw.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
349
+ else
350
+ raw.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
351
+ end
352
+ @postscript_name = name.delete("^!-~").delete("[](){}<>/%#")
353
+ break unless @postscript_name.empty?
354
+ end
355
+ end
356
+
357
+ @postscript_name = "Font" if @postscript_name.nil? || @postscript_name.empty?
358
+ end
359
+
360
+ def build_font(tables)
361
+ tags = tables.keys.sort
362
+ entry_selector = Math.log2(tags.size).floor
363
+ search_range = 2**entry_selector * 16
364
+
365
+ directory = [0x00010000, tags.size, search_range, entry_selector, tags.size * 16 - search_range].pack("Nnnnn")
366
+ body = String.new(encoding: Encoding::BINARY)
367
+ body_offset = 12 + tags.size * 16
368
+ head_offset = nil
369
+
370
+ tags.each do |tag|
371
+ data = tables[tag]
372
+ head_offset = body_offset + body.bytesize if tag == "head"
373
+ directory << [tag, checksum(data), body_offset + body.bytesize, data.bytesize].pack("a4NNN")
374
+ body << data << padding(data)
375
+ end
376
+
377
+ font = directory << body
378
+ font[head_offset + 8, 4] = [(0xB1B0AFBA - checksum(font)) & 0xFFFFFFFF].pack("N")
379
+ font
380
+ end
381
+
382
+ def checksum(data)
383
+ (data + padding(data)).unpack("N*").sum & 0xFFFFFFFF
384
+ end
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,119 @@
1
+ require "zlib"
2
+ require "digest/md5"
3
+
4
+ module Receipts
5
+ module PDF
6
+ # An indirect object reference: `12 0 R`
7
+ Reference = Struct.new(:id)
8
+
9
+ # A string written in hex form: `<48656C6C6F>`
10
+ HexString = Struct.new(:bytes)
11
+
12
+ class Stream
13
+ attr_reader :dictionary, :data
14
+
15
+ # Data is Flate compressed unless the dictionary already declares a filter
16
+ def initialize(data, dictionary = {})
17
+ data = data.b
18
+ dictionary = dictionary.dup
19
+
20
+ unless dictionary.key?(:Filter)
21
+ data = Zlib::Deflate.deflate(data)
22
+ dictionary[:Filter] = :FlateDecode
23
+ end
24
+
25
+ dictionary[:Length] = data.bytesize
26
+ @data = data
27
+ @dictionary = dictionary
28
+ end
29
+ end
30
+
31
+ # Serializes Ruby values into PDF syntax
32
+ module Serializer
33
+ NAME_ESCAPE = /[^\x21-\x7E]|[#%()\/<>\[\]{}]/n
34
+
35
+ module_function
36
+
37
+ def dump(value)
38
+ case value
39
+ when Reference then "#{value.id} 0 R"
40
+ when Symbol then name(value)
41
+ when Hash then "<<" + value.map { |k, v| "#{name(k)} #{dump(v)}" }.join(" ") + ">>"
42
+ when Array then "[" + value.map { |v| dump(v) }.join(" ") + "]"
43
+ when HexString then "<#{value.bytes.unpack1("H*")}>"
44
+ when String then literal(value)
45
+ when Integer then value.to_s
46
+ when Float then number(value)
47
+ when true, false then value.to_s
48
+ when nil then "null"
49
+ else raise ArgumentError, "cannot serialize #{value.class} to PDF"
50
+ end
51
+ end
52
+
53
+ def name(value)
54
+ "/" + value.to_s.b.gsub(NAME_ESCAPE) { |c| format("#%02X", c.ord) }
55
+ end
56
+
57
+ def literal(value)
58
+ "(" + value.b.gsub(/[\\()\r]/n) { |c| (c == "\r") ? "\\r" : "\\#{c}" } + ")"
59
+ end
60
+
61
+ def number(value)
62
+ return value.to_s if value.is_a?(Integer)
63
+ str = format("%.4f", value).sub(/\.?0+\z/, "")
64
+ (str == "-0") ? "0" : str
65
+ end
66
+
67
+ # Text strings (document info, etc) must be PDFDocEncoding or UTF-16BE with a BOM
68
+ def text_string(value)
69
+ value = value.to_s
70
+ value.ascii_only? ? value.b : "\xFE\xFF".b + value.encode("UTF-16BE").b
71
+ end
72
+ end
73
+
74
+ # Assembles numbered objects into a PDF file with a cross-reference table
75
+ class Writer
76
+ def initialize
77
+ @objects = []
78
+ end
79
+
80
+ def reserve
81
+ @objects << nil
82
+ Reference.new(@objects.size)
83
+ end
84
+
85
+ def set(ref, value)
86
+ @objects[ref.id - 1] = value
87
+ ref
88
+ end
89
+
90
+ def add(value)
91
+ set(reserve, value)
92
+ end
93
+
94
+ def render(root:, info:)
95
+ out = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
96
+
97
+ offsets = @objects.each_with_index.map do |object, index|
98
+ offset = out.bytesize
99
+ out << "#{index + 1} 0 obj\n"
100
+ if object.is_a?(Stream)
101
+ out << Serializer.dump(object.dictionary) << "\nstream\n" << object.data << "\nendstream"
102
+ else
103
+ out << Serializer.dump(object)
104
+ end
105
+ out << "\nendobj\n"
106
+ offset
107
+ end
108
+
109
+ xref = out.bytesize
110
+ id = HexString.new(Digest::MD5.digest(out))
111
+
112
+ out << "xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n"
113
+ offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
114
+ out << "trailer\n" << Serializer.dump(Size: @objects.size + 1, Root: root, Info: info, ID: [id, id])
115
+ out << "\nstartxref\n#{xref}\n%%EOF\n"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -1,3 +1,3 @@
1
1
  module Receipts
2
- VERSION = "2.3.0"
2
+ VERSION = "3.0.0"
3
3
  end
data/lib/receipts.rb CHANGED
@@ -1,7 +1,14 @@
1
1
  require "receipts/version"
2
2
  require "open-uri"
3
- require "prawn"
4
- require "prawn/table"
3
+ require "receipts/pdf/geometry"
4
+ require "receipts/pdf/writer"
5
+ require "receipts/pdf/true_type"
6
+ require "receipts/pdf/font"
7
+ require "receipts/pdf/image"
8
+ require "receipts/pdf/inline_format"
9
+ require "receipts/pdf/text_layout"
10
+ require "receipts/pdf/table"
11
+ require "receipts/pdf/document"
5
12
 
6
13
  module Receipts
7
14
  autoload :Base, "receipts/base"