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,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
|
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"
|