ymlbill 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.
- checksums.yaml +7 -0
- data/README.md +363 -0
- data/exe/ymlbill +5 -0
- data/lib/ymlbill/cli.rb +82 -0
- data/lib/ymlbill/document_loader.rb +22 -0
- data/lib/ymlbill/html_renderer.rb +53 -0
- data/lib/ymlbill/pdf_engine.rb +9 -0
- data/lib/ymlbill/pdf_engines/chromium.rb +27 -0
- data/lib/ymlbill/template_resolver.rb +17 -0
- data/lib/ymlbill/templates/default.html.erb +134 -0
- data/lib/ymlbill/version.rb +3 -0
- data/lib/ymlbill.rb +19 -0
- metadata +138 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ba38c5e802b0d79ac1d6a639597f0fe6b1dc0a1c0123e83802a4a8fce9df6acf
|
|
4
|
+
data.tar.gz: 0eb3cb4531f5f166a52b84d41a37af5ccdecd16d0ef05282d207817ed985fdb3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a5eaf2895349aacd7f69263b2a150236aad126fc15d218c3cf0dde5894c319bdb2e238610462fe3e070cfd9c90af9d956cc5d88489ec43b11de5a16eae8b04e7
|
|
7
|
+
data.tar.gz: 9cd1d554cda396bf2f0de792b4b40420abc469e63b5d6b803dc14130c87b42ed7fd5702c6a109f8dcd9a648bdc88c4b1506f6f392c058f8dc73acf4fc52fd54d
|
data/README.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# ymlbill
|
|
2
|
+
|
|
3
|
+
A CLI tool to generate PDF invoices and quotes from YAML files.
|
|
4
|
+
|
|
5
|
+
## Example Output
|
|
6
|
+
|
|
7
|
+
<img src="examples/example_invoice.png" alt="Example Invoice" width="80%">
|
|
8
|
+
|
|
9
|
+
## Why ymlbill?
|
|
10
|
+
|
|
11
|
+
ymlbill was built with version control and filesystem-based organization in mind. Instead of storing invoices in a proprietary database or SaaS platform, everything lives as plain YAML files in your project directory:
|
|
12
|
+
|
|
13
|
+
- **Versionable**: Track every invoice change with git.
|
|
14
|
+
- **Filesystem-native**: Organize clients, sellers, and invoices in a logical folder structure
|
|
15
|
+
- **Template-driven**: One template, infinite variations. Can be customized per client or project
|
|
16
|
+
|
|
17
|
+
### Example folder structure
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
your-project/
|
|
21
|
+
├── sellers/
|
|
22
|
+
│ └── acme.yml
|
|
23
|
+
├── clients/
|
|
24
|
+
│ ├── client_a/
|
|
25
|
+
│ │ ├── client.yml
|
|
26
|
+
│ │ ├── invoice_001.yml <-- Refer to client.yml
|
|
27
|
+
│ │ ├── invoice_002.yml
|
|
28
|
+
│ │ └── quote_2026_001.yml
|
|
29
|
+
│ └── client_b/
|
|
30
|
+
│ │ ├── client.yml
|
|
31
|
+
│ └── invoice_001.yml
|
|
32
|
+
└── invoices/
|
|
33
|
+
└── 2026/
|
|
34
|
+
└── INV-2026-001.pdf
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Reference sellers and clients from any invoice:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
# invoices/2026/INV-2026-001.yml
|
|
41
|
+
document:
|
|
42
|
+
type: invoice
|
|
43
|
+
number: INV-2026-001
|
|
44
|
+
|
|
45
|
+
seller: ../sellers/acme.yml
|
|
46
|
+
client: ../clients/client_a/client.yml # or inline
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
### With Nix (no installation required)
|
|
52
|
+
|
|
53
|
+
Run directly without installing:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
nix run github:pcboy/ymlbill -- generate invoice.yml
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### With Ruby
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
gem install ymlbill
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### From source
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/pcboy/ymlbill
|
|
69
|
+
cd ymlbill
|
|
70
|
+
gem build ymlbill.gemspec
|
|
71
|
+
gem install ./ymlbill-*.gem
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
1. Create an invoice YAML file:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# invoice.yml
|
|
80
|
+
document:
|
|
81
|
+
type: invoice
|
|
82
|
+
number: INV-2026-001
|
|
83
|
+
date: 2026-08-13
|
|
84
|
+
due_date: 2026-08-27
|
|
85
|
+
currency: EUR
|
|
86
|
+
tax_perc: 20
|
|
87
|
+
|
|
88
|
+
seller: sellers/acme.yml
|
|
89
|
+
client: clients/client_1.yml
|
|
90
|
+
|
|
91
|
+
items:
|
|
92
|
+
- description: Consulting services
|
|
93
|
+
quantity: 3
|
|
94
|
+
unit_price: 150.00
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
2. Generate the PDF:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ymlbill generate invoice.yml
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Custom template
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
ymlbill generate invoice.yml -t custom.html.erb
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Custom output path
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
ymlbill generate invoice.yml -o output.pdf
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Options
|
|
116
|
+
|
|
117
|
+
- `-t, --template PATH` - Custom HTML ERB template
|
|
118
|
+
- `-o, --output PATH` - Output PDF path
|
|
119
|
+
- `--version` - Print version
|
|
120
|
+
- `-h, --help` - Show help
|
|
121
|
+
|
|
122
|
+
## Example YAML
|
|
123
|
+
|
|
124
|
+
### Inline client and seller
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
# invoice.yml
|
|
128
|
+
document:
|
|
129
|
+
type: invoice
|
|
130
|
+
number: INV-2026-001
|
|
131
|
+
date: 2026-08-13
|
|
132
|
+
due_date: 2026-08-27
|
|
133
|
+
currency: EUR
|
|
134
|
+
tax_perc: 20
|
|
135
|
+
|
|
136
|
+
seller:
|
|
137
|
+
name: Acme Corp
|
|
138
|
+
email: billing@acme.com
|
|
139
|
+
phone: +33 1 23 45 67 89
|
|
140
|
+
address: |
|
|
141
|
+
123 Business St
|
|
142
|
+
75001 Paris
|
|
143
|
+
France
|
|
144
|
+
|
|
145
|
+
client:
|
|
146
|
+
name: Client Corp
|
|
147
|
+
email: contact@client.com
|
|
148
|
+
address: |
|
|
149
|
+
456 Client Ave
|
|
150
|
+
69001 Lyon
|
|
151
|
+
France
|
|
152
|
+
|
|
153
|
+
items:
|
|
154
|
+
- description: Consulting services
|
|
155
|
+
quantity: 3
|
|
156
|
+
unit_price: 150.00
|
|
157
|
+
- description: Travel expenses
|
|
158
|
+
quantity: 1
|
|
159
|
+
unit_price: 50.00
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### With file references
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
# invoice.yml
|
|
166
|
+
document:
|
|
167
|
+
type: invoice
|
|
168
|
+
number: INV-2026-001
|
|
169
|
+
date: 2026-08-13
|
|
170
|
+
currency: EUR
|
|
171
|
+
tax_perc: 20
|
|
172
|
+
|
|
173
|
+
seller: sellers/acme.yml
|
|
174
|
+
client: clients/client_1.yml
|
|
175
|
+
|
|
176
|
+
items:
|
|
177
|
+
- description: Web development
|
|
178
|
+
quantity: 10
|
|
179
|
+
unit_price: 120.00
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Quote example
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
# quote.yml
|
|
186
|
+
document:
|
|
187
|
+
type: quote
|
|
188
|
+
number: QUO-2026-001
|
|
189
|
+
date: 2026-08-13
|
|
190
|
+
currency: EUR
|
|
191
|
+
tax_perc: 20
|
|
192
|
+
|
|
193
|
+
seller:
|
|
194
|
+
name: Acme Corp
|
|
195
|
+
email: billing@acme.com
|
|
196
|
+
address: |
|
|
197
|
+
123 Business St
|
|
198
|
+
75001 Paris
|
|
199
|
+
France
|
|
200
|
+
|
|
201
|
+
client:
|
|
202
|
+
name: Prospect Ltd
|
|
203
|
+
email: contact@prospect.com
|
|
204
|
+
address: |
|
|
205
|
+
789 Prospect Blvd
|
|
206
|
+
13001 Marseille
|
|
207
|
+
France
|
|
208
|
+
|
|
209
|
+
items:
|
|
210
|
+
- description: Website redesign
|
|
211
|
+
quantity: 1
|
|
212
|
+
unit_price: 2500.00
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Custom Templates
|
|
216
|
+
|
|
217
|
+
A default template is included at [`lib/ymlbill/templates/default.html.erb`](lib/ymlbill/templates/default.html.erb).
|
|
218
|
+
|
|
219
|
+
Create a custom ERB template to customize the invoice/quote appearance:
|
|
220
|
+
|
|
221
|
+
```erb
|
|
222
|
+
<!-- custom.html.erb -->
|
|
223
|
+
<!DOCTYPE html>
|
|
224
|
+
<html>
|
|
225
|
+
<head>
|
|
226
|
+
<meta charset="utf-8">
|
|
227
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
228
|
+
</head>
|
|
229
|
+
<body class="p-8">
|
|
230
|
+
<h1 class="text-3xl font-bold mb-4"><%= data.document.type.capitalize %></h1>
|
|
231
|
+
<p>Number: <%= data.document.number %></p>
|
|
232
|
+
<p>Date: <%= data.document.date %></p>
|
|
233
|
+
|
|
234
|
+
<div class="mt-8">
|
|
235
|
+
<h2 class="text-xl font-semibold">Seller</h2>
|
|
236
|
+
<p><%= data.seller.name %></p>
|
|
237
|
+
<p><%= data.seller.address %></p>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div class="mt-4">
|
|
241
|
+
<h2 class="text-xl font-semibold">Client</h2>
|
|
242
|
+
<p><%= data.client.name %></p>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<table class="mt-8 w-full">
|
|
246
|
+
<thead>
|
|
247
|
+
<tr>
|
|
248
|
+
<th>Description</th>
|
|
249
|
+
<th>Qty</th>
|
|
250
|
+
<th>Unit Price</th>
|
|
251
|
+
<th>Total</th>
|
|
252
|
+
</tr>
|
|
253
|
+
</thead>
|
|
254
|
+
<tbody>
|
|
255
|
+
<% data.items.each do |item| %>
|
|
256
|
+
<tr>
|
|
257
|
+
<td><%= item.description %></td>
|
|
258
|
+
<td><%= item.quantity %></td>
|
|
259
|
+
<td><%= money(item.unit_price, data.document.currency) %></td>
|
|
260
|
+
<td><%= money(item.quantity * item.unit_price, data.document.currency) %></td>
|
|
261
|
+
</tr>
|
|
262
|
+
<% end %>
|
|
263
|
+
</tbody>
|
|
264
|
+
</table>
|
|
265
|
+
</body>
|
|
266
|
+
</html>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The template has access to:
|
|
270
|
+
|
|
271
|
+
- `data` - All YAML data with dot notation (e.g., `data.document.number`, `data.client.name`)
|
|
272
|
+
- `money(amount, currency)` - Helper to format currency using the Money gem. Currency must be a valid ISO 4217 code (e.g., EUR, USD, GBP)
|
|
273
|
+
|
|
274
|
+
Then use it with:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
ymlbill generate invoice.yml -t custom.html.erb
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Flexibility: Add Any Fields You Need
|
|
281
|
+
|
|
282
|
+
The YAML structure is completely flexible. You can add any custom fields to your invoice and they will be passed through to your template:
|
|
283
|
+
|
|
284
|
+
```yaml
|
|
285
|
+
# invoice.yml
|
|
286
|
+
document:
|
|
287
|
+
type: invoice
|
|
288
|
+
number: INV-2026-001
|
|
289
|
+
date: 2026-08-13
|
|
290
|
+
currency: EUR
|
|
291
|
+
tax_perc: 20
|
|
292
|
+
|
|
293
|
+
seller: sellers/acme.yml
|
|
294
|
+
client: clients/client_1.yml
|
|
295
|
+
|
|
296
|
+
# Custom fields - add whatever you need
|
|
297
|
+
project:
|
|
298
|
+
name: Website Redesign
|
|
299
|
+
code: PRJ-2026-042
|
|
300
|
+
manager: John Doe
|
|
301
|
+
|
|
302
|
+
items:
|
|
303
|
+
- description: Development
|
|
304
|
+
quantity: 10
|
|
305
|
+
unit_price: 150.00
|
|
306
|
+
|
|
307
|
+
# Add notes, terms, or any other metadata
|
|
308
|
+
terms: Payment due within 30 days
|
|
309
|
+
po_number: PO-12345
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Then access them in your custom template:
|
|
313
|
+
|
|
314
|
+
```erb
|
|
315
|
+
<p>Project: <%= data.project.name %> (<%= data.project.code %>)</p>
|
|
316
|
+
<p>Project Manager: <%= data.project.manager %></p>
|
|
317
|
+
<p>PO Number: <%= data.po_number %></p>
|
|
318
|
+
<p>Terms: <%= data.terms %></p>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
This lets you adapt invoices to your specific business needs. Purchase orders, project codes, cost centers, custom tax fields, or any other metadata your workflow requires.
|
|
322
|
+
|
|
323
|
+
## Development
|
|
324
|
+
|
|
325
|
+
### Prerequisites
|
|
326
|
+
|
|
327
|
+
- Ruby 3.4+
|
|
328
|
+
- Nix (optional, for reproducible environment)
|
|
329
|
+
|
|
330
|
+
### Setup
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
# With Nix
|
|
334
|
+
nix develop
|
|
335
|
+
|
|
336
|
+
# Without Nix
|
|
337
|
+
bundle install
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Running tests
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
bundle exec rspec
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Building the gem
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
gem build ymlbill.gemspec
|
|
350
|
+
gem install ./ymlbill-*.gem
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Contributing
|
|
354
|
+
|
|
355
|
+
1. Fork the repository
|
|
356
|
+
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
357
|
+
3. Commit your changes (`git commit -am 'Add new feature'`)
|
|
358
|
+
4. Push to the branch (`git push origin feature/my-feature`)
|
|
359
|
+
5. Create a Pull Request
|
|
360
|
+
|
|
361
|
+
## License
|
|
362
|
+
|
|
363
|
+
MIT
|
data/exe/ymlbill
ADDED
data/lib/ymlbill/cli.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require 'thor'
|
|
2
|
+
require 'tempfile'
|
|
3
|
+
|
|
4
|
+
module Ymlbill
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
desc 'generate INPUT_YAML', 'Generate PDF invoice/quote from YAML file'
|
|
7
|
+
option :template, aliases: ['-t'], type: :string, desc: 'Path to custom HTML ERB template'
|
|
8
|
+
option :output, aliases: ['-o'], type: :string,
|
|
9
|
+
desc: 'Output PDF path (default: input basename + .pdf)'
|
|
10
|
+
option :debug_html, aliases: ['-d'], type: :boolean, default: false,
|
|
11
|
+
desc: 'Keep HTML file for debugging'
|
|
12
|
+
|
|
13
|
+
rescue_from InputFileNotFoundError do |e|
|
|
14
|
+
warn "Error: #{e.message}"
|
|
15
|
+
exit 3
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
rescue_from InvalidYamlError do |e|
|
|
19
|
+
warn "Error: #{e.message}"
|
|
20
|
+
exit 3
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
rescue_from TemplateNotFoundError do |e|
|
|
24
|
+
warn "Error: #{e.message}"
|
|
25
|
+
exit 4
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rescue_from TemplateRenderError do |e|
|
|
29
|
+
warn "Error: #{e.message}"
|
|
30
|
+
exit 4
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
rescue_from PdfGenerationError do |e|
|
|
34
|
+
warn "Error: #{e.message}"
|
|
35
|
+
exit 5
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate(input_file)
|
|
39
|
+
@input_file = input_file
|
|
40
|
+
run_pipeline
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc '--version', 'Print version'
|
|
44
|
+
def version
|
|
45
|
+
puts "ymlbill #{Ymlbill::VERSION}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
map '--version' => :version
|
|
49
|
+
map ['-h', '--help'] => :help
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def run_pipeline
|
|
54
|
+
data = DocumentLoader.load(@input_file)
|
|
55
|
+
template_path = TemplateResolver.resolve(options[:template])
|
|
56
|
+
base_dir = File.dirname(File.expand_path(@input_file))
|
|
57
|
+
html = HtmlRenderer.new(template_path: template_path, base_dir: base_dir).render(data: data)
|
|
58
|
+
|
|
59
|
+
output_path = options[:output] || default_output_path(@input_file)
|
|
60
|
+
|
|
61
|
+
Tempfile.create(['ymlbill', '.html']) do |f|
|
|
62
|
+
f.write(html)
|
|
63
|
+
f.flush
|
|
64
|
+
|
|
65
|
+
if options[:debug_html]
|
|
66
|
+
debug_path = "#{File.basename(@input_file, '.*')}.html"
|
|
67
|
+
File.write(debug_path, html)
|
|
68
|
+
$stdout.puts "HTML saved to: #{debug_path}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
PdfEngine.build.render(html_path: f.path, output_path: output_path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
$stdout.puts "Generated: #{output_path}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def default_output_path(input)
|
|
78
|
+
basename = File.basename(input, '.*')
|
|
79
|
+
"#{basename}.pdf"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Ymlbill
|
|
2
|
+
class DocumentLoader
|
|
3
|
+
def self.load(path)
|
|
4
|
+
raise InputFileNotFoundError, "Input file not found: #{path}" unless File.exist?(path)
|
|
5
|
+
|
|
6
|
+
yaml_content = File.read(path)
|
|
7
|
+
data = YAML.safe_load(yaml_content, permitted_classes: [Date])
|
|
8
|
+
|
|
9
|
+
raise InvalidYamlError, "YAML must be a hash, got #{data.class}" unless data.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
base_dir = File.dirname(path)
|
|
12
|
+
|
|
13
|
+
%w[seller client].each do |x|
|
|
14
|
+
data[x] = load(File.join(base_dir, data[x])) if data[x].is_a?(String)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
data
|
|
18
|
+
rescue ::SyntaxError, Psych::SyntaxError => e
|
|
19
|
+
raise InvalidYamlError, "Invalid YAML syntax: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require 'ostruct'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'money'
|
|
4
|
+
|
|
5
|
+
module Ymlbill
|
|
6
|
+
class HtmlRenderer
|
|
7
|
+
def initialize(template_path:, base_dir: nil)
|
|
8
|
+
@template_path = template_path
|
|
9
|
+
@base_dir = base_dir
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(data:)
|
|
13
|
+
template_content = File.read(@template_path)
|
|
14
|
+
|
|
15
|
+
data = JSON.parse(data.to_json, object_class: OpenStruct)
|
|
16
|
+
|
|
17
|
+
erb = ERB.new(template_content, trim_mode: '-')
|
|
18
|
+
erb.result(binding)
|
|
19
|
+
rescue SyntaxError => e
|
|
20
|
+
raise TemplateRenderError, "Template syntax error: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def money(amount, currency = 'EUR')
|
|
24
|
+
Money.new(amount * 100, currency.to_s.upcase).format(
|
|
25
|
+
symbol: true, decimal_mark: '.', thousands_separator: ' '
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def logo_path(path)
|
|
30
|
+
return nil unless path
|
|
31
|
+
|
|
32
|
+
full_path = @base_dir ? File.join(@base_dir, path) : File.expand_path(path)
|
|
33
|
+
full_path = File.expand_path(full_path)
|
|
34
|
+
return nil unless File.exist?(full_path)
|
|
35
|
+
|
|
36
|
+
ext = File.extname(full_path).downcase
|
|
37
|
+
mime_type = case ext
|
|
38
|
+
when '.jpg', '.jpeg' then 'image/jpeg'
|
|
39
|
+
when '.svg' then 'image/svg+xml'
|
|
40
|
+
when '.gif' then 'image/gif'
|
|
41
|
+
when '.webp' then 'image/webp'
|
|
42
|
+
else 'image/png'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
content = File.read(full_path, mode: 'rb')
|
|
46
|
+
base64 = [content].pack('m0')
|
|
47
|
+
"data:#{mime_type};base64,#{base64}"
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
warn "Warning: Could not load logo from #{path}: #{e.message}"
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "ferrum"
|
|
2
|
+
|
|
3
|
+
module Ymlbill
|
|
4
|
+
module PdfEngines
|
|
5
|
+
class Chromium
|
|
6
|
+
def render(html_path:, output_path:)
|
|
7
|
+
browser = Ferrum::Browser.new(
|
|
8
|
+
browser_path: ENV["BROWSER_PATH"],
|
|
9
|
+
headless: true,
|
|
10
|
+
browser_options: { "no-sandbox": nil }
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
browser.go_to("file://#{File.expand_path(html_path)}")
|
|
14
|
+
browser.pdf(
|
|
15
|
+
path: output_path,
|
|
16
|
+
paperWidth: 8.27,
|
|
17
|
+
paperHeight: 11.69,
|
|
18
|
+
printBackground: true
|
|
19
|
+
)
|
|
20
|
+
rescue ::Ferrum::Error => e
|
|
21
|
+
raise PdfGenerationError, "Failed to generate PDF: #{e.message}"
|
|
22
|
+
ensure
|
|
23
|
+
browser&.quit
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Ymlbill
|
|
2
|
+
class TemplateResolver
|
|
3
|
+
DEFAULT_TEMPLATE_PATH = File.expand_path('templates/default.html.erb', __dir__)
|
|
4
|
+
|
|
5
|
+
class << self
|
|
6
|
+
def resolve(template_path)
|
|
7
|
+
return DEFAULT_TEMPLATE_PATH if template_path.nil?
|
|
8
|
+
|
|
9
|
+
unless File.exist?(template_path)
|
|
10
|
+
raise TemplateNotFoundError, "Template not found: #{template_path}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
template_path
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><%= data.document.type.capitalize %> <%= data.document.number %></title>
|
|
7
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
8
|
+
<style type="text/tailwindcss">
|
|
9
|
+
@media print {
|
|
10
|
+
body {
|
|
11
|
+
-webkit-print-color-adjust: exact;
|
|
12
|
+
print-color-adjust: exact;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body class="bg-neutral-100 p-4 text-xs text-neutral-800 antialiased">
|
|
18
|
+
<div class="mx-auto w-full max-w-3xl p-1">
|
|
19
|
+
|
|
20
|
+
<!-- Header: seller logo + document title -->
|
|
21
|
+
<div class="flex items-start justify-between mb-8">
|
|
22
|
+
<% if data.seller.logo %>
|
|
23
|
+
<img src="<%= logo_path(data.seller.logo) %>" alt="<%= data.seller.name %>" class="h-12 w-auto" />
|
|
24
|
+
<% else %>
|
|
25
|
+
<h1 class="bg-gradient-to-r from-pink-500 via-yellow-400 to-cyan-400 bg-clip-text text-2xl font-black italic tracking-tight text-transparent drop-shadow-md">
|
|
26
|
+
<%= data.seller.name %>
|
|
27
|
+
</h1>
|
|
28
|
+
<% end %>
|
|
29
|
+
<div class="text-right">
|
|
30
|
+
<h2 class="text-3xl font-light uppercase tracking-wide text-neutral-800"><%= data.document.type %></h2>
|
|
31
|
+
<p class="mt-1 text-sm text-neutral-500"><%= data.document.type.capitalize %> # <%= data.document.number %></p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Seller (left) + Date/Client (right) -->
|
|
36
|
+
<div class="mb-6 flex justify-between">
|
|
37
|
+
<!-- Seller + Client side-by-side in a shared container -->
|
|
38
|
+
<div class="flex gap-12">
|
|
39
|
+
<div class="text-xs">
|
|
40
|
+
<p class="font-bold text-neutral-900"><%= data.seller.name %></p>
|
|
41
|
+
<% if data.seller.address %>
|
|
42
|
+
<p class="whitespace-pre-line text-neutral-600"><%= data.seller.address %></p>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% if data.seller.email %><p class="mt-0.5 text-neutral-600"><%= data.seller.email %></p><% end %>
|
|
45
|
+
<% if data.seller.phone %><p class="text-neutral-600"><%= data.seller.phone %></p><% end %>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="text-xs">
|
|
49
|
+
<p class="mb-0.5 text-neutral-500">Bill To:</p>
|
|
50
|
+
<p class="font-bold text-neutral-900"><%= data.client.name %></p>
|
|
51
|
+
<% if data.client.address %>
|
|
52
|
+
<p class="whitespace-pre-line text-neutral-600"><%= data.client.address %></p>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% if data.client.email %><p class="mt-0.5 text-neutral-600"><%= data.client.email %></p><% end %>
|
|
55
|
+
<% if data.client.phone %><p class="text-neutral-600"><%= data.client.phone %></p><% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Date block on the far right -->
|
|
60
|
+
<div class="w-56 text-xs">
|
|
61
|
+
<div class="flex justify-between py-0.5">
|
|
62
|
+
<span class="text-neutral-500">Date:</span>
|
|
63
|
+
<span class="text-neutral-900"><%= data.document.date %></span>
|
|
64
|
+
</div>
|
|
65
|
+
<% if data.document.due_date %>
|
|
66
|
+
<div class="flex justify-between py-0.5">
|
|
67
|
+
<span class="text-neutral-500">Due Date:</span>
|
|
68
|
+
<span class="text-neutral-900"><%= data.document.due_date %></span>
|
|
69
|
+
</div>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<!-- Items table: dark rounded header bar, borderless rows -->
|
|
75
|
+
<table class="mb-4 w-full text-xs">
|
|
76
|
+
<thead>
|
|
77
|
+
<tr class="bg-neutral-700 text-xs font-medium text-white">
|
|
78
|
+
<th class="w-1/2 rounded-l-md border-r border-white/20 py-1.5 pl-3 pr-2 text-left">Item</th>
|
|
79
|
+
<th class="w-1/6 border-r border-white/20 py-1.5 px-2 text-center">Quantity</th>
|
|
80
|
+
<th class="w-1/6 border-r border-white/20 py-1.5 px-2 text-right">Rate</th>
|
|
81
|
+
<th class="w-1/6 rounded-r-md py-1.5 pl-2 pr-3 text-right">Amount</th>
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody class="text-xs">
|
|
85
|
+
<% data.items.each do |item| %>
|
|
86
|
+
<tr>
|
|
87
|
+
<td class="py-1 pl-3 pr-2 font-bold text-neutral-800"><%= item.description %></td>
|
|
88
|
+
<td class="py-1 px-2 text-center text-neutral-600"><%= item.quantity %></td>
|
|
89
|
+
<td class="py-1 px-2 text-right text-neutral-600"><%= money(item.unit_price, data.document.currency) %></td>
|
|
90
|
+
<td class="py-1 pl-2 pr-3 text-right text-neutral-600"><%= money(item.quantity * item.unit_price, data.document.currency) %></td>
|
|
91
|
+
</tr>
|
|
92
|
+
<% end %>
|
|
93
|
+
</tbody>
|
|
94
|
+
</table>
|
|
95
|
+
|
|
96
|
+
<!-- Totals -->
|
|
97
|
+
<% subtotal = data.items.sum { |item| item.quantity * item.unit_price } %>
|
|
98
|
+
<% tax_amount = subtotal * (data.document.tax_perc.to_f / 100) %>
|
|
99
|
+
<div class="mb-6 flex justify-end">
|
|
100
|
+
<div class="w-56 text-xs">
|
|
101
|
+
<div class="flex justify-between border-b px-1 border-neutral-300 py-1 text-neutral-500">
|
|
102
|
+
<span>Subtotal</span>
|
|
103
|
+
<span class="font-semibold text-neutral-800"><%= money(subtotal, data.document.currency) %></span>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="flex justify-between border-b px-1 border-neutral-300 py-1 text-neutral-500">
|
|
106
|
+
<span>Tax (<%= data.document.tax_perc %>%)</span>
|
|
107
|
+
<span class="font-semibold text-neutral-800"><%= money(tax_amount, data.document.currency) %></span>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="flex justify-between bg-neutral-700 px-1 py-2 text-white">
|
|
110
|
+
<span class="font-bold">Total</span>
|
|
111
|
+
<span class="font-bold"><%= money(subtotal + tax_amount, data.document.currency) %></span>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Payment Info + Notes side by side -->
|
|
117
|
+
<div class="mb-4 flex gap-4">
|
|
118
|
+
<% if data.seller.payment_info %>
|
|
119
|
+
<div class="flex-1 rounded-md bg-white p-3 shadow-sm">
|
|
120
|
+
<h3 class="mb-1 text-xs font-bold uppercase tracking-wide text-neutral-500">Payment Information</h3>
|
|
121
|
+
<p class="whitespace-pre-line text-neutral-600"><%= data.seller.payment_info %></p>
|
|
122
|
+
</div>
|
|
123
|
+
<% end %>
|
|
124
|
+
|
|
125
|
+
<% if data.notes %>
|
|
126
|
+
<div class="flex-1 rounded-md bg-white p-3 shadow-sm">
|
|
127
|
+
<h3 class="mb-1 text-xs font-bold uppercase tracking-wide text-neutral-500">Notes</h3>
|
|
128
|
+
<p class="whitespace-pre-line text-neutral-600"><%= data.notes %></p>
|
|
129
|
+
</div>
|
|
130
|
+
<% end %>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
data/lib/ymlbill.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'erb'
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module Ymlbill
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class InputFileNotFoundError < Error; end
|
|
8
|
+
class InvalidYamlError < Error; end
|
|
9
|
+
class TemplateNotFoundError < Error; end
|
|
10
|
+
class TemplateRenderError < Error; end
|
|
11
|
+
class PdfGenerationError < Error; end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative 'ymlbill/version'
|
|
15
|
+
require_relative 'ymlbill/cli'
|
|
16
|
+
require_relative 'ymlbill/document_loader'
|
|
17
|
+
require_relative 'ymlbill/template_resolver'
|
|
18
|
+
require_relative 'ymlbill/html_renderer'
|
|
19
|
+
require_relative 'ymlbill/pdf_engine'
|
metadata
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ymlbill
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- David Hagege
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ferrum
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.15'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.15'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: money
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 7.1.1
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 7.1.1
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: ostruct
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.6'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.6'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: thor
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 1.5.0
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 1.5.0
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '13.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rspec
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.13'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.13'
|
|
96
|
+
description: A CLI tool that converts YAML invoice/quote definitions into styled PDF
|
|
97
|
+
documents using ERB and headless Chromium.
|
|
98
|
+
email:
|
|
99
|
+
- david@joynetiks.com
|
|
100
|
+
executables:
|
|
101
|
+
- ymlbill
|
|
102
|
+
extensions: []
|
|
103
|
+
extra_rdoc_files: []
|
|
104
|
+
files:
|
|
105
|
+
- README.md
|
|
106
|
+
- exe/ymlbill
|
|
107
|
+
- lib/ymlbill.rb
|
|
108
|
+
- lib/ymlbill/cli.rb
|
|
109
|
+
- lib/ymlbill/document_loader.rb
|
|
110
|
+
- lib/ymlbill/html_renderer.rb
|
|
111
|
+
- lib/ymlbill/pdf_engine.rb
|
|
112
|
+
- lib/ymlbill/pdf_engines/chromium.rb
|
|
113
|
+
- lib/ymlbill/template_resolver.rb
|
|
114
|
+
- lib/ymlbill/templates/default.html.erb
|
|
115
|
+
- lib/ymlbill/version.rb
|
|
116
|
+
homepage: https://github.com/pcboy/ymlbill
|
|
117
|
+
licenses:
|
|
118
|
+
- MIT
|
|
119
|
+
metadata:
|
|
120
|
+
rubygems_mfa_required: 'true'
|
|
121
|
+
rdoc_options: []
|
|
122
|
+
require_paths:
|
|
123
|
+
- lib
|
|
124
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '3.4'
|
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
130
|
+
requirements:
|
|
131
|
+
- - ">="
|
|
132
|
+
- !ruby/object:Gem::Version
|
|
133
|
+
version: '0'
|
|
134
|
+
requirements: []
|
|
135
|
+
rubygems_version: 3.7.2
|
|
136
|
+
specification_version: 4
|
|
137
|
+
summary: A CLI tool to generate PDF invoices and quotes from YAML files.
|
|
138
|
+
test_files: []
|