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.
- checksums.yaml +4 -4
- data/.standard.yml +1 -0
- data/CHANGELOG.md +13 -1
- data/Gemfile +0 -1
- data/README.md +29 -10
- data/examples/invoice.pdf +0 -0
- data/examples/receipt.pdf +0 -0
- data/examples/statement.pdf +0 -0
- data/lib/receipts/base.rb +3 -7
- 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,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
|
data/lib/receipts/version.rb
CHANGED
data/lib/receipts.rb
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
require "receipts/version"
|
|
2
2
|
require "open-uri"
|
|
3
|
-
require "
|
|
4
|
-
require "
|
|
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"
|
metadata
CHANGED
|
@@ -1,49 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: receipts
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Oliver
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
12
|
-
dependencies:
|
|
13
|
-
- !ruby/object:Gem::Dependency
|
|
14
|
-
name: prawn
|
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
|
16
|
-
requirements:
|
|
17
|
-
- - ">="
|
|
18
|
-
- !ruby/object:Gem::Version
|
|
19
|
-
version: 1.3.0
|
|
20
|
-
- - "<"
|
|
21
|
-
- !ruby/object:Gem::Version
|
|
22
|
-
version: 3.0.0
|
|
23
|
-
type: :runtime
|
|
24
|
-
prerelease: false
|
|
25
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
-
requirements:
|
|
27
|
-
- - ">="
|
|
28
|
-
- !ruby/object:Gem::Version
|
|
29
|
-
version: 1.3.0
|
|
30
|
-
- - "<"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: 3.0.0
|
|
33
|
-
- !ruby/object:Gem::Dependency
|
|
34
|
-
name: prawn-table
|
|
35
|
-
requirement: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.2.1
|
|
40
|
-
type: :runtime
|
|
41
|
-
prerelease: false
|
|
42
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - "~>"
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: 0.2.1
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
47
12
|
description: Receipts for your Rails application that works with any payment provider.
|
|
48
13
|
email:
|
|
49
14
|
- excid3@gmail.com
|
|
@@ -51,6 +16,7 @@ executables: []
|
|
|
51
16
|
extensions: []
|
|
52
17
|
extra_rdoc_files: []
|
|
53
18
|
files:
|
|
19
|
+
- ".standard.yml"
|
|
54
20
|
- CHANGELOG.md
|
|
55
21
|
- Gemfile
|
|
56
22
|
- LICENSE.txt
|
|
@@ -63,11 +29,22 @@ files:
|
|
|
63
29
|
- examples/statement.pdf
|
|
64
30
|
- lib/receipts.rb
|
|
65
31
|
- lib/receipts/base.rb
|
|
32
|
+
- lib/receipts/fonts/Inter-Bold.ttf
|
|
33
|
+
- lib/receipts/fonts/Inter-Regular.ttf
|
|
34
|
+
- lib/receipts/fonts/OFL.txt
|
|
66
35
|
- lib/receipts/invoice.rb
|
|
36
|
+
- lib/receipts/pdf/document.rb
|
|
37
|
+
- lib/receipts/pdf/font.rb
|
|
38
|
+
- lib/receipts/pdf/geometry.rb
|
|
39
|
+
- lib/receipts/pdf/image.rb
|
|
40
|
+
- lib/receipts/pdf/inline_format.rb
|
|
41
|
+
- lib/receipts/pdf/table.rb
|
|
42
|
+
- lib/receipts/pdf/text_layout.rb
|
|
43
|
+
- lib/receipts/pdf/true_type.rb
|
|
44
|
+
- lib/receipts/pdf/writer.rb
|
|
67
45
|
- lib/receipts/receipt.rb
|
|
68
46
|
- lib/receipts/statement.rb
|
|
69
47
|
- lib/receipts/version.rb
|
|
70
|
-
- receipts.gemspec
|
|
71
48
|
homepage: https://github.com/excid3/receipts
|
|
72
49
|
licenses:
|
|
73
50
|
- MIT
|
|
@@ -75,7 +52,6 @@ metadata:
|
|
|
75
52
|
homepage_uri: https://github.com/excid3/receipts
|
|
76
53
|
source_code_uri: https://github.com/excid3/receipts
|
|
77
54
|
changelog_uri: https://github.com/excid3/receipts/blob/master/CHANGELOG.md
|
|
78
|
-
post_install_message:
|
|
79
55
|
rdoc_options: []
|
|
80
56
|
require_paths:
|
|
81
57
|
- lib
|
|
@@ -83,15 +59,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
83
59
|
requirements:
|
|
84
60
|
- - ">="
|
|
85
61
|
- !ruby/object:Gem::Version
|
|
86
|
-
version: '
|
|
62
|
+
version: '2.7'
|
|
87
63
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
64
|
requirements:
|
|
89
65
|
- - ">="
|
|
90
66
|
- !ruby/object:Gem::Version
|
|
91
67
|
version: '0'
|
|
92
68
|
requirements: []
|
|
93
|
-
rubygems_version:
|
|
94
|
-
signing_key:
|
|
69
|
+
rubygems_version: 4.0.21
|
|
95
70
|
specification_version: 4
|
|
96
71
|
summary: Receipts for your Rails application that works with any payment provider.
|
|
97
72
|
test_files: []
|
data/receipts.gemspec
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/receipts/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "receipts"
|
|
7
|
-
spec.version = Receipts::VERSION
|
|
8
|
-
spec.authors = ["Chris Oliver"]
|
|
9
|
-
spec.email = ["excid3@gmail.com"]
|
|
10
|
-
|
|
11
|
-
spec.summary = "Receipts for your Rails application that works with any payment provider."
|
|
12
|
-
spec.description = "Receipts for your Rails application that works with any payment provider."
|
|
13
|
-
spec.homepage = "https://github.com/excid3/receipts"
|
|
14
|
-
spec.license = "MIT"
|
|
15
|
-
|
|
16
|
-
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
-
spec.metadata["source_code_uri"] = spec.homepage
|
|
18
|
-
spec.metadata["changelog_uri"] = "https://github.com/excid3/receipts/blob/master/CHANGELOG.md"
|
|
19
|
-
|
|
20
|
-
# Specify which files should be added to the gem when it is released.
|
|
21
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
22
|
-
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
23
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
24
|
-
(f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
spec.bindir = "exe"
|
|
28
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
29
|
-
spec.require_paths = ["lib"]
|
|
30
|
-
|
|
31
|
-
spec.add_dependency "prawn", ">= 1.3.0", "< 3.0.0"
|
|
32
|
-
spec.add_dependency "prawn-table", "~> 0.2.1"
|
|
33
|
-
end
|