tallyman 1.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.
@@ -0,0 +1,171 @@
1
+ // Tallyman'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 Tallyman::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
+ }
data/lib/tallyman.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+ loader = Zeitwerk::Loader.for_gem
5
+ loader.ignore("#{__dir__}/generators")
6
+ loader.setup
7
+
8
+ require 'active_support/core_ext/object/blank' # For Object#blank? method
9
+ require 'bigdecimal'
10
+ require 'date'
11
+ require 'i18n'
12
+ require 'json'
13
+ require 'money'
14
+ require 'typst'
15
+ require 'time'
16
+
17
+ # Load our own translations. This has to resolve against the gem rather than the working
18
+ # directory, or the locales only load when the process happens to be running from the gem root.
19
+ I18n.load_path.concat(Dir[File.expand_path('../config/locales/*.yml', __dir__)])
20
+
21
+ module Tallyman
22
+
23
+ # A blank value (nil, "", " ") is 0.
24
+ def self.to_decimal(value)
25
+ BigDecimal(value.to_s.presence || 0)
26
+ end
27
+
28
+ end
data/tallyman.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'tallyman'
5
+ s.version = '1.0.0'
6
+ s.required_ruby_version = '>= 3.2'
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ['Alan Johnson', 'Edouard Briere']
9
+ s.email = ['edouard@webtranslateit.com']
10
+ s.homepage = 'https://github.com/Rebase-Capital/tallyman'
11
+ s.summary = 'A simple library for rendering invoices.'
12
+ s.description = 'Tallyman is a library for rendering invoices to pdf, using Typst.'
13
+ s.license = 'MIT'
14
+
15
+ s.add_dependency 'activesupport', '>= 7', '< 9'
16
+ s.add_dependency 'i18n', '~> 1.12', '< 2'
17
+ s.add_dependency 'money', '>= 6.16', '< 8.0'
18
+ s.add_dependency 'rqrcode', '>= 2', '< 4'
19
+ s.add_dependency 'typst', '~> 0.15'
20
+ s.add_dependency 'zeitwerk', '~> 2.6', '< 3'
21
+
22
+ # Only what the gem needs at run time, plus the docs. The spec suite carries several
23
+ # hundred kilobytes of reference PDFs that nobody installing this wants.
24
+ root_files = %w[README.md CHANGELOG.md tallyman.gemspec].freeze
25
+ s.files = `git ls-files -z`.split("\x0").select do |path|
26
+ path.start_with?('lib/', 'config/locales/', 'fonts/') || root_files.include?(path)
27
+ end
28
+ s.require_paths = ['lib']
29
+ s.metadata['rubygems_mfa_required'] = 'true'
30
+ end
metadata ADDED
@@ -0,0 +1,185 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tallyman
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alan Johnson
8
+ - Edouard Briere
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: i18n
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.12'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '2'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '1.12'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '2'
53
+ - !ruby/object:Gem::Dependency
54
+ name: money
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '6.16'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '8.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '6.16'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '8.0'
73
+ - !ruby/object:Gem::Dependency
74
+ name: rqrcode
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '2'
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: '4'
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '2'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '4'
93
+ - !ruby/object:Gem::Dependency
94
+ name: typst
95
+ requirement: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '0.15'
100
+ type: :runtime
101
+ prerelease: false
102
+ version_requirements: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: '0.15'
107
+ - !ruby/object:Gem::Dependency
108
+ name: zeitwerk
109
+ requirement: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '2.6'
114
+ - - "<"
115
+ - !ruby/object:Gem::Version
116
+ version: '3'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.6'
124
+ - - "<"
125
+ - !ruby/object:Gem::Version
126
+ version: '3'
127
+ description: Tallyman is a library for rendering invoices to pdf, using Typst.
128
+ email:
129
+ - edouard@webtranslateit.com
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - CHANGELOG.md
135
+ - README.md
136
+ - config/locales/tallyman.de.yml
137
+ - config/locales/tallyman.en.yml
138
+ - config/locales/tallyman.es.yml
139
+ - config/locales/tallyman.fr.yml
140
+ - config/locales/tallyman.nl.yml
141
+ - config/locales/tallyman.zh-CN.yml
142
+ - fonts/NotoSans-Bold.ttf
143
+ - fonts/NotoSans-Regular.ttf
144
+ - lib/generators/tallyman/setup/USAGE
145
+ - lib/generators/tallyman/setup/setup_generator.rb
146
+ - lib/generators/tallyman/setup/templates/invoice.rb
147
+ - lib/generators/tallyman/setup/templates/line_item.rb
148
+ - lib/generators/tallyman/setup/templates/migration.rb
149
+ - lib/tallyman.rb
150
+ - lib/tallyman/appendix.rb
151
+ - lib/tallyman/assets/default_logo.png
152
+ - lib/tallyman/config.rb
153
+ - lib/tallyman/invoice.rb
154
+ - lib/tallyman/invoice_presenter.rb
155
+ - lib/tallyman/invoiceable.rb
156
+ - lib/tallyman/line_item.rb
157
+ - lib/tallyman/line_itemable.rb
158
+ - lib/tallyman/markup.rb
159
+ - lib/tallyman/pdf_renderer.rb
160
+ - lib/tallyman/qr_code.rb
161
+ - lib/tallyman/templates/invoice.typ
162
+ - tallyman.gemspec
163
+ homepage: https://github.com/Rebase-Capital/tallyman
164
+ licenses:
165
+ - MIT
166
+ metadata:
167
+ rubygems_mfa_required: 'true'
168
+ rdoc_options: []
169
+ require_paths:
170
+ - lib
171
+ required_ruby_version: !ruby/object:Gem::Requirement
172
+ requirements:
173
+ - - ">="
174
+ - !ruby/object:Gem::Version
175
+ version: '3.2'
176
+ required_rubygems_version: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ requirements: []
182
+ rubygems_version: 3.6.9
183
+ specification_version: 4
184
+ summary: A simple library for rendering invoices.
185
+ test_files: []