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