webtranslateit-payday 1.7.3 → 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 +29 -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 -319
- 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 +10 -7
- 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 -29
- data/.gitignore +0 -8
- data/.rubocop.yml +0 -16
- data/.rubocop_todo.yml +0 -0
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -191
- 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,357 +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
|
-
logo_info = pdf.svg(File.read(image), at: pdf.bounds.top_left, width: width, height: height)
|
|
84
|
-
logo_height = logo_info[:height]
|
|
85
|
-
else
|
|
86
|
-
logo_info = pdf.image(image, at: pdf.bounds.top_left, width: width, height: height)
|
|
87
|
-
logo_height = logo_info.scaled_height
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# render the company details
|
|
91
|
-
table_data = []
|
|
92
|
-
table_data << [bold_cell(pdf, invoice_or_default(invoice, :company_name).strip, size: 12)]
|
|
93
|
-
|
|
94
|
-
invoice_or_default(invoice, :company_details).lines.each { |line| table_data << [line] }
|
|
95
|
-
|
|
96
|
-
table = pdf.make_table(table_data, cell_style: {borders: [], padding: 0})
|
|
97
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.bounds.top], width: table.width,
|
|
98
|
-
height: table.height + 5) do
|
|
99
|
-
table.draw
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
pdf.move_cursor_to(pdf.bounds.top - logo_height - 20)
|
|
103
|
-
end
|
|
104
|
-
# rubocop:enable Metrics/MethodLength
|
|
105
|
-
|
|
106
|
-
# rubocop:todo Metrics/MethodLength
|
|
107
|
-
def self.bill_to_ship_to(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
108
|
-
bill_to_cell_style = {borders: [], padding: [2, 0]}
|
|
109
|
-
bill_to_ship_to_bottom = 0
|
|
110
|
-
|
|
111
|
-
# render bill to
|
|
112
|
-
pdf.float do
|
|
113
|
-
pdf.table([[bold_cell(pdf, I18n.t('payday.invoice.bill_to', default: 'Bill To'))],
|
|
114
|
-
[invoice.bill_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
115
|
-
bill_to_ship_to_bottom = pdf.cursor
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# render ship to
|
|
119
|
-
if defined?(invoice.ship_to) && !invoice.ship_to.nil?
|
|
120
|
-
table = pdf.make_table([[bold_cell(pdf, I18n.t('payday.invoice.ship_to', default: 'Ship To'))],
|
|
121
|
-
[invoice.ship_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
122
|
-
|
|
123
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor], width: table.width, height: table.height + 2) do
|
|
124
|
-
table.draw
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# make sure we start at the lower of the bill_to or ship_to details
|
|
129
|
-
bill_to_ship_to_bottom = pdf.cursor if pdf.cursor < bill_to_ship_to_bottom
|
|
130
|
-
pdf.move_cursor_to(bill_to_ship_to_bottom - 20)
|
|
131
|
-
end
|
|
132
|
-
# rubocop:enable Metrics/MethodLength
|
|
133
|
-
|
|
134
|
-
# rubocop:todo Metrics/PerceivedComplexity
|
|
135
|
-
# rubocop:todo Metrics/MethodLength
|
|
136
|
-
# rubocop:todo Metrics/AbcSize
|
|
137
|
-
def self.invoice_details(invoice, pdf) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
138
|
-
# invoice details
|
|
139
|
-
table_data = []
|
|
140
|
-
|
|
141
|
-
# invoice number
|
|
142
|
-
if defined?(invoice.invoice_number) && invoice.invoice_number
|
|
143
|
-
table_data << if invoice.paid?
|
|
144
|
-
[bold_cell(pdf, I18n.t('payday.invoice.receipt_no', default: 'Receipt #:')),
|
|
145
|
-
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
146
|
-
else
|
|
147
|
-
[bold_cell(pdf, I18n.t('payday.invoice.invoice_no', default: 'Invoice #:')),
|
|
148
|
-
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Due on
|
|
153
|
-
if defined?(invoice.due_at) && invoice.due_at
|
|
154
|
-
due_date = if invoice.due_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
155
|
-
invoice.due_at.strftime(Payday::Config.default.date_format)
|
|
156
|
-
else
|
|
157
|
-
invoice.due_at.to_s
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
table_data << [bold_cell(pdf, I18n.t('payday.invoice.due_date', default: 'Due Date:')),
|
|
161
|
-
bold_cell(pdf, due_date, align: :right)]
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# Paid on
|
|
165
|
-
if defined?(invoice.paid_at) && invoice.paid_at
|
|
166
|
-
paid_date = if invoice.paid_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
167
|
-
invoice.paid_at.strftime(Payday::Config.default.date_format)
|
|
168
|
-
else
|
|
169
|
-
invoice.paid_at.to_s
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
table_data << [bold_cell(pdf, I18n.t('payday.invoice.paid_date', default: 'Paid Date:')),
|
|
173
|
-
bold_cell(pdf, paid_date, align: :right)]
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# loop through invoice_details and include them
|
|
177
|
-
invoice.each_detail do |key, value|
|
|
178
|
-
table_data << [bold_cell(pdf, key),
|
|
179
|
-
bold_cell(pdf, value, align: :right)]
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
return unless table_data.length.positive?
|
|
183
|
-
|
|
184
|
-
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
|
|
185
33
|
end
|
|
186
|
-
# rubocop:enable Metrics/AbcSize
|
|
187
|
-
# rubocop:enable Metrics/MethodLength
|
|
188
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
189
34
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
table_data = []
|
|
193
|
-
table_data << [bold_cell(pdf, I18n.t('payday.line_item.description', default: 'Description'), borders: []),
|
|
194
|
-
bold_cell(pdf, I18n.t('payday.line_item.unit_price', default: 'Unit Price'), align: :center,
|
|
195
|
-
borders: []),
|
|
196
|
-
bold_cell(pdf, I18n.t('payday.line_item.quantity', default: 'Quantity'), align: :center,
|
|
197
|
-
borders: []),
|
|
198
|
-
bold_cell(pdf, I18n.t('payday.line_item.amount', default: 'Amount'), align: :center, borders: [])]
|
|
199
|
-
invoice.line_items.each do |line|
|
|
200
|
-
table_data << if line.predefined_amount
|
|
201
|
-
[line.description, '', '', number_to_currency(line.predefined_amount, invoice)]
|
|
202
|
-
else
|
|
203
|
-
[line.description,
|
|
204
|
-
line.display_price || number_to_currency(line.price, invoice),
|
|
205
|
-
line.display_quantity || BigDecimal(line.quantity.to_s).to_s('F'),
|
|
206
|
-
number_to_currency(line.amount, invoice)]
|
|
207
|
-
end
|
|
208
|
-
end
|
|
35
|
+
def self.currency_for(invoice)
|
|
36
|
+
return invoice.currency if invoice.respond_to?(:currency) && invoice.currency
|
|
209
37
|
|
|
210
|
-
|
|
211
|
-
pdf.table(table_data, width: pdf.bounds.width, header: true,
|
|
212
|
-
cell_style: {border_width: 0.5, border_left_color: 'FFFFFF', border_right_color: 'FFFFFF',
|
|
213
|
-
border_top_color: 'F6F9FC', border_bottom_color: 'BCC6D0', padding: [5, 10],
|
|
214
|
-
inline_format: true},
|
|
215
|
-
row_colors: %w[F6F9FC ffffff]) do
|
|
216
|
-
# left align the number columns
|
|
217
|
-
columns(1..3).rows(1..(row_length - 1)).style(align: :right)
|
|
218
|
-
|
|
219
|
-
# set the column widths correctly
|
|
220
|
-
natural = natural_column_widths
|
|
221
|
-
natural[0] = width - natural[1] - natural[2] - natural[3]
|
|
222
|
-
end
|
|
38
|
+
Payday::Config.default.currency
|
|
223
39
|
end
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
# rubocop:todo Metrics/PerceivedComplexity
|
|
227
|
-
# rubocop:todo Metrics/MethodLength
|
|
228
|
-
def self.totals_lines(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
229
|
-
table_data = []
|
|
230
|
-
table_data << [
|
|
231
|
-
bold_cell(pdf, I18n.t('payday.invoice.subtotal', default: 'Subtotal:')),
|
|
232
|
-
cell(pdf, number_to_currency(invoice.subtotal, invoice), align: :right)
|
|
233
|
-
]
|
|
234
|
-
|
|
235
|
-
tax_description = if invoice.tax_description.nil?
|
|
236
|
-
I18n.t('payday.invoice.tax', default: 'Tax:')
|
|
237
|
-
else
|
|
238
|
-
invoice.tax_description
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
table_data << [
|
|
242
|
-
bold_cell(pdf, tax_description),
|
|
243
|
-
cell(pdf, number_to_currency(invoice.tax, invoice), align: :right)
|
|
244
|
-
]
|
|
245
|
-
|
|
246
|
-
if invoice.shipping_rate.positive?
|
|
247
|
-
shipping_description = if invoice.shipping_description.nil?
|
|
248
|
-
I18n.t('payday.invoice.shipping', default: 'Shipping:')
|
|
249
|
-
else
|
|
250
|
-
invoice.shipping_description
|
|
251
|
-
end
|
|
40
|
+
private_class_method :currency_for
|
|
252
41
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
align: :right)
|
|
257
|
-
]
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
if invoice.retention_rate.positive?
|
|
261
|
-
retention_description = if invoice.retention_description.nil?
|
|
262
|
-
I18n.t('payday.invoice.retention', default: 'Retention:')
|
|
263
|
-
else
|
|
264
|
-
invoice.retention_description
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
table_data << [
|
|
268
|
-
bold_cell(pdf, retention_description),
|
|
269
|
-
cell(pdf, number_to_currency(-invoice.retention, invoice),
|
|
270
|
-
align: :right)
|
|
271
|
-
]
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
table_data << [
|
|
275
|
-
bold_cell(pdf, I18n.t('payday.invoice.total', default: 'Total:'),
|
|
276
|
-
size: 12),
|
|
277
|
-
cell(pdf, number_to_currency(invoice.total, invoice),
|
|
278
|
-
size: 12, align: :right)
|
|
279
|
-
]
|
|
280
|
-
table = pdf.make_table(table_data, cell_style: {borders: []})
|
|
281
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor],
|
|
282
|
-
width: table.width, height: table.height + 2) do
|
|
283
|
-
table.draw
|
|
284
|
-
end
|
|
42
|
+
def initialize(invoice)
|
|
43
|
+
@invoice = invoice
|
|
44
|
+
@dependencies = {}
|
|
285
45
|
end
|
|
286
|
-
# rubocop:enable Metrics/MethodLength
|
|
287
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
288
|
-
|
|
289
|
-
def self.notes(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
290
|
-
return unless defined?(invoice.notes) && invoice.notes
|
|
291
46
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
pdf.line_width = 0.5
|
|
297
|
-
pdf.stroke_color = 'cccccc'
|
|
298
|
-
pdf.stroke_line([0, pdf.cursor - 3, pdf.bounds.width, pdf.cursor - 3])
|
|
299
|
-
pdf.move_cursor_to(pdf.cursor - 10)
|
|
300
|
-
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
|
|
301
51
|
|
|
302
|
-
|
|
52
|
+
Typst(body: File.read(TEMPLATE), dependencies: @dependencies, fonts: fonts,
|
|
53
|
+
sys_inputs: {'invoice' => data.to_json})
|
|
54
|
+
.compile(:pdf).bytes.flatten.pack('C*')
|
|
303
55
|
end
|
|
304
56
|
|
|
305
|
-
|
|
306
|
-
return unless defined?(invoice.qr_code) && invoice.qr_code.to_s.strip.present?
|
|
57
|
+
private
|
|
307
58
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
pdf.move_cursor_to(pdf.cursor - 10)
|
|
311
|
-
qr = RQRCode::QRCode.new(invoice.qr_code.to_s)
|
|
312
|
-
png = qr.as_png(size: 200)
|
|
313
|
-
|
|
314
|
-
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))] }
|
|
315
61
|
end
|
|
316
62
|
|
|
317
|
-
|
|
318
|
-
|
|
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?
|
|
319
67
|
|
|
320
|
-
|
|
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}
|
|
321
72
|
end
|
|
322
73
|
|
|
323
|
-
def
|
|
324
|
-
if invoice.respond_to?(
|
|
325
|
-
invoice.send(property)
|
|
326
|
-
else
|
|
327
|
-
Payday::Config.default.send(property)
|
|
328
|
-
end
|
|
329
|
-
end
|
|
74
|
+
def invoice_logo
|
|
75
|
+
return @invoice.invoice_logo if @invoice.respond_to?(:invoice_logo) && @invoice.invoice_logo
|
|
330
76
|
|
|
331
|
-
|
|
332
|
-
Prawn::Table::Cell::Text.make(pdf, text, options)
|
|
77
|
+
Payday::Config.default.invoice_logo
|
|
333
78
|
end
|
|
334
79
|
|
|
335
|
-
def
|
|
336
|
-
|
|
337
|
-
end
|
|
80
|
+
def logo_parts(setting)
|
|
81
|
+
return [setting, nil, nil] unless setting.is_a?(Hash)
|
|
338
82
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
Money.locale_backend = :currency
|
|
342
|
-
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
|
|
343
|
-
currency = Money::Currency.wrap(invoice_or_default(invoice, :currency))
|
|
344
|
-
number *= currency.subunit_to_unit
|
|
345
|
-
number = number.round unless Money.default_infinite_precision
|
|
346
|
-
Money.new(number, currency).format
|
|
83
|
+
width, height = setting[:size].to_s.split('x').map(&:to_f)
|
|
84
|
+
[setting[:filename], width, height]
|
|
347
85
|
end
|
|
348
86
|
|
|
349
|
-
def
|
|
350
|
-
|
|
351
|
-
cell_proxy.each do |cell|
|
|
352
|
-
max = cell.natural_content_width if cell.natural_content_width > max
|
|
353
|
-
end
|
|
87
|
+
def register_qr_code(data)
|
|
88
|
+
return if data.nil?
|
|
354
89
|
|
|
355
|
-
|
|
90
|
+
@dependencies['qr.svg'] = QrCode.new(data).to_svg
|
|
356
91
|
end
|
|
357
92
|
|
|
358
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
|
+
}
|