webtranslateit-payday 1.7.4 → 2.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/CHANGELOG.md +25 -0
- data/README.md +18 -3
- data/lib/payday/config.rb +4 -4
- data/lib/payday/invoice_presenter.rb +162 -0
- data/lib/payday/markup.rb +61 -0
- data/lib/payday/pdf_renderer.rb +54 -323
- data/lib/payday/qr_code.rb +99 -0
- data/lib/payday/templates/invoice.typ +171 -0
- data/lib/payday.rb +5 -5
- data/payday.gemspec +9 -6
- metadata +18 -83
- data/.gitattributes +0 -1
- data/.github/dependabot.yml +0 -28
- data/.github/release-drafter.yml +0 -36
- data/.github/workflows/ci.yml +0 -47
- data/.github/workflows/release-drafter.yml +0 -24
- data/.gitignore +0 -8
- data/.rubocop.yml +0 -16
- data/.rubocop_todo.yml +0 -0
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -210
- data/Guardfile +0 -12
- data/Rakefile +0 -14
- data/spec/assets/example_invoice_with_qr.pdf +0 -0
- data/spec/assets/svg.pdf +0 -2568
- data/spec/assets/testing.pdf +1 -14241
- data/spec/assets/testing_es.pdf +0 -0
- data/spec/assets/testing_predefined_amount.pdf +0 -0
- data/spec/assets/tiger.svg +0 -52
- data/spec/invoice_spec.rb +0 -403
- data/spec/line_item_spec.rb +0 -34
- data/spec/pdf_renderer_spec.rb +0 -15
- data/spec/spec_helper.rb +0 -11
- data/spec/support/asset_matchers.rb +0 -28
- /data/{spec → lib/payday}/assets/default_logo.png +0 -0
data/lib/payday/pdf_renderer.rb
CHANGED
|
@@ -2,361 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
module Payday
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
5
|
+
# Renders an invoice to PDF by feeding a presented Hash to the Typst template.
|
|
6
|
+
#
|
|
7
|
+
# Invoice data crosses into the template only as sys_inputs JSON, where Typst treats every
|
|
8
|
+
# string as literal text. Never interpolate invoice data into the template source.
|
|
9
|
+
class PdfRenderer
|
|
10
|
+
|
|
11
|
+
TEMPLATE = File.expand_path('templates/invoice.typ', __dir__)
|
|
12
|
+
FONT_DIR = File.expand_path('../../fonts', __dir__)
|
|
13
|
+
FONTS = %w[NotoSans-Regular.ttf NotoSans-Bold.ttf].freeze
|
|
8
14
|
|
|
9
15
|
# Renders the given invoice as a pdf on disk
|
|
10
16
|
def self.render_to_file(invoice, path)
|
|
11
|
-
|
|
17
|
+
File.binwrite(path, render(invoice))
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
# Renders the given invoice as a pdf, returning a string
|
|
15
21
|
def self.render(invoice)
|
|
16
|
-
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def self.pdf(invoice) # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
|
|
20
|
-
pdf = Prawn::Document.new(page_size: invoice_or_default(invoice, :page_size))
|
|
21
|
-
|
|
22
|
-
font_dir = File.join(File.dirname(__dir__), '..', 'fonts')
|
|
23
|
-
pdf.font_families.update(
|
|
24
|
-
'NotoSans' => {normal: File.join(font_dir, 'NotoSans-Regular.ttf'),
|
|
25
|
-
bold: File.join(font_dir, 'NotoSans-Bold.ttf')}
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
# set up some default styling
|
|
29
|
-
pdf.font_size(10)
|
|
30
|
-
pdf.font 'NotoSans'
|
|
31
|
-
|
|
32
|
-
stamp(invoice, pdf)
|
|
33
|
-
company_banner(invoice, pdf)
|
|
34
|
-
bill_to_ship_to(invoice, pdf)
|
|
35
|
-
invoice_details(invoice, pdf)
|
|
36
|
-
line_items_table(invoice, pdf)
|
|
37
|
-
totals_lines(invoice, pdf)
|
|
38
|
-
notes(invoice, pdf)
|
|
39
|
-
render_qr_code(invoice, pdf) unless defined?(invoice.notes) && invoice.notes
|
|
40
|
-
|
|
41
|
-
page_numbers(pdf)
|
|
42
|
-
|
|
43
|
-
pdf
|
|
22
|
+
new(invoice).render
|
|
44
23
|
end
|
|
45
24
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
if stamp
|
|
57
|
-
pdf.bounding_box([150, pdf.cursor - 50], width: pdf.bounds.width - 300) do
|
|
58
|
-
pdf.font('NotoSans') do
|
|
59
|
-
pdf.fill_color 'cc0000'
|
|
60
|
-
pdf.text stamp, align: :center, size: 25, rotate: 15, style: :bold
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
pdf.fill_color '000000'
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# rubocop:todo Metrics/MethodLength
|
|
69
|
-
def self.company_banner(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
70
|
-
# render the logo
|
|
71
|
-
image = invoice_or_default(invoice, :invoice_logo)
|
|
72
|
-
height = nil
|
|
73
|
-
width = nil
|
|
74
|
-
|
|
75
|
-
# Handle images defined with a hash of options
|
|
76
|
-
if image.is_a?(Hash)
|
|
77
|
-
data = image
|
|
78
|
-
image = data[:filename]
|
|
79
|
-
width, height = data[:size].split('x').map(&:to_f)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
if File.extname(image) == '.svg'
|
|
83
|
-
# Logos are read from local files, so there is nothing to fetch over the network.
|
|
84
|
-
# Passing this explicitly also silences prawn-svg's deprecation warning and keeps
|
|
85
|
-
# behaviour stable when prawn-svg 1.0 flips the default to false.
|
|
86
|
-
logo_info = pdf.svg(File.read(image), at: pdf.bounds.top_left, width: width, height: height,
|
|
87
|
-
enable_web_requests: false)
|
|
88
|
-
logo_height = logo_info[:height]
|
|
89
|
-
else
|
|
90
|
-
logo_info = pdf.image(image, at: pdf.bounds.top_left, width: width, height: height)
|
|
91
|
-
logo_height = logo_info.scaled_height
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# render the company details
|
|
95
|
-
table_data = []
|
|
96
|
-
table_data << [bold_cell(pdf, invoice_or_default(invoice, :company_name).strip, size: 12)]
|
|
97
|
-
|
|
98
|
-
invoice_or_default(invoice, :company_details).lines.each { |line| table_data << [line] }
|
|
99
|
-
|
|
100
|
-
table = pdf.make_table(table_data, cell_style: {borders: [], padding: 0})
|
|
101
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.bounds.top], width: table.width,
|
|
102
|
-
height: table.height + 5) do
|
|
103
|
-
table.draw
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
pdf.move_cursor_to(pdf.bounds.top - logo_height - 20)
|
|
107
|
-
end
|
|
108
|
-
# rubocop:enable Metrics/MethodLength
|
|
109
|
-
|
|
110
|
-
# rubocop:todo Metrics/MethodLength
|
|
111
|
-
def self.bill_to_ship_to(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
112
|
-
bill_to_cell_style = {borders: [], padding: [2, 0]}
|
|
113
|
-
bill_to_ship_to_bottom = 0
|
|
114
|
-
|
|
115
|
-
# render bill to
|
|
116
|
-
pdf.float do
|
|
117
|
-
pdf.table([[bold_cell(pdf, I18n.t('payday.invoice.bill_to', default: 'Bill To'))],
|
|
118
|
-
[invoice.bill_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
119
|
-
bill_to_ship_to_bottom = pdf.cursor
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# render ship to
|
|
123
|
-
if defined?(invoice.ship_to) && !invoice.ship_to.nil?
|
|
124
|
-
table = pdf.make_table([[bold_cell(pdf, I18n.t('payday.invoice.ship_to', default: 'Ship To'))],
|
|
125
|
-
[invoice.ship_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
126
|
-
|
|
127
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor], width: table.width, height: table.height + 2) do
|
|
128
|
-
table.draw
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# make sure we start at the lower of the bill_to or ship_to details
|
|
133
|
-
bill_to_ship_to_bottom = pdf.cursor if pdf.cursor < bill_to_ship_to_bottom
|
|
134
|
-
pdf.move_cursor_to(bill_to_ship_to_bottom - 20)
|
|
135
|
-
end
|
|
136
|
-
# rubocop:enable Metrics/MethodLength
|
|
137
|
-
|
|
138
|
-
# rubocop:todo Metrics/PerceivedComplexity
|
|
139
|
-
# rubocop:todo Metrics/MethodLength
|
|
140
|
-
# rubocop:todo Metrics/AbcSize
|
|
141
|
-
def self.invoice_details(invoice, pdf) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
142
|
-
# invoice details
|
|
143
|
-
table_data = []
|
|
144
|
-
|
|
145
|
-
# invoice number
|
|
146
|
-
if defined?(invoice.invoice_number) && invoice.invoice_number
|
|
147
|
-
table_data << if invoice.paid?
|
|
148
|
-
[bold_cell(pdf, I18n.t('payday.invoice.receipt_no', default: 'Receipt #:')),
|
|
149
|
-
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
150
|
-
else
|
|
151
|
-
[bold_cell(pdf, I18n.t('payday.invoice.invoice_no', default: 'Invoice #:')),
|
|
152
|
-
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Due on
|
|
157
|
-
if defined?(invoice.due_at) && invoice.due_at
|
|
158
|
-
due_date = if invoice.due_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
159
|
-
invoice.due_at.strftime(Payday::Config.default.date_format)
|
|
160
|
-
else
|
|
161
|
-
invoice.due_at.to_s
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
table_data << [bold_cell(pdf, I18n.t('payday.invoice.due_date', default: 'Due Date:')),
|
|
165
|
-
bold_cell(pdf, due_date, align: :right)]
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Paid on
|
|
169
|
-
if defined?(invoice.paid_at) && invoice.paid_at
|
|
170
|
-
paid_date = if invoice.paid_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
171
|
-
invoice.paid_at.strftime(Payday::Config.default.date_format)
|
|
172
|
-
else
|
|
173
|
-
invoice.paid_at.to_s
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
table_data << [bold_cell(pdf, I18n.t('payday.invoice.paid_date', default: 'Paid Date:')),
|
|
177
|
-
bold_cell(pdf, paid_date, align: :right)]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# loop through invoice_details and include them
|
|
181
|
-
invoice.each_detail do |key, value|
|
|
182
|
-
table_data << [bold_cell(pdf, key),
|
|
183
|
-
bold_cell(pdf, value, align: :right)]
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
return unless table_data.length.positive?
|
|
187
|
-
|
|
188
|
-
pdf.table(table_data, cell_style: {borders: [], padding: [1, 10, 1, 1]})
|
|
25
|
+
# Converts this number to a formatted currency string
|
|
26
|
+
def self.number_to_currency(number, invoice)
|
|
27
|
+
Money.locale_backend = :currency
|
|
28
|
+
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
|
|
29
|
+
currency = Money::Currency.wrap(currency_for(invoice))
|
|
30
|
+
number *= currency.subunit_to_unit
|
|
31
|
+
number = number.round unless Money.default_infinite_precision
|
|
32
|
+
Money.new(number, currency).format
|
|
189
33
|
end
|
|
190
|
-
# rubocop:enable Metrics/AbcSize
|
|
191
|
-
# rubocop:enable Metrics/MethodLength
|
|
192
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
193
34
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
table_data = []
|
|
197
|
-
table_data << [bold_cell(pdf, I18n.t('payday.line_item.description', default: 'Description'), borders: []),
|
|
198
|
-
bold_cell(pdf, I18n.t('payday.line_item.unit_price', default: 'Unit Price'), align: :center,
|
|
199
|
-
borders: []),
|
|
200
|
-
bold_cell(pdf, I18n.t('payday.line_item.quantity', default: 'Quantity'), align: :center,
|
|
201
|
-
borders: []),
|
|
202
|
-
bold_cell(pdf, I18n.t('payday.line_item.amount', default: 'Amount'), align: :center, borders: [])]
|
|
203
|
-
invoice.line_items.each do |line|
|
|
204
|
-
table_data << if line.predefined_amount
|
|
205
|
-
[line.description, '', '', number_to_currency(line.predefined_amount, invoice)]
|
|
206
|
-
else
|
|
207
|
-
[line.description,
|
|
208
|
-
line.display_price || number_to_currency(line.price, invoice),
|
|
209
|
-
line.display_quantity || BigDecimal(line.quantity.to_s).to_s('F'),
|
|
210
|
-
number_to_currency(line.amount, invoice)]
|
|
211
|
-
end
|
|
212
|
-
end
|
|
35
|
+
def self.currency_for(invoice)
|
|
36
|
+
return invoice.currency if invoice.respond_to?(:currency) && invoice.currency
|
|
213
37
|
|
|
214
|
-
|
|
215
|
-
pdf.table(table_data, width: pdf.bounds.width, header: true,
|
|
216
|
-
cell_style: {border_width: 0.5, border_left_color: 'FFFFFF', border_right_color: 'FFFFFF',
|
|
217
|
-
border_top_color: 'F6F9FC', border_bottom_color: 'BCC6D0', padding: [5, 10],
|
|
218
|
-
inline_format: true},
|
|
219
|
-
row_colors: %w[F6F9FC ffffff]) do
|
|
220
|
-
# left align the number columns
|
|
221
|
-
columns(1..3).rows(1..(row_length - 1)).style(align: :right)
|
|
222
|
-
|
|
223
|
-
# set the column widths correctly
|
|
224
|
-
natural = natural_column_widths
|
|
225
|
-
natural[0] = width - natural[1] - natural[2] - natural[3]
|
|
226
|
-
end
|
|
38
|
+
Payday::Config.default.currency
|
|
227
39
|
end
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# rubocop:todo Metrics/PerceivedComplexity
|
|
231
|
-
# rubocop:todo Metrics/MethodLength
|
|
232
|
-
def self.totals_lines(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
233
|
-
table_data = []
|
|
234
|
-
table_data << [
|
|
235
|
-
bold_cell(pdf, I18n.t('payday.invoice.subtotal', default: 'Subtotal:')),
|
|
236
|
-
cell(pdf, number_to_currency(invoice.subtotal, invoice), align: :right)
|
|
237
|
-
]
|
|
238
|
-
|
|
239
|
-
tax_description = if invoice.tax_description.nil?
|
|
240
|
-
I18n.t('payday.invoice.tax', default: 'Tax:')
|
|
241
|
-
else
|
|
242
|
-
invoice.tax_description
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
table_data << [
|
|
246
|
-
bold_cell(pdf, tax_description),
|
|
247
|
-
cell(pdf, number_to_currency(invoice.tax, invoice), align: :right)
|
|
248
|
-
]
|
|
249
|
-
|
|
250
|
-
if invoice.shipping_rate.positive?
|
|
251
|
-
shipping_description = if invoice.shipping_description.nil?
|
|
252
|
-
I18n.t('payday.invoice.shipping', default: 'Shipping:')
|
|
253
|
-
else
|
|
254
|
-
invoice.shipping_description
|
|
255
|
-
end
|
|
40
|
+
private_class_method :currency_for
|
|
256
41
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
align: :right)
|
|
261
|
-
]
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
if invoice.retention_rate.positive?
|
|
265
|
-
retention_description = if invoice.retention_description.nil?
|
|
266
|
-
I18n.t('payday.invoice.retention', default: 'Retention:')
|
|
267
|
-
else
|
|
268
|
-
invoice.retention_description
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
table_data << [
|
|
272
|
-
bold_cell(pdf, retention_description),
|
|
273
|
-
cell(pdf, number_to_currency(-invoice.retention, invoice),
|
|
274
|
-
align: :right)
|
|
275
|
-
]
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
table_data << [
|
|
279
|
-
bold_cell(pdf, I18n.t('payday.invoice.total', default: 'Total:'),
|
|
280
|
-
size: 12),
|
|
281
|
-
cell(pdf, number_to_currency(invoice.total, invoice),
|
|
282
|
-
size: 12, align: :right)
|
|
283
|
-
]
|
|
284
|
-
table = pdf.make_table(table_data, cell_style: {borders: []})
|
|
285
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor],
|
|
286
|
-
width: table.width, height: table.height + 2) do
|
|
287
|
-
table.draw
|
|
288
|
-
end
|
|
42
|
+
def initialize(invoice)
|
|
43
|
+
@invoice = invoice
|
|
44
|
+
@dependencies = {}
|
|
289
45
|
end
|
|
290
|
-
# rubocop:enable Metrics/MethodLength
|
|
291
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
292
|
-
|
|
293
|
-
def self.notes(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
294
|
-
return unless defined?(invoice.notes) && invoice.notes
|
|
295
46
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
pdf.line_width = 0.5
|
|
301
|
-
pdf.stroke_color = 'cccccc'
|
|
302
|
-
pdf.stroke_line([0, pdf.cursor - 3, pdf.bounds.width, pdf.cursor - 3])
|
|
303
|
-
pdf.move_cursor_to(pdf.cursor - 10)
|
|
304
|
-
pdf.text(invoice.notes.to_s, inline_format: true)
|
|
47
|
+
def render
|
|
48
|
+
data = InvoicePresenter.new(@invoice).to_h
|
|
49
|
+
register_qr_code(data[:qr_code])
|
|
50
|
+
data[:logo] = logo
|
|
305
51
|
|
|
306
|
-
|
|
52
|
+
Typst(body: File.read(TEMPLATE), dependencies: @dependencies, fonts: fonts,
|
|
53
|
+
sys_inputs: {'invoice' => data.to_json})
|
|
54
|
+
.compile(:pdf).bytes.flatten.pack('C*')
|
|
307
55
|
end
|
|
308
56
|
|
|
309
|
-
|
|
310
|
-
return unless defined?(invoice.qr_code) && invoice.qr_code.to_s.strip.present?
|
|
57
|
+
private
|
|
311
58
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
pdf.move_cursor_to(pdf.cursor - 10)
|
|
315
|
-
qr = RQRCode::QRCode.new(invoice.qr_code.to_s)
|
|
316
|
-
png = qr.as_png(size: 200)
|
|
317
|
-
|
|
318
|
-
pdf.image(StringIO.new(png.to_s), width: 100, position: :left)
|
|
59
|
+
def fonts
|
|
60
|
+
FONTS.to_h { |name| [name, File.binread(File.join(FONT_DIR, name))] }
|
|
319
61
|
end
|
|
320
62
|
|
|
321
|
-
|
|
322
|
-
|
|
63
|
+
# Config#invoice_logo is either a path or a {filename:, size: "WxH"} Hash.
|
|
64
|
+
def logo
|
|
65
|
+
setting = invoice_logo
|
|
66
|
+
return nil if setting.nil?
|
|
323
67
|
|
|
324
|
-
|
|
68
|
+
path, width, height = logo_parts(setting)
|
|
69
|
+
name = "logo#{File.extname(path.to_s)}"
|
|
70
|
+
@dependencies[name] = File.binread(path.to_s)
|
|
71
|
+
{name: name, width: width, height: height}
|
|
325
72
|
end
|
|
326
73
|
|
|
327
|
-
def
|
|
328
|
-
if invoice.respond_to?(
|
|
329
|
-
invoice.send(property)
|
|
330
|
-
else
|
|
331
|
-
Payday::Config.default.send(property)
|
|
332
|
-
end
|
|
333
|
-
end
|
|
74
|
+
def invoice_logo
|
|
75
|
+
return @invoice.invoice_logo if @invoice.respond_to?(:invoice_logo) && @invoice.invoice_logo
|
|
334
76
|
|
|
335
|
-
|
|
336
|
-
Prawn::Table::Cell::Text.make(pdf, text, options)
|
|
77
|
+
Payday::Config.default.invoice_logo
|
|
337
78
|
end
|
|
338
79
|
|
|
339
|
-
def
|
|
340
|
-
|
|
341
|
-
end
|
|
80
|
+
def logo_parts(setting)
|
|
81
|
+
return [setting, nil, nil] unless setting.is_a?(Hash)
|
|
342
82
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
Money.locale_backend = :currency
|
|
346
|
-
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
|
|
347
|
-
currency = Money::Currency.wrap(invoice_or_default(invoice, :currency))
|
|
348
|
-
number *= currency.subunit_to_unit
|
|
349
|
-
number = number.round unless Money.default_infinite_precision
|
|
350
|
-
Money.new(number, currency).format
|
|
83
|
+
width, height = setting[:size].to_s.split('x').map(&:to_f)
|
|
84
|
+
[setting[:filename], width, height]
|
|
351
85
|
end
|
|
352
86
|
|
|
353
|
-
def
|
|
354
|
-
|
|
355
|
-
cell_proxy.each do |cell|
|
|
356
|
-
max = cell.natural_content_width if cell.natural_content_width > max
|
|
357
|
-
end
|
|
87
|
+
def register_qr_code(data)
|
|
88
|
+
return if data.nil?
|
|
358
89
|
|
|
359
|
-
|
|
90
|
+
@dependencies['qr.svg'] = QrCode.new(data).to_svg
|
|
360
91
|
end
|
|
361
92
|
|
|
362
93
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rqrcode'
|
|
4
|
+
|
|
5
|
+
module Payday
|
|
6
|
+
|
|
7
|
+
# Draws a QR code as an SVG.
|
|
8
|
+
#
|
|
9
|
+
# The matrix comes straight from RQRCode, so the payload and its error correction level are
|
|
10
|
+
# untouched and the code carries exactly the same data it always did. Only the drawing
|
|
11
|
+
# changes: vector instead of a 200px raster, lightly rounded modules, and the three finder
|
|
12
|
+
# patterns drawn as rounded frames.
|
|
13
|
+
#
|
|
14
|
+
# Rounding is kept deliberately slight. These codes are scanned off paper, sometimes off a
|
|
15
|
+
# bad photocopy, and on a Verifactu invoice a code that will not scan is a compliance
|
|
16
|
+
# problem rather than a cosmetic one. Modules keep their full square footprint apart from a
|
|
17
|
+
# softened corner, so a scanner sees the same geometry the old raster produced.
|
|
18
|
+
class QrCode
|
|
19
|
+
|
|
20
|
+
QUIET_ZONE = 4 # modules of clear space; the QR spec's minimum
|
|
21
|
+
UNIT = 10 # user units per module
|
|
22
|
+
RADIUS = 1.5 # corner radius of a data module, out of UNIT
|
|
23
|
+
FINDER = 7 # finder patterns are 7 modules square
|
|
24
|
+
# Corner radius of a finder frame. Above roughly 1.2 modules the curve starts cutting the
|
|
25
|
+
# corner module out of the ring, which changes what a scanner reads.
|
|
26
|
+
EYE_RADIUS = 10
|
|
27
|
+
DARK = '#111111'
|
|
28
|
+
LIGHT = '#ffffff'
|
|
29
|
+
|
|
30
|
+
def initialize(data)
|
|
31
|
+
@modules = RQRCode::QRCode.new(data.to_s).modules
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_svg
|
|
35
|
+
<<~SVG
|
|
36
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{side} #{side}" shape-rendering="geometricPrecision">
|
|
37
|
+
<rect width="#{side}" height="#{side}" fill="#{LIGHT}"/>
|
|
38
|
+
#{(data_modules + finders).join("\n")}
|
|
39
|
+
</svg>
|
|
40
|
+
SVG
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def size
|
|
46
|
+
@modules.length
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def side
|
|
50
|
+
(size + (QUIET_ZONE * 2)) * UNIT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def data_modules
|
|
54
|
+
@modules.each_with_index.flat_map do |cells, row|
|
|
55
|
+
cells.each_with_index.filter_map do |dark, col|
|
|
56
|
+
module_rect(row, col) if dark && !finder?(row, col)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def module_rect(row, col)
|
|
62
|
+
%(<rect class="m" x="#{x(col)}" y="#{x(row)}" width="#{UNIT}" height="#{UNIT}" ) +
|
|
63
|
+
%(rx="#{RADIUS}" fill="#{DARK}"/>)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A finder is a 7x7 ring with a 3x3 centre. Drawing it as two rounded rects rather than 33
|
|
67
|
+
# separate modules is what gives the code its softer corners.
|
|
68
|
+
def finders
|
|
69
|
+
finder_origins.flat_map { |row, col| [ring(row, col), pupil(row, col)] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ring(row, col)
|
|
73
|
+
%(<rect class="f" x="#{x(col) + (UNIT / 2)}" y="#{x(row) + (UNIT / 2)}" ) +
|
|
74
|
+
%(width="#{UNIT * 6}" height="#{UNIT * 6}" rx="#{EYE_RADIUS}" ) +
|
|
75
|
+
%(fill="none" stroke="#{DARK}" stroke-width="#{UNIT}"/>)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pupil(row, col)
|
|
79
|
+
%(<rect class="p" x="#{x(col + 2)}" y="#{x(row + 2)}" ) +
|
|
80
|
+
%(width="#{UNIT * 3}" height="#{UNIT * 3}" rx="#{UNIT}" fill="#{DARK}"/>)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finder_origins
|
|
84
|
+
[[0, 0], [0, size - FINDER], [size - FINDER, 0]]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def finder?(row, col)
|
|
88
|
+
finder_origins.any? do |r, c|
|
|
89
|
+
row.between?(r, r + FINDER - 1) && col.between?(c, c + FINDER - 1)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def x(index)
|
|
94
|
+
(index + QUIET_ZONE) * UNIT
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Payday's invoice layout.
|
|
2
|
+
//
|
|
3
|
+
// All data arrives as JSON through sys.inputs, where Typst inserts string values as literal
|
|
4
|
+
// text rather than markup. Never interpolate invoice data into this file's source, and never
|
|
5
|
+
// call eval() on a value that came from an invoice.
|
|
6
|
+
|
|
7
|
+
#let d = json(bytes(sys.inputs.invoice))
|
|
8
|
+
|
|
9
|
+
// Gaps between the major blocks, calibrated so the rendered invoice keeps the vertical
|
|
10
|
+
// rhythm the prawn renderer produced. Every within-block gap falls out of the table insets.
|
|
11
|
+
#let gap-after-header = 15pt
|
|
12
|
+
#let gap-after-addresses = 13.2pt
|
|
13
|
+
#let gap-after-details = 8pt
|
|
14
|
+
#let gap-before-totals = -12pt
|
|
15
|
+
#let gap-before-notes = 22.3pt
|
|
16
|
+
#let gap-notes-label-to-rule = -7.1pt
|
|
17
|
+
#let gap-after-notes-rule = -0.4pt
|
|
18
|
+
#let gap-before-qr = -0.6pt
|
|
19
|
+
|
|
20
|
+
// Prawn page-size names mapped onto Typst paper names.
|
|
21
|
+
#let papers = ("LETTER": "us-letter", "A4": "a4", "LEGAL": "us-legal")
|
|
22
|
+
|
|
23
|
+
#set document(title: "Invoice", date: none)
|
|
24
|
+
#set page(
|
|
25
|
+
paper: papers.at(d.page_size, default: "a4"),
|
|
26
|
+
margin: 36pt, // prawn's default half-inch page margin
|
|
27
|
+
numbering: none,
|
|
28
|
+
// Page numbers appear only when the invoice runs to more than one page. This has to be a
|
|
29
|
+
// footer rather than a trailing `set page`, which would only affect the final page.
|
|
30
|
+
footer: context {
|
|
31
|
+
if counter(page).final().first() > 1 {
|
|
32
|
+
align(right, text(size: 9pt, counter(page).display("1 / 1", both: true)))
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
#set text(font: "Noto Sans", size: 10pt)
|
|
37
|
+
|
|
38
|
+
// Renders the styled runs produced by Payday::Markup.
|
|
39
|
+
#let render-runs(runs) = {
|
|
40
|
+
if runs == none { return }
|
|
41
|
+
for r in runs {
|
|
42
|
+
let body = text(
|
|
43
|
+
size: if "size" in r { r.size * 1pt } else { 1em },
|
|
44
|
+
fill: if "color" in r { rgb(r.color) } else { black },
|
|
45
|
+
weight: if r.at("bold", default: false) { "bold" } else { "regular" },
|
|
46
|
+
style: if r.at("italic", default: false) { "italic" } else { "normal" },
|
|
47
|
+
r.text,
|
|
48
|
+
)
|
|
49
|
+
if r.at("underline", default: false) { body = underline(body) }
|
|
50
|
+
if r.at("strike", default: false) { body = strike(body) }
|
|
51
|
+
if r.at("sub", default: false) { body = sub(body) }
|
|
52
|
+
if r.at("sup", default: false) { body = super(body) }
|
|
53
|
+
if "link" in r { link(r.link, body) } else { body }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- status stamp ------------------------------------------------------------------
|
|
58
|
+
// Drawn before anything else so it stays at a fixed offset from the top of the page, the way
|
|
59
|
+
// prawn stamped it, rather than drifting with the height of the logo.
|
|
60
|
+
#if d.stamp != none {
|
|
61
|
+
place(top + center, dx: 5.1pt, dy: 33pt, rotate(15deg,
|
|
62
|
+
text(fill: rgb("cc0000"), size: 25pt, weight: "bold")[#d.stamp]))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- logo and company identity ---------------------------------------------------
|
|
66
|
+
#grid(
|
|
67
|
+
columns: (1fr, auto),
|
|
68
|
+
align: (left + top, right + top),
|
|
69
|
+
if d.logo != none {
|
|
70
|
+
image(
|
|
71
|
+
d.logo.name,
|
|
72
|
+
width: if d.logo.width != none { d.logo.width * 1pt } else { auto },
|
|
73
|
+
height: if d.logo.height != none { d.logo.height * 1pt } else { auto },
|
|
74
|
+
fit: "contain",
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
align(left, pad(top: 5.7pt)[
|
|
78
|
+
#text(weight: "bold", size: 12pt)[#d.company_name] \
|
|
79
|
+
#d.company_details
|
|
80
|
+
]),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
#v(gap-after-header)
|
|
84
|
+
|
|
85
|
+
// --- bill to / ship to -------------------------------------------------------------
|
|
86
|
+
#grid(
|
|
87
|
+
columns: (1fr, 200pt),
|
|
88
|
+
align: (left + top, left + top),
|
|
89
|
+
[#text(weight: "bold")[#d.labels.bill_to]#v(10.4pt, weak: true)#d.bill_to],
|
|
90
|
+
if d.ship_to != none {
|
|
91
|
+
[#text(weight: "bold")[#d.labels.ship_to]#v(10.4pt, weak: true)#d.ship_to]
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// prawn drew the ship-to box 2pt taller than its contents, so invoices carrying a ship-to
|
|
96
|
+
// address pushed the details block down by that much.
|
|
97
|
+
#v(gap-after-addresses + if d.ship_to != none { 2pt } else { 0pt })
|
|
98
|
+
|
|
99
|
+
// --- invoice details ---------------------------------------------------------------
|
|
100
|
+
#if d.details.len() > 0 {
|
|
101
|
+
table(
|
|
102
|
+
columns: 2,
|
|
103
|
+
stroke: none,
|
|
104
|
+
inset: (left: 1pt, right: 0pt, y: 4.25pt),
|
|
105
|
+
column-gutter: 10pt,
|
|
106
|
+
align: (left, right),
|
|
107
|
+
..d.details.map(row => (
|
|
108
|
+
text(weight: "bold")[#row.at(0)],
|
|
109
|
+
text(weight: "bold")[#row.at(1)],
|
|
110
|
+
)).flatten()
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#v(gap-after-details)
|
|
115
|
+
|
|
116
|
+
// --- line items ---------------------------------------------------------------------
|
|
117
|
+
#table(
|
|
118
|
+
columns: (1fr, auto, auto, auto),
|
|
119
|
+
align: (left, right, right, right),
|
|
120
|
+
inset: (x: 10pt, y: 8.25pt),
|
|
121
|
+
stroke: (x, y) => (
|
|
122
|
+
left: none,
|
|
123
|
+
right: none,
|
|
124
|
+
top: 0.5pt + rgb("F6F9FC"),
|
|
125
|
+
bottom: 0.5pt + rgb("BCC6D0"),
|
|
126
|
+
),
|
|
127
|
+
fill: (x, y) => if y == 0 { white } else if calc.odd(y) { rgb("F6F9FC") } else { white },
|
|
128
|
+
table.header(
|
|
129
|
+
text(weight: "bold")[#d.labels.description],
|
|
130
|
+
text(weight: "bold")[#d.labels.unit_price],
|
|
131
|
+
text(weight: "bold")[#d.labels.quantity],
|
|
132
|
+
text(weight: "bold")[#d.labels.amount],
|
|
133
|
+
),
|
|
134
|
+
..d.line_items.map(li => (
|
|
135
|
+
render-runs(li.description), [#li.price], [#li.quantity], [#li.amount],
|
|
136
|
+
)).flatten()
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
#v(gap-before-totals)
|
|
140
|
+
|
|
141
|
+
// --- totals ---------------------------------------------------------------------------
|
|
142
|
+
#align(right, table(
|
|
143
|
+
columns: 2,
|
|
144
|
+
stroke: none,
|
|
145
|
+
// The total is set 12pt, whose taller glyph box would otherwise pull the row up.
|
|
146
|
+
inset: (x, y) => (
|
|
147
|
+
left: 5pt, right: 5pt, bottom: 8.25pt,
|
|
148
|
+
top: if y == d.totals.len() - 1 { 8.95pt } else { 8.25pt },
|
|
149
|
+
),
|
|
150
|
+
align: (left, right),
|
|
151
|
+
..d.totals.map(row => {
|
|
152
|
+
let size = if row.at(2) { 12pt } else { 10pt }
|
|
153
|
+
(text(weight: "bold", size: size)[#row.at(0)], text(size: size)[#row.at(1)])
|
|
154
|
+
}).flatten()
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
// --- notes ------------------------------------------------------------------------------
|
|
158
|
+
#if d.notes != none {
|
|
159
|
+
v(gap-before-notes)
|
|
160
|
+
text(weight: "bold")[#d.labels.notes]
|
|
161
|
+
v(gap-notes-label-to-rule)
|
|
162
|
+
line(length: 100%, stroke: 0.5pt + rgb("cccccc"))
|
|
163
|
+
v(gap-after-notes-rule)
|
|
164
|
+
render-runs(d.notes)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- QR code ------------------------------------------------------------------------------
|
|
168
|
+
#if d.qr_code != none {
|
|
169
|
+
v(gap-before-qr)
|
|
170
|
+
image("qr.svg", width: 100pt)
|
|
171
|
+
}
|