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.
@@ -2,357 +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
- 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
- # rubocop:todo Metrics/MethodLength
191
- def self.line_items_table(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
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
- pdf.move_cursor_to(pdf.cursor - 20)
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
- # rubocop:enable Metrics/MethodLength
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
- table_data << [
254
- bold_cell(pdf, shipping_description),
255
- cell(pdf, number_to_currency(invoice.shipping, invoice),
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
- pdf.move_cursor_to(pdf.cursor - 30)
293
- pdf.font('NotoSans') do
294
- pdf.text(I18n.t('payday.invoice.notes', default: 'Notes'), style: :bold)
295
- end
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
- 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*')
303
55
  end
304
56
 
305
- def self.render_qr_code(invoice, pdf)
306
- return unless defined?(invoice.qr_code) && invoice.qr_code.to_s.strip.present?
57
+ private
307
58
 
308
- require 'rqrcode'
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
- def self.page_numbers(pdf)
318
- 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?
319
67
 
320
- 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}
321
72
  end
322
73
 
323
- def self.invoice_or_default(invoice, property)
324
- if invoice.respond_to?(property) && invoice.send(property)
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
- def self.cell(pdf, text, options = {})
332
- Prawn::Table::Cell::Text.make(pdf, text, options)
77
+ Payday::Config.default.invoice_logo
333
78
  end
334
79
 
335
- def self.bold_cell(pdf, text, options = {})
336
- cell(pdf, "<b>#{text}</b>", options.merge(inline_format: true))
337
- end
80
+ def logo_parts(setting)
81
+ return [setting, nil, nil] unless setting.is_a?(Hash)
338
82
 
339
- # Converts this number to a formatted currency string
340
- def self.number_to_currency(number, invoice)
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 self.max_cell_width(cell_proxy)
350
- max = 0
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
- max
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
+ }