typstify 0.1.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.
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+
6
+ module Typstify
7
+ # Font resolution, done by us rather than by the compiler.
8
+ #
9
+ # Typst warns about an unknown font family and then quietly substitutes
10
+ # another face — which is how a production invoice ends up in a font nobody
11
+ # chose. We would rather surface that, but the `typst` binding currently
12
+ # discards compiler warnings on a successful compile (it only formats them
13
+ # into the message when compilation *fails*), so there is nothing to listen
14
+ # to. See the upstream PR linked in the README.
15
+ #
16
+ # Until that lands, we answer the question ourselves: read the family names a
17
+ # template asks for out of its source, and check them against the fonts we
18
+ # can actually see. Cheap, deterministic, and honest about what it covers —
19
+ # it reads static `font:` declarations, not families computed at runtime.
20
+ module Fonts
21
+ # Faces compiled into the Typst binary; always available, never on disk.
22
+ EMBEDDED = [
23
+ "libertinus serif",
24
+ "new computer modern",
25
+ "new computer modern math",
26
+ "deja vu sans mono",
27
+ "dejavu sans mono"
28
+ ].freeze
29
+
30
+ # `font: "Inter"` or `font: ("Inter", "Noto Sans")`
31
+ DECLARATION = /\bfont\s*:\s*(\((?:[^()]*)\)|"(?:[^"\\]|\\.)*")/m
32
+ STRING = /"((?:[^"\\]|\\.)*)"/
33
+
34
+ SYSTEM_DIRECTORIES = [
35
+ "/System/Library/Fonts",
36
+ "/System/Library/Fonts/Supplemental",
37
+ "/Library/Fonts",
38
+ "~/Library/Fonts",
39
+ "/usr/share/fonts",
40
+ "/usr/local/share/fonts",
41
+ "~/.fonts",
42
+ "~/.local/share/fonts"
43
+ ].freeze
44
+
45
+ EXTENSIONS = %w[.ttf .otf .ttc .otc].freeze
46
+
47
+ module_function
48
+
49
+ # Families a chunk of Typst source asks for, in declaration order.
50
+ def declared_families(source)
51
+ source.to_s.scan(DECLARATION).flat_map do |(declaration)|
52
+ declaration.scan(STRING).flatten
53
+ end.map(&:strip).reject(&:empty?).uniq
54
+ end
55
+
56
+ # Families the compiler will be able to find, downcased for comparison.
57
+ def available_families(font_paths, include_system: true)
58
+ key = [font_paths.map(&:to_s).sort, include_system]
59
+ @available ||= {}
60
+ @available[key] ||= begin
61
+ families = EMBEDDED.dup
62
+ search_paths(font_paths, include_system: include_system).each do |directory|
63
+ families.concat(families_in(directory))
64
+ end
65
+ families.map(&:downcase).uniq.to_set
66
+ end
67
+ end
68
+
69
+ # Reset the memoized scan. Fonts installed mid-process are rare; specs are not.
70
+ def reset!
71
+ @available = nil
72
+ end
73
+
74
+ # @return [Array<String>] families the template wants and nothing provides
75
+ def missing(source, font_paths, include_system: true)
76
+ available = available_families(font_paths, include_system: include_system)
77
+ declared_families(source).reject { |family| available.include?(family.downcase) }
78
+ end
79
+
80
+ # The directories the compiler will look in. Kept in step with
81
+ # `ignore_system_fonts`, so the check never calls a family available that
82
+ # the compiler will not actually reach for.
83
+ def search_paths(font_paths, include_system: true)
84
+ paths = font_paths.map { |path| File.expand_path(path.to_s) }
85
+ paths += SYSTEM_DIRECTORIES.map { |directory| File.expand_path(directory) } if include_system
86
+ paths
87
+ end
88
+
89
+ def families_in(directory)
90
+ return [] unless File.directory?(directory)
91
+
92
+ Dir.glob(File.join(directory, "**", "*")).flat_map do |path|
93
+ next [] unless EXTENSIONS.include?(File.extname(path).downcase)
94
+
95
+ read_families(path)
96
+ end
97
+ rescue SystemCallError
98
+ []
99
+ end
100
+
101
+ # Family names out of an SFNT `name` table (nameID 1 and 16).
102
+ def read_families(path)
103
+ File.open(path, "rb") do |file|
104
+ header = file.read(4)
105
+ return [] if header.nil?
106
+
107
+ offsets = header == "ttcf" ? collection_offsets(file) : [0]
108
+ offsets.flat_map { |offset| families_at(file, offset) }
109
+ end
110
+ rescue SystemCallError, EOFError
111
+ []
112
+ end
113
+
114
+ def collection_offsets(file)
115
+ file.seek(8)
116
+ count = file.read(4).unpack1("N")
117
+ return [] if count.nil? || count.zero? || count > 1024
118
+
119
+ Array(file.read(4 * count)&.unpack("N*"))
120
+ end
121
+
122
+ def families_at(file, base)
123
+ file.seek(base + 4)
124
+ table_count = file.read(2)&.unpack1("n")
125
+ return [] if table_count.nil? || table_count.zero?
126
+
127
+ file.seek(base + 12)
128
+ records = file.read(16 * table_count).to_s
129
+ name_offset = nil
130
+ table_count.times do |index|
131
+ record = records[index * 16, 16]
132
+ break if record.nil?
133
+
134
+ name_offset = record[8, 4].unpack1("N") if record[0, 4] == "name"
135
+ end
136
+ return [] if name_offset.nil?
137
+
138
+ parse_name_table(file, name_offset)
139
+ end
140
+
141
+ def parse_name_table(file, offset)
142
+ file.seek(offset)
143
+ header = file.read(6)
144
+ return [] if header.nil? || header.bytesize < 6
145
+
146
+ _format, count, storage = header.unpack("n3")
147
+ records = file.read(12 * count).to_s
148
+
149
+ count.times.filter_map do |index|
150
+ record = records[index * 12, 12]
151
+ next if record.nil?
152
+
153
+ platform, _encoding, _language, name_id, length, string_offset = record.unpack("n6")
154
+ next unless [1, 16].include?(name_id)
155
+
156
+ file.seek(offset + storage + string_offset)
157
+ decode(file.read(length), platform)
158
+ end.compact.uniq
159
+ end
160
+
161
+ def decode(bytes, platform)
162
+ return nil if bytes.nil? || bytes.empty?
163
+
164
+ string =
165
+ if platform == 3 || platform.zero?
166
+ bytes.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
167
+ else
168
+ bytes.force_encoding(Encoding::BINARY).encode(Encoding::UTF_8, Encoding::ISO_8859_1)
169
+ end
170
+ string.strip.empty? ? nil : string.strip
171
+ rescue EncodingError
172
+ nil
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ module Typstify
6
+ # Registers `render pdf:` on every controller.
7
+ #
8
+ # render pdf: "invoices/show",
9
+ # data: { number: "A-1" },
10
+ # filename: "invoice-A-1.pdf",
11
+ # disposition: :inline
12
+ #
13
+ # `filename` defaults to the template's basename; `disposition` to attachment,
14
+ # matching `send_data`.
15
+ module Renderer
16
+ DEFAULT_DISPOSITION = :attachment
17
+
18
+ def self.install!
19
+ ActionController::Renderers.add :pdf do |template, options|
20
+ pdf = Typstify.render(
21
+ template: template,
22
+ data: options[:data],
23
+ assigns: view_assigns
24
+ )
25
+
26
+ send_data(
27
+ pdf,
28
+ type: options[:type] || "application/pdf",
29
+ filename: Typstify::Renderer.filename_for(template, options),
30
+ disposition: options[:disposition] || DEFAULT_DISPOSITION,
31
+ status: options[:status] || :ok
32
+ )
33
+ end
34
+ end
35
+
36
+ def self.filename_for(template, options)
37
+ return options[:filename] if options[:filename]
38
+
39
+ base = File.basename(template.to_s).sub(/\.typ(\.erb)?\z/, "")
40
+ "#{base}.pdf"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Typstify
6
+ # Turns a template name ("invoices/show") into a file on disk, and refuses to
7
+ # look outside `template_root` while doing it.
8
+ #
9
+ # This is the first of two containment layers. The second — and the one that
10
+ # actually matters — is the workspace: Typst compiles with the tmpdir as its
11
+ # root, so even a template that somehow got resolved elsewhere could not read
12
+ # past it. This layer exists to turn a traversal attempt into a clear error
13
+ # rather than a confusing "file not found".
14
+ class Resolver
15
+ Resolution = Struct.new(:name, :path, :mode, keyword_init: true) do
16
+ def erb? = mode == :erb
17
+ end
18
+
19
+ # Data mode wins: a directory holding both show.typ and show.typ.erb
20
+ # renders the safe one.
21
+ EXTENSIONS = { ".typ" => :data, ".typ.erb" => :erb }.freeze
22
+
23
+ def initialize(config = Typstify.config)
24
+ @config = config
25
+ end
26
+
27
+ # @param name [String] e.g. "invoices/show", with or without extension
28
+ # @return [Resolution]
29
+ # @raise [PathError] if the name points outside template_root
30
+ # @raise [MissingTemplate] if nothing is there
31
+ def call(name)
32
+ stem = name.to_s.sub(/\.typ(\.erb)?\z/, "")
33
+ root = @config.template_root.expand_path
34
+
35
+ tried = EXTENSIONS.keys.map { |ext| candidate(root, stem, ext) }
36
+
37
+ EXTENSIONS.each_with_index do |(_ext, mode), index|
38
+ path = tried[index]
39
+ return Resolution.new(name: stem, path: path, mode: mode) if path.file?
40
+ end
41
+
42
+ raise MissingTemplate.new(name, tried)
43
+ end
44
+
45
+ private
46
+
47
+ def candidate(root, stem, extension)
48
+ if stem.start_with?("/", "~")
49
+ raise PathError, "Template name #{stem.inspect} must be relative to template_root (#{root})."
50
+ end
51
+
52
+ path = root.join("#{stem}#{extension}").expand_path
53
+ unless path.to_s == root.to_s || path.to_s.start_with?("#{root}#{File::SEPARATOR}")
54
+ raise PathError,
55
+ "Template name #{stem.inspect} resolves to #{path}, which is outside " \
56
+ "template_root (#{root}). Path traversal is not allowed."
57
+ end
58
+
59
+ path
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typstify
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typstify
4
+ # Parsing for the compiler's diagnostic output.
5
+ #
6
+ # The binding gives us diagnostics as one formatted blob — the same codespan
7
+ # rendering the `typst` CLI prints — raised as an ArgumentError:
8
+ #
9
+ # error: expected expression
10
+ # ┌─ /tmp/typstify-abc/main.typ:12:8
11
+ # │
12
+ # 12 │ #let x =
13
+ # │ ^
14
+ #
15
+ # Warnings share that format and are included *only when compilation fails*,
16
+ # so a successful compile with warnings tells us nothing. Everything here is
17
+ # therefore best-effort presentation, not a contract.
18
+ module Warnings
19
+ BLOCK = /^(?<severity>error|warning):\s*(?<message>.*)$/
20
+ LOCATION = /┌─\s*(?<path>.+?):(?<line>\d+):(?<column>\d+)/
21
+
22
+ Diagnostic = Struct.new(:severity, :message, :line, :column, keyword_init: true)
23
+
24
+ module_function
25
+
26
+ # Split a raw diagnostic blob into its individual errors and warnings.
27
+ def parse(raw)
28
+ text = raw.to_s
29
+ starts = text.enum_for(:scan, BLOCK).map { Regexp.last_match.begin(0) }
30
+ return [] if starts.empty?
31
+
32
+ bounds = starts.zip(starts.drop(1) + [text.length])
33
+ bounds.map do |(from, to)|
34
+ chunk = text[from...to]
35
+ header = chunk.match(BLOCK)
36
+ location = chunk.match(LOCATION)
37
+ Diagnostic.new(
38
+ severity: header[:severity].to_sym,
39
+ message: header[:message].strip,
40
+ line: location && location[:line].to_i,
41
+ column: location && location[:column].to_i
42
+ )
43
+ end
44
+ end
45
+
46
+ # The workspace is a tmpdir with a random name; showing it to a developer
47
+ # who wrote `app/views/invoices/show.typ` is noise at best. Swap it for the
48
+ # name they used.
49
+ def rewrite_paths(raw, workspace_dir, template_name)
50
+ raw.to_s
51
+ .gsub(%r{#{Regexp.escape(workspace_dir.to_s)}/?main\.typ}, "#{template_name}.typ")
52
+ .gsub(%r{#{Regexp.escape(workspace_dir.to_s)}/?}, "")
53
+ .gsub(%r{[^\s:]*/main\.typ}, "#{template_name}.typ")
54
+ end
55
+
56
+ def dispatch(diagnostics, template, config)
57
+ messages = diagnostics.select { |d| d.severity == :warning }.map do |diagnostic|
58
+ diagnostic.line ? "#{diagnostic.message} (line #{diagnostic.line})" : diagnostic.message
59
+ end
60
+ return if messages.empty?
61
+
62
+ config.on_warning&.call(messages, template)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ module Typstify
8
+ # A throwaway directory holding everything one compile is allowed to see.
9
+ #
10
+ # <tmp>/main.typ the template
11
+ # <tmp>/data.json your data
12
+ # <tmp>/shared/ config.shared_dir, copied
13
+ #
14
+ # Typst is invoked with this directory as its root, so `#read("../.env")`
15
+ # cannot reach your application — not because we filter it, but because the
16
+ # file is not there. That is the whole security model, and it is why the
17
+ # template is copied to the *root* of the workspace rather than nested: Typst
18
+ # resolves relative imports against the importing file, so main-at-root is
19
+ # what makes `#import "shared/branding.typ"` work from any view subdirectory.
20
+ #
21
+ # Every render gets its own workspace, which is also what makes concurrent
22
+ # rendering safe.
23
+ class Workspace
24
+ MAIN = "main.typ"
25
+ DATA = "data.json"
26
+
27
+ attr_reader :dir
28
+
29
+ # Yields a Workspace and removes it afterwards, exception or not.
30
+ def self.build(source:, data:, config: Typstify.config)
31
+ Dir.mktmpdir("typstify-") do |tmp|
32
+ workspace = new(Pathname.new(tmp), config)
33
+ workspace.write_main(source)
34
+ workspace.write_data(data)
35
+ workspace.copy_shared
36
+ yield workspace
37
+ end
38
+ end
39
+
40
+ def initialize(dir, config)
41
+ @dir = dir
42
+ @config = config
43
+ end
44
+
45
+ def main_path = dir.join(MAIN)
46
+
47
+ def write_main(source)
48
+ File.write(main_path, source, encoding: Encoding::UTF_8)
49
+ end
50
+
51
+ # Takes the already-serialised JSON string: validation happens in Document,
52
+ # before a workspace exists.
53
+ def write_data(data)
54
+ File.write(dir.join(DATA), data || "{}", encoding: Encoding::UTF_8)
55
+ end
56
+
57
+ # Copy config.shared_dir in, if it exists. Copies rather than symlinks:
58
+ # a symlink inside the root points outside the root, which would hand back
59
+ # exactly the file-system access the workspace exists to remove. Symlinks
60
+ # found in the source tree are skipped for the same reason.
61
+ def copy_shared
62
+ source = @config.template_root.join(@config.shared_dir)
63
+ return unless source.directory?
64
+
65
+ destination = dir.join(@config.shared_dir)
66
+ copy_tree(source, destination)
67
+ end
68
+
69
+ private
70
+
71
+ def copy_tree(source, destination)
72
+ FileUtils.mkdir_p(destination)
73
+ source.children.each do |child|
74
+ next if child.symlink?
75
+
76
+ target = destination.join(child.basename)
77
+ if child.directory?
78
+ copy_tree(child, target)
79
+ elsif child.file?
80
+ FileUtils.cp(child, target)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
data/lib/typstify.rb ADDED
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "stringio"
5
+
6
+ require_relative "typstify/version"
7
+ require_relative "typstify/errors"
8
+ require_relative "typstify/config"
9
+ require_relative "typstify/escaping"
10
+ require_relative "typstify/data"
11
+ require_relative "typstify/warnings"
12
+ require_relative "typstify/fonts"
13
+ require_relative "typstify/resolver"
14
+ require_relative "typstify/workspace"
15
+ require_relative "typstify/erb_pipeline"
16
+ require_relative "typstify/adapter"
17
+ require_relative "typstify/document"
18
+
19
+ require_relative "typstify/engine" if defined?(Rails::Engine)
20
+
21
+ # PDF generation for Rails on the Typst engine.
22
+ #
23
+ # Typstify.render(template: "invoices/show", data: { number: "A-1" })
24
+ #
25
+ # In a controller, prefer the renderer:
26
+ #
27
+ # render pdf: "invoices/show", data: { ... }, filename: "invoice.pdf"
28
+ module Typstify
29
+ class << self
30
+ def config
31
+ @config ||= Config.new
32
+ end
33
+
34
+ def configure
35
+ yield config
36
+ apply_package_cache!
37
+ config
38
+ end
39
+
40
+ # Mostly for specs: drop all configuration and cached font scans.
41
+ def reset!
42
+ @config = Config.new
43
+ Fonts.reset!
44
+ config
45
+ end
46
+
47
+ # Compile a template to PDF.
48
+ #
49
+ # @param template [String] e.g. "invoices/show"
50
+ # @param data [Object] anything JSON-serializable; read in the template
51
+ # with `#let data = json("data.json")`
52
+ # @param assigns [Hash] instance variables for ERB mode only
53
+ # @return [String] PDF bytes, ASCII-8BIT
54
+ def render(template:, data: nil, assigns: {})
55
+ Document.new(template: template, data: data, assigns: assigns, config: config).to_pdf
56
+ end
57
+
58
+ # Render and attach in one step.
59
+ #
60
+ # Typstify.render_and_attach(user.documents,
61
+ # template: "certificates/completion",
62
+ # data: cert_data, filename: "certificate.pdf")
63
+ #
64
+ # @param attachable [#attach] an ActiveStorage has_one/has_many attachment proxy
65
+ def render_and_attach(attachable, template:, filename:, data: nil, assigns: {},
66
+ content_type: "application/pdf")
67
+ pdf = render(template: template, data: data, assigns: assigns)
68
+ attachable.attach(
69
+ io: StringIO.new(pdf),
70
+ filename: filename,
71
+ content_type: content_type
72
+ )
73
+ end
74
+
75
+ def root
76
+ @root ||= Pathname.new(__dir__).join("..").expand_path
77
+ end
78
+
79
+ # Faces shipped with the gem, always on the font search path so the starter
80
+ # templates render with no configuration.
81
+ def bundled_font_path
82
+ root.join("fonts")
83
+ end
84
+
85
+ # The starter template pack, copied by `rails g typstify:template`.
86
+ def template_pack_path
87
+ root.join("templates")
88
+ end
89
+
90
+ # Point Typst's package resolution at the configured cache.
91
+ #
92
+ # Typst looks in the platform data directory, which on Linux — and so in
93
+ # every Docker image — is XDG_DATA_HOME. Set once at boot rather than around
94
+ # each compile, because mutating ENV per render is not thread-safe and
95
+ # concurrent rendering is a supported use. On macOS the platform directory
96
+ # is fixed and this has no effect; see docs/fonts-and-docker.md.
97
+ def apply_package_cache!
98
+ cache = config.package_cache
99
+ return if cache.nil?
100
+
101
+ ENV["XDG_DATA_HOME"] = cache.expand_path.to_s
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,70 @@
1
+ // Certificate — Typstify starter template.
2
+ //
3
+ // Landscape, centred, and deliberately restrained: a border, a name, a reason.
4
+ // Data mode; see templates/invoice for the fuller commentary.
5
+
6
+ #import "shared/branding.typ": *
7
+
8
+ #let data = json("data.json")
9
+
10
+ #set page(
11
+ paper: "a4",
12
+ flipped: true,
13
+ margin: 16mm,
14
+ background: {
15
+ place(center + horizon, rect(
16
+ width: 100% - 10mm,
17
+ height: 100% - 10mm,
18
+ stroke: 1.5pt + brand.accent,
19
+ radius: 2pt,
20
+ ))
21
+ place(center + horizon, rect(
22
+ width: 100% - 14mm,
23
+ height: 100% - 14mm,
24
+ stroke: 0.5pt + brand.rule,
25
+ ))
26
+ },
27
+ )
28
+ #set text(font: brand.font, size: 11pt, fill: brand.ink)
29
+
30
+ #align(center)[
31
+ #v(14mm)
32
+ #text(size: 9pt, fill: brand.muted, tracking: 2pt)[#upper(data.at("eyebrow", default: "Certificate"))]
33
+
34
+ #v(6mm)
35
+ #text(size: 30pt, weight: "semibold", fill: brand.accent)[#data.title]
36
+
37
+ #v(8mm)
38
+ #text(size: 10pt, fill: brand.muted)[#data.presented_to_label]
39
+
40
+ #v(3mm)
41
+ #text(size: 26pt, weight: "semibold")[#data.recipient]
42
+
43
+ #v(3mm)
44
+ #line(length: 70mm, stroke: 0.5pt + brand.rule)
45
+
46
+ #v(6mm)
47
+ #block(width: 60%)[
48
+ #set par(justify: false, leading: 0.8em)
49
+ #text(size: 11pt, fill: brand.muted)[#data.description]
50
+ ]
51
+
52
+ #v(14mm)
53
+
54
+ #grid(
55
+ columns: (1fr, 1fr),
56
+ gutter: 30mm,
57
+ ..data.signatories.map(person => [
58
+ #line(length: 100%, stroke: 0.5pt + brand.ink)
59
+ #v(2pt)
60
+ #text(size: 10pt, weight: "semibold")[#person.name]
61
+ #linebreak()
62
+ #text(size: 8.5pt, fill: brand.muted)[#person.role]
63
+ ]),
64
+ )
65
+
66
+ #v(8mm)
67
+ #text(size: 8pt, fill: brand.muted)[
68
+ #data.issued_on #h(6pt) · #h(6pt) #data.reference
69
+ ]
70
+ ]
@@ -0,0 +1,13 @@
1
+ {
2
+ "eyebrow": "Certificate of Completion",
3
+ "title": "Advanced Rails Performance",
4
+ "presented_to_label": "This is to certify that",
5
+ "recipient": "Ada Okonkwo",
6
+ "description": "has successfully completed the twelve-week programme covering query optimisation, caching strategy, background processing and production profiling, including all assessed exercises.",
7
+ "signatories": [
8
+ { "name": "Priya Raman", "role": "Programme Director" },
9
+ { "name": "Tom Whelan", "role": "Lead Instructor" }
10
+ ],
11
+ "issued_on": "18 June 2026",
12
+ "reference": "Certificate no. CRT-00412"
13
+ }
@@ -0,0 +1,57 @@
1
+ // Invoice — Typstify starter template.
2
+ //
3
+ // Data mode: every dynamic value arrives as JSON. Nothing here interpolates a
4
+ // Ruby string into source, so a customer named `#read("/etc/passwd")` renders
5
+ // as those characters and nothing else.
6
+ //
7
+ // Preview it without booting Rails:
8
+ // rake typstify:preview[invoices/show]
9
+
10
+ #import "shared/branding.typ": *
11
+
12
+ #let data = json("data.json")
13
+
14
+ #show: brand-page
15
+
16
+ #brand-header(
17
+ title: "Invoice " + data.number,
18
+ subtitle: data.at("subtitle", default: none),
19
+ meta: (
20
+ ("Issued", data.issued_on),
21
+ ("Due", data.due_on),
22
+ ("Amount due", data.total),
23
+ ),
24
+ )
25
+
26
+ #grid(
27
+ columns: (1fr, 1fr),
28
+ gutter: 16pt,
29
+ brand-address(label: "Billed to", lines: data.bill_to),
30
+ brand-address(label: "From", lines: data.bill_from),
31
+ )
32
+
33
+ #v(14pt)
34
+
35
+ #brand-table(
36
+ columns: (1fr, auto, auto, auto),
37
+ aligns: (left, right, right, right),
38
+ header: ("Description", "Qty", "Unit", "Amount"),
39
+ rows: data.line_items.map(item => (
40
+ [#item.name],
41
+ [#item.qty],
42
+ [#item.unit_price],
43
+ [#item.amount],
44
+ )),
45
+ )
46
+
47
+ #v(12pt)
48
+
49
+ #brand-totals(
50
+ rows: data.at("summary", default: ()).map(row => (row.label, row.value)),
51
+ emphasis: ("Total due", data.total),
52
+ )
53
+
54
+ #if data.at("notes", default: none) != none [
55
+ #v(16pt)
56
+ #brand-note[#data.notes]
57
+ ]