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.
@@ -2,361 +2,92 @@
2
2
 
3
3
  module Payday
4
4
 
5
- # The PDF renderer. We use this internally in Payday to render pdfs, but really you should just need to call
6
- # {{Payday::Invoiceable#render_pdf}} to render pdfs yourself.
7
- class PdfRenderer # rubocop:todo Metrics/ClassLength
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
- pdf(invoice).render_file(path)
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
- pdf(invoice).render
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
- def self.stamp(invoice, pdf) # rubocop:todo Metrics/MethodLength
47
- stamp = nil
48
- if invoice.refunded?
49
- stamp = I18n.t 'payday.status.refunded', default: 'REFUNDED'
50
- elsif invoice.paid?
51
- stamp = I18n.t 'payday.status.paid', default: 'PAID'
52
- elsif invoice.overdue?
53
- stamp = I18n.t 'payday.status.overdue', default: 'OVERDUE'
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
- # rubocop:todo Metrics/MethodLength
195
- def self.line_items_table(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
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
- pdf.move_cursor_to(pdf.cursor - 20)
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
- # rubocop:enable Metrics/MethodLength
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
- table_data << [
258
- bold_cell(pdf, shipping_description),
259
- cell(pdf, number_to_currency(invoice.shipping, invoice),
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
- pdf.move_cursor_to(pdf.cursor - 30)
297
- pdf.font('NotoSans') do
298
- pdf.text(I18n.t('payday.invoice.notes', default: 'Notes'), style: :bold)
299
- end
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
- render_qr_code(invoice, pdf)
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
- def self.render_qr_code(invoice, pdf)
310
- return unless defined?(invoice.qr_code) && invoice.qr_code.to_s.strip.present?
57
+ private
311
58
 
312
- require 'rqrcode'
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
- def self.page_numbers(pdf)
322
- return unless pdf.page_count > 1
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
- pdf.number_pages('<page> / <total>', at: [pdf.bounds.right - 25, -10])
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 self.invoice_or_default(invoice, property)
328
- if invoice.respond_to?(property) && invoice.send(property)
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
- def self.cell(pdf, text, options = {})
336
- Prawn::Table::Cell::Text.make(pdf, text, options)
77
+ Payday::Config.default.invoice_logo
337
78
  end
338
79
 
339
- def self.bold_cell(pdf, text, options = {})
340
- cell(pdf, "<b>#{text}</b>", options.merge(inline_format: true))
341
- end
80
+ def logo_parts(setting)
81
+ return [setting, nil, nil] unless setting.is_a?(Hash)
342
82
 
343
- # Converts this number to a formatted currency string
344
- def self.number_to_currency(number, invoice)
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 self.max_cell_width(cell_proxy)
354
- max = 0
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
- max
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
+ }