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,21 @@
1
+ {
2
+ "number": "INV-2043",
3
+ "subtitle": "Thanks for your business",
4
+ "issued_on": "12 March 2026",
5
+ "due_on": "11 April 2026",
6
+ "total": "$4,860.00",
7
+ "bill_to": ["Northwind Trading Ltd.", "48 Harbour Road", "Bristol BS1 5TY", "United Kingdom"],
8
+ "bill_from": ["Your Company", "12 Example Street", "London EC1A 1AA", "VAT GB123456789"],
9
+ "line_items": [
10
+ { "name": "Platform subscription — March", "qty": "1", "unit_price": "$2,400.00", "amount": "$2,400.00" },
11
+ { "name": "Additional seats", "qty": "12", "unit_price": "$95.00", "amount": "$1,140.00" },
12
+ { "name": "Onboarding workshop", "qty": "4", "unit_price": "$210.00", "amount": "$840.00" },
13
+ { "name": "Priority support", "qty": "1", "unit_price": "$480.00", "amount": "$480.00" }
14
+ ],
15
+ "summary": [
16
+ { "label": "Subtotal", "value": "$4,860.00" },
17
+ { "label": "VAT (0%)", "value": "$0.00" },
18
+ { "label": "Paid to date", "value": "$0.00" }
19
+ ],
20
+ "notes": "Payment by bank transfer within 30 days. Quote invoice number INV-2043 as the reference."
21
+ }
@@ -0,0 +1,69 @@
1
+ // Receipt — Typstify starter template.
2
+ //
3
+ // A payment confirmation: narrower page, no billing terms, a paid stamp.
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
+ #show: brand-page
11
+
12
+ #set page(paper: "a5", margin: (x: 14mm, y: 16mm))
13
+
14
+ #brand-header(
15
+ title: "Receipt",
16
+ subtitle: data.at("subtitle", default: none),
17
+ meta: (
18
+ ("Receipt no.", data.number),
19
+ ("Paid on", data.paid_on),
20
+ ),
21
+ )
22
+
23
+ #block(
24
+ width: 100%,
25
+ fill: rgb("#ecfdf5"),
26
+ stroke: 0.5pt + rgb("#a7f3d0"),
27
+ radius: 3pt,
28
+ inset: 10pt,
29
+ )[
30
+ #grid(
31
+ columns: (1fr, auto),
32
+ align: (left + horizon, right + horizon),
33
+ [
34
+ #text(size: 8pt, fill: rgb("#047857"))[#upper("Paid in full")]
35
+ #linebreak()
36
+ #text(size: 9pt, fill: brand.muted)[#data.method]
37
+ ],
38
+ text(size: 16pt, weight: "semibold", fill: rgb("#047857"))[#data.total],
39
+ )
40
+ ]
41
+
42
+ #v(12pt)
43
+
44
+ #brand-address(label: "Received from", lines: data.received_from)
45
+
46
+ #v(12pt)
47
+
48
+ #brand-table(
49
+ columns: (1fr, auto, auto),
50
+ aligns: (left, right, right),
51
+ header: ("Description", "Qty", "Amount"),
52
+ rows: data.line_items.map(item => (
53
+ [#item.name],
54
+ [#item.qty],
55
+ [#item.amount],
56
+ )),
57
+ )
58
+
59
+ #v(10pt)
60
+
61
+ #brand-totals(
62
+ rows: data.at("summary", default: ()).map(row => (row.label, row.value)),
63
+ emphasis: ("Total paid", data.total),
64
+ )
65
+
66
+ #if data.at("notes", default: none) != none [
67
+ #v(12pt)
68
+ #brand-note[#data.notes]
69
+ ]
@@ -0,0 +1,17 @@
1
+ {
2
+ "number": "RCP-8871",
3
+ "subtitle": "No payment is due",
4
+ "paid_on": "3 February 2026",
5
+ "method": "Visa ending 4242",
6
+ "total": "$1,290.00",
7
+ "received_from": ["Ada Okonkwo", "Harbour Analytics", "Dublin, Ireland"],
8
+ "line_items": [
9
+ { "name": "Annual plan", "qty": "1", "amount": "$1,200.00" },
10
+ { "name": "Extra storage (50 GB)", "qty": "1", "amount": "$90.00" }
11
+ ],
12
+ "summary": [
13
+ { "label": "Subtotal", "value": "$1,290.00" },
14
+ { "label": "Tax", "value": "$0.00" }
15
+ ],
16
+ "notes": "Keep this receipt for your records. Questions? Reply to your confirmation email."
17
+ }
@@ -0,0 +1,103 @@
1
+ // Report — Typstify starter template.
2
+ //
3
+ // A multi-page document: cover block, headline metrics, a data table and
4
+ // free-form sections. This is the one that shows why Typst beats a coordinate
5
+ // DSL — pagination, running headers and a table of contents come for free.
6
+ //
7
+ // Data mode; see templates/invoice for the fuller commentary.
8
+
9
+ #import "shared/branding.typ": *
10
+
11
+ #let data = json("data.json")
12
+
13
+ #show: brand-page
14
+
15
+ #brand-header(
16
+ title: data.title,
17
+ subtitle: data.at("subtitle", default: none),
18
+ meta: (
19
+ ("Period", data.period),
20
+ ("Prepared for", data.prepared_for),
21
+ ("Generated", data.generated_on),
22
+ ),
23
+ )
24
+
25
+ // Headline metrics.
26
+ #grid(
27
+ columns: data.metrics.len(),
28
+ gutter: 10pt,
29
+ ..data.metrics.map(metric => block(
30
+ width: 100%,
31
+ fill: rgb("#f9fafb"),
32
+ stroke: 0.5pt + brand.rule,
33
+ radius: 3pt,
34
+ inset: 10pt,
35
+ )[
36
+ #text(size: 8pt, fill: brand.muted)[#upper(metric.label)]
37
+ #linebreak()
38
+ #text(size: 18pt, weight: "semibold")[#metric.value]
39
+ #linebreak()
40
+ // Always a third line, present or not, so the cards line up. `height: 100%`
41
+ // is the obvious alternative and the wrong one: inside a grid it resolves
42
+ // against the page, not the row, and pushes the whole report onto page two.
43
+ #if metric.at("change", default: none) == none [
44
+ #text(size: 9pt)[ ]
45
+ ] else [
46
+ #text(size: 9pt, fill: if metric.at("positive", default: true) {
47
+ rgb("#047857")
48
+ } else {
49
+ rgb("#b91c1c")
50
+ })[#metric.change]
51
+ ]
52
+ ]),
53
+ )
54
+
55
+ #v(16pt)
56
+
57
+ #heading(level: 1, outlined: false)[#data.table.heading]
58
+ #v(2pt)
59
+
60
+ #brand-table(
61
+ columns: (1fr,) + data.table.columns.slice(1).map(_ => auto),
62
+ aligns: (left,) + data.table.columns.slice(1).map(_ => right),
63
+ header: data.table.columns,
64
+ rows: data.table.rows.map(row => row.map(cell => [#cell])),
65
+ )
66
+
67
+ // A simple bar chart, drawn with rectangles. No plotting library, no browser.
68
+ #if data.at("bars", default: none) != none [
69
+ #v(16pt)
70
+ #heading(level: 1, outlined: false)[#data.bars.heading]
71
+ #v(6pt)
72
+ #let peak = calc.max(..data.bars.series.map(bar => bar.value))
73
+ #for bar in data.bars.series [
74
+ #grid(
75
+ columns: (28mm, 1fr, 18mm),
76
+ gutter: 8pt,
77
+ align: (left + horizon, left + horizon, right + horizon),
78
+ text(size: 9pt)[#bar.label],
79
+ box(width: 100%)[
80
+ #rect(
81
+ width: 100% * (bar.value / peak),
82
+ height: 8pt,
83
+ radius: 2pt,
84
+ fill: brand.accent,
85
+ stroke: none,
86
+ )
87
+ ],
88
+ text(size: 9pt, fill: brand.muted)[#bar.display],
89
+ )
90
+ #v(4pt)
91
+ ]
92
+ ]
93
+
94
+ #for section in data.sections [
95
+ #v(14pt)
96
+ #heading(level: 1, outlined: false)[#section.heading]
97
+ #v(2pt)
98
+ #set par(justify: true)
99
+ #for paragraph in section.body [
100
+ #paragraph
101
+ #parbreak()
102
+ ]
103
+ ]
@@ -0,0 +1,49 @@
1
+ {
2
+ "title": "Monthly Operations Report",
3
+ "subtitle": "Platform reliability and usage",
4
+ "period": "February 2026",
5
+ "prepared_for": "Northwind Trading Ltd.",
6
+ "generated_on": "1 March 2026",
7
+ "metrics": [
8
+ { "label": "Requests", "value": "48.2M", "change": "+12%", "positive": true },
9
+ { "label": "p95 latency", "value": "184 ms", "change": "-9%", "positive": true },
10
+ { "label": "Error rate", "value": "0.043%", "change": "+0.01pp", "positive": false },
11
+ { "label": "Uptime", "value": "99.98%" }
12
+ ],
13
+ "table": {
14
+ "heading": "Traffic by service",
15
+ "columns": ["Service", "Requests", "p95", "Errors"],
16
+ "rows": [
17
+ ["Checkout API", "18,402,113", "142 ms", "0.02%"],
18
+ ["Catalogue", "14,880,204", "96 ms", "0.01%"],
19
+ ["Search", "9,113,776", "311 ms", "0.11%"],
20
+ ["Webhooks", "4,204,882", "204 ms", "0.06%"],
21
+ ["Reporting", "1,610,442", "884 ms", "0.09%"]
22
+ ]
23
+ },
24
+ "bars": {
25
+ "heading": "Requests by week",
26
+ "series": [
27
+ { "label": "Week 1", "value": 10.8, "display": "10.8M" },
28
+ { "label": "Week 2", "value": 11.9, "display": "11.9M" },
29
+ { "label": "Week 3", "value": 12.4, "display": "12.4M" },
30
+ { "label": "Week 4", "value": 13.1, "display": "13.1M" }
31
+ ]
32
+ },
33
+ "sections": [
34
+ {
35
+ "heading": "Summary",
36
+ "body": [
37
+ "Traffic grew 12% month on month, driven almost entirely by the checkout API following the February pricing launch. Latency improved despite the extra load, thanks to the connection-pool change shipped on the 8th.",
38
+ "One incident affected search for 41 minutes on 19 February. Root cause was an unbounded query introduced by a third-party integration; a query timeout has since been added."
39
+ ]
40
+ },
41
+ {
42
+ "heading": "Actions for next month",
43
+ "body": [
44
+ "Reporting remains the slowest service at a p95 of 884 ms. The team will move its aggregate queries onto the read replica and revisit the index on the events table.",
45
+ "Error rate rose slightly, entirely within the webhooks service. The retry policy will be changed from fixed to exponential backoff."
46
+ ]
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,140 @@
1
+ // Shared branding for every Typstify document.
2
+ //
3
+ // Change it once here and every invoice, receipt, report and certificate
4
+ // follows. Nothing in this file reads data.json — it is pure presentation, so
5
+ // it can be imported by any template.
6
+
7
+ #let brand = (
8
+ name: "Your Company",
9
+ tagline: "",
10
+ accent: rgb("#1f5eff"),
11
+ ink: rgb("#111827"),
12
+ muted: rgb("#6b7280"),
13
+ rule: rgb("#e5e7eb"),
14
+ font: "Inter",
15
+ )
16
+
17
+ // Base page setup. Wrap a document with: #show: brand-page
18
+ #let brand-page(body) = {
19
+ set page(
20
+ paper: "a4",
21
+ margin: (x: 18mm, y: 20mm),
22
+ footer: context [
23
+ #set text(size: 8pt, fill: brand.muted)
24
+ #line(length: 100%, stroke: 0.5pt + brand.rule)
25
+ #v(2pt)
26
+ #grid(
27
+ columns: (1fr, auto),
28
+ align: (left, right),
29
+ [#brand.name],
30
+ [Page #counter(page).display() of #context counter(page).final().first()],
31
+ )
32
+ ],
33
+ )
34
+ set text(font: brand.font, size: 10pt, fill: brand.ink)
35
+ set par(justify: false, leading: 0.65em)
36
+ show heading: set text(weight: "semibold")
37
+ body
38
+ }
39
+
40
+ // Document header: a title on the left, the company block on the right.
41
+ #let brand-header(title: "", subtitle: none, meta: ()) = {
42
+ grid(
43
+ columns: (1fr, auto),
44
+ align: (left + bottom, right + bottom),
45
+ [
46
+ #text(size: 22pt, weight: "semibold", fill: brand.accent)[#title]
47
+ #if subtitle != none [
48
+ #linebreak()
49
+ #text(size: 10pt, fill: brand.muted)[#subtitle]
50
+ ]
51
+ ],
52
+ [
53
+ #text(size: 11pt, weight: "semibold")[#brand.name]
54
+ #if brand.tagline != "" [
55
+ #linebreak()
56
+ #text(size: 9pt, fill: brand.muted)[#brand.tagline]
57
+ ]
58
+ ],
59
+ )
60
+ v(4pt)
61
+ line(length: 100%, stroke: 1pt + brand.accent)
62
+ if meta.len() > 0 {
63
+ v(6pt)
64
+ grid(
65
+ columns: meta.len(),
66
+ gutter: 16pt,
67
+ ..meta.map(pair => [
68
+ #text(size: 8pt, fill: brand.muted, upper(pair.at(0)))
69
+ #linebreak()
70
+ #text(size: 10pt)[#pair.at(1)]
71
+ ]),
72
+ )
73
+ }
74
+ v(10pt)
75
+ }
76
+
77
+ // A labelled block of lines, e.g. an address.
78
+ #let brand-address(label: "", lines: ()) = [
79
+ #text(size: 8pt, fill: brand.muted)[#upper(label)]
80
+ #linebreak()
81
+ #for line in lines [
82
+ #line
83
+ #linebreak()
84
+ ]
85
+ ]
86
+
87
+ // The standard table styling used by the invoice and receipt templates.
88
+ #let brand-table(columns: (), header: (), rows: (), aligns: none) = {
89
+ set text(size: 9.5pt)
90
+ table(
91
+ columns: columns,
92
+ align: if aligns == none { left } else { (col, _) => aligns.at(col) },
93
+ stroke: (x, y) => (
94
+ bottom: if y == 0 { 1pt + brand.rule } else { 0.5pt + brand.rule },
95
+ ),
96
+ inset: (x: 6pt, y: 7pt),
97
+ table.header(..header.map(cell => text(
98
+ size: 8pt,
99
+ weight: "semibold",
100
+ fill: brand.muted,
101
+ )[#upper(cell)])),
102
+ ..rows.flatten(),
103
+ )
104
+ }
105
+
106
+ // Right-aligned totals block.
107
+ #let brand-totals(rows: (), emphasis: none) = align(right)[
108
+ #block(width: 62mm)[
109
+ #for row in rows [
110
+ #grid(
111
+ columns: (1fr, auto),
112
+ gutter: 12pt,
113
+ align: (left, right),
114
+ text(fill: brand.muted)[#row.at(0)],
115
+ [#row.at(1)],
116
+ )
117
+ #v(2pt)
118
+ ]
119
+ #if emphasis != none [
120
+ #line(length: 100%, stroke: 0.5pt + brand.rule)
121
+ #v(4pt)
122
+ #grid(
123
+ columns: (1fr, auto),
124
+ gutter: 12pt,
125
+ align: (left, right),
126
+ text(weight: "semibold")[#emphasis.at(0)],
127
+ text(weight: "semibold", size: 12pt, fill: brand.accent)[#emphasis.at(1)],
128
+ )
129
+ ]
130
+ ]
131
+ ]
132
+
133
+ #let brand-note(body) = block(
134
+ width: 100%,
135
+ fill: rgb("#f9fafb"),
136
+ stroke: 0.5pt + brand.rule,
137
+ radius: 3pt,
138
+ inset: 10pt,
139
+ text(size: 9pt, fill: brand.muted)[#body],
140
+ )
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typstify
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nikhil Nelson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: typst
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.15'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: actionpack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '8.2'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '7.1'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '8.2'
47
+ - !ruby/object:Gem::Dependency
48
+ name: activesupport
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '8.2'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '7.1'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '8.2'
67
+ - !ruby/object:Gem::Dependency
68
+ name: railties
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '7.1'
74
+ - - "<"
75
+ - !ruby/object:Gem::Version
76
+ version: '8.2'
77
+ type: :runtime
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '7.1'
84
+ - - "<"
85
+ - !ruby/object:Gem::Version
86
+ version: '8.2'
87
+ description: Typstify gives Rails first-class PDF generation on Typst, the Rust typesetting
88
+ engine. Controllers get `render pdf:`, templates live in app/views like every other
89
+ view, and your data arrives as JSON so user content can never change the document
90
+ — injection-safe by construction, compiled in an isolated sandbox directory. Ships
91
+ professionally designed invoice, receipt, report and certificate templates, and
92
+ can emit accessible PDF/UA-1 output for EAA and ADA compliance. No headless Chrome,
93
+ no archived wkhtmltopdf binary.
94
+ email:
95
+ - thesolohacker47@gmail.com
96
+ executables: []
97
+ extensions: []
98
+ extra_rdoc_files: []
99
+ files:
100
+ - CHANGELOG.md
101
+ - LICENSE
102
+ - README.md
103
+ - SECURITY.md
104
+ - fonts/inter/Inter-Regular.ttf
105
+ - fonts/inter/Inter-SemiBold.ttf
106
+ - fonts/inter/OFL.txt
107
+ - lib/generators/typstify/install/install_generator.rb
108
+ - lib/generators/typstify/install/templates/typstify.rb
109
+ - lib/generators/typstify/template/template_generator.rb
110
+ - lib/tasks/typstify.rake
111
+ - lib/typstify.rb
112
+ - lib/typstify/adapter.rb
113
+ - lib/typstify/config.rb
114
+ - lib/typstify/data.rb
115
+ - lib/typstify/document.rb
116
+ - lib/typstify/engine.rb
117
+ - lib/typstify/erb_pipeline.rb
118
+ - lib/typstify/errors.rb
119
+ - lib/typstify/escaping.rb
120
+ - lib/typstify/fonts.rb
121
+ - lib/typstify/renderer.rb
122
+ - lib/typstify/resolver.rb
123
+ - lib/typstify/version.rb
124
+ - lib/typstify/warnings.rb
125
+ - lib/typstify/workspace.rb
126
+ - templates/certificate/certificate.typ
127
+ - templates/certificate/sample_data.json
128
+ - templates/invoice/invoice.typ
129
+ - templates/invoice/sample_data.json
130
+ - templates/receipt/receipt.typ
131
+ - templates/receipt/sample_data.json
132
+ - templates/report/report.typ
133
+ - templates/report/sample_data.json
134
+ - templates/shared/branding.typ
135
+ homepage: https://github.com/TheSoloHacker47/typstify
136
+ licenses:
137
+ - MIT
138
+ metadata:
139
+ homepage_uri: https://github.com/TheSoloHacker47/typstify
140
+ source_code_uri: https://github.com/TheSoloHacker47/typstify
141
+ changelog_uri: https://github.com/TheSoloHacker47/typstify/blob/main/CHANGELOG.md
142
+ bug_tracker_uri: https://github.com/TheSoloHacker47/typstify/issues
143
+ documentation_uri: https://github.com/TheSoloHacker47/typstify#readme
144
+ rubygems_mfa_required: 'true'
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: 3.1.0
154
+ required_rubygems_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ requirements: []
160
+ rubygems_version: 3.5.22
161
+ signing_key:
162
+ specification_version: 4
163
+ summary: PDF generation for Rails on the Typst engine — the wkhtmltopdf replacement.
164
+ test_files: []