├── .github
└── FUNDING.yml
├── .gitignore
├── LICENSE
├── README.md
├── composer.json
├── example
├── example.pdf
├── example.php
└── example.png
└── src
├── Invoice.php
└── lang.php
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: mzur
2 | custom: https://www.paypal.me/drmzur
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | composer.lock
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Martin Zurowietz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # InvoiScript
2 |
3 | Generate simple PDF invoices with PHP.
4 |
5 | ## Installation
6 |
7 | Run:
8 | ```bash
9 | composer require mzur/invoiscript
10 | ```
11 |
12 | ## Usage
13 |
14 | ### Example
15 |
16 | ```php
17 | use Mzur\InvoiScript\Invoice;
18 |
19 | require_once(__DIR__.'/vendor/autoload.php');
20 |
21 | $content = [
22 | 'title' => 'Invoice No. 1',
23 | 'beforeInfo' => [
24 | 'Date:',
25 | 'June 10, 2021',
26 | ],
27 | 'afterInfo' => [
28 | 'All prices in EUR.',
29 | '',
30 | 'This invoice is due on June 20, 2021.',
31 | ],
32 | 'clientAddress' => [
33 | 'Jane Doe',
34 | 'Example Street 42',
35 | '1337 Demo City',
36 | ],
37 | 'entries' => [
38 | [
39 | 'description' => 'Hot air',
40 | 'quantity' => 11,
41 | 'price' => 8,
42 | ],
43 | [
44 | 'description' => 'Something cool',
45 | 'quantity' => 5,
46 | 'price' => 20,
47 | ],
48 | ],
49 | ];
50 |
51 | $pdf = new Invoice($content);
52 | $pdf->generate('invoice.pdf');
53 | ```
54 |
55 | This generates the following PDF:
56 |
57 |
58 |
59 | ### Styling
60 |
61 | Content in `title`, `beforeInfo` and `afterInfo` can be styled with basic HTML-like tags. Example:
62 |
63 | ```php
64 | $content = [
65 | 'title' => 'Invoice No. 1',
66 | 'beforeInfo' => [
67 | 'Date:',
68 | 'June 10, 2021',
69 | ],
70 | //...
71 | ];
72 | ```
73 |
74 | Available tags:
75 |
76 | - ``: Bold
77 | - ``: Italic
78 | - ``: Underlined
79 |
80 | See the [layout section](#layout) for customization of font and font sizes.
81 |
82 | ### Template
83 |
84 | Set a template file:
85 |
86 | ```php
87 | $pdf = new Invoice($content);
88 | $pdf->setTemplate(__DIR__.'/template.pdf');
89 | ```
90 |
91 | The template can have multiple pages, which will be used for the matching pages of the invoice. If the invoice has more pages than the template, the last page of the template will be repeated.
92 |
93 | ### Language
94 |
95 | Set the language:
96 |
97 | ```php
98 | $pdf = new Invoice($content);
99 | $pdf->setLanguage('de');
100 | ```
101 |
102 | Available languages are `en` and `de`. Default is `en`.
103 |
104 | ### Variables
105 |
106 | Variables can be used in `title`, `beforeInfo` and `afterInfo`. Example:
107 |
108 | ```php
109 | $content = [
110 | 'title' => 'Invoice No. {number}',
111 | 'beforeInfo' => [
112 | 'Date:',
113 | '{createdDate}',
114 | ],
115 | //...
116 | ];
117 |
118 | $variables = [
119 | 'number' => 1,
120 | 'createdDate' => 'June 10, 2021',
121 | ];
122 |
123 | $pdf = new Invoice($content);
124 | $pdf->setVariables($variables);
125 | ```
126 |
127 | The following variables are always available:
128 |
129 | - `{total}`: Total amount of the invoice.
130 | - `{page}`: Current page number.
131 | - `{pages}`: Total number of pages.
132 |
133 | ### Layout
134 |
135 | The default spacings, font, font size etc. can be overridden with a custom layout. Example:
136 |
137 | ```php
138 | $layout = [
139 | 'font' => 'helvetica',
140 | ];
141 |
142 | $pdf = new Invoice($content);
143 | $pdf->setLayout($layout);
144 | ```
145 |
146 | See the [source code](src/Invoice.php#L357) for all available layout options and the defaults.
147 |
148 | ## Acknowledgment
149 |
150 | Thanks to the creator(s) of [FPDF](http://www.fpdf.org/) and [FPDI](https://www.setasign.com/products/fpdi/about/)!
151 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mzur/invoiscript",
3 | "description": "Generate simple PDF invoices",
4 | "type": "library",
5 | "require": {
6 | "setasign/fpdf": "^1.8",
7 | "setasign/fpdi": "^2.3"
8 | },
9 | "license": "MIT",
10 | "authors": [
11 | {
12 | "name": "Martin Zurowietz",
13 | "email": "martin@zurowietz.de"
14 | }
15 | ],
16 | "autoload": {
17 | "psr-4": {
18 | "Mzur\\InvoiScript\\": "src/"
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/example/example.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mzur/InvoiScript/da29b0668f26c88e158aca6cc56a46a368c6c9c8/example/example.pdf
--------------------------------------------------------------------------------
/example/example.php:
--------------------------------------------------------------------------------
1 | 'Invoice No. 1',
10 | 'beforeInfo' => [
11 | 'Date:',
12 | 'June 10, 2021',
13 | ],
14 | 'afterInfo' => [
15 | 'All prices in EUR.',
16 | '',
17 | 'This invoice is due on June 20, 2021.',
18 | ],
19 | 'clientAddress' => [
20 | 'Jane Doe',
21 | 'Example Street 42',
22 | '1337 Demo City',
23 | ],
24 | 'entries' => [
25 | [
26 | 'description' => 'Hot air',
27 | 'quantity' => 11,
28 | 'price' => 8,
29 | ],
30 | [
31 | 'description' => 'Something cool',
32 | 'quantity' => 5,
33 | 'price' => 20,
34 | ],
35 | ],
36 | ];
37 |
38 | $pdf = new Invoice($content);
39 | $pdf->generate('example.pdf');
40 |
--------------------------------------------------------------------------------
/example/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mzur/InvoiScript/da29b0668f26c88e158aca6cc56a46a368c6c9c8/example/example.png
--------------------------------------------------------------------------------
/src/Invoice.php:
--------------------------------------------------------------------------------
1 | content = $content;
86 | $this->entries = $this->content['entries'] ?? [];
87 | if (empty($this->entries)) {
88 | throw new Exception("No item entries for the invoice.");
89 | }
90 | $this->setLayout();
91 | $this->setVariables();
92 | $this->setAutoPageBreak(false);
93 | $this->templatePages = 0;
94 | $this->aliasNbPages('{pages}');
95 | }
96 |
97 | /**
98 | * Set the template to use.
99 | *
100 | * @param string $path Path to the PDF template. The last page of the template will be
101 | * used for all pages of the invoice that have a higher page number. Else each
102 | * template page will be used for each invoice page.
103 | */
104 | public function setTemplate($path)
105 | {
106 | $this->templatePages = $this->setSourceFile($path);
107 | }
108 |
109 | /**
110 | * Set the language to use.
111 | *
112 | * @param string $code Language code.
113 | */
114 | public function setLanguage($code)
115 | {
116 | $this->lang = $code;
117 | }
118 |
119 | /**
120 | * Set custom layout variables.
121 | *
122 | * @param array $layout
123 | */
124 | public function setLayout($layout = [])
125 | {
126 | $this->layout = $this->getLayout($layout);
127 | $this->setFont($this->layout['font'], '', $this->layout['fontSize']);
128 | $this->setMargins($this->layout['pagePaddingLeft'], $this->layout['pagePaddingTop']);
129 | }
130 |
131 | /**
132 | * Set custom content variables.
133 | *
134 | * @param array $variables
135 | */
136 | public function setVariables($variables = [])
137 | {
138 | $this->variables = $this->getVariables($variables);
139 | }
140 |
141 | /**
142 | * Generate the invoice PDF.
143 | *
144 | * @param string $path
145 | */
146 | public function generate($path)
147 | {
148 | $this->newPage();
149 | $this->makeLetterhead();
150 | $this->makeTitle();
151 | $this->makeBeforeInfo();
152 | $this->makeEntries();
153 | $this->makeAfterInfo();
154 | $this->output('F', $path, true);
155 | }
156 |
157 | /**
158 | * Render the letterhead.
159 | */
160 | protected function makeLetterhead()
161 | {
162 | $address = $this->content['clientAddress'];
163 | $this->setMargins($this->layout['addressPaddingLeft'], $this->layout['pagePaddingTop']);
164 | $this->setY($this->layout['addressMarginTop']);
165 |
166 | foreach ($address as $line) {
167 | $this->cell(0, $this->layout['contentCellHeight'], mb_convert_encoding($line, 'ISO-8859-1', 'UTF-8'), 0, 1);
168 | }
169 |
170 | $this->setMargins($this->layout['pagePaddingLeft'], $this->layout['pagePaddingTop']);
171 | }
172 |
173 | /**
174 | * Render the invoice title.
175 | */
176 | protected function makeTitle()
177 | {
178 | $this->setY($this->layout['titleMarginTop']);
179 | $this->setFont('', 'B', $this->layout['titleFontSize']);
180 | $this->write($this->layout['titleCellHeight'], $this->content['title']);
181 | $this->setFont('', '', $this->layout['fontSize']);
182 | }
183 |
184 | /**
185 | * Render the text before the item table.
186 | */
187 | protected function makeBeforeInfo()
188 | {
189 | $this->setY($this->layout['contentMarginTopP1']);
190 | $this->renderInfo($this->content['beforeInfo'] ?? []);
191 | }
192 |
193 | /**
194 | * Render the item table.
195 | */
196 | protected function makeEntries()
197 | {
198 | $this->setY($this->getY() + $this->layout['entriesPaddingTop']);
199 | $widths = $this->layout['entriesColumnWidths'];
200 | $alignment = $this->layout['entriesColumnAlignment'];
201 |
202 | $makeHeader = function ($widths, $alignment) {
203 | $header = [
204 | $this->t('quantity'),
205 | $this->t('item'),
206 | $this->t('price'),
207 | $this->t('total'),
208 | ];
209 | $this->setFont('', 'b');
210 | foreach ($widths as $index => $width) {
211 | $this->cell($width, $this->layout['titleCellHeight'], mb_convert_encoding($header[$index],'ISO-8859-1', 'UTF-8'), 'BT', 0, $alignment[$index]);
212 | }
213 | $this->setFont('', '');
214 | $this->ln();
215 | };
216 |
217 | $makeHeader($widths, $alignment);
218 | $currentY = 0;
219 | $nextY = $this->getY();
220 |
221 | foreach ($this->entries as $entry) {
222 | $this->setY($nextY);
223 | if ($this->needsNewPage()) {
224 | $this->newPage();
225 | $makeHeader($widths, $alignment);
226 | $nextY = $this->getY();
227 | }
228 | $currentY = $nextY;
229 | $this->cell($widths[0], $this->layout['contentCellHeight'], $this->numberFormat($entry['quantity']), '', 0, $alignment[0]);
230 | $this->multiCell($widths[1], $this->layout['contentCellHeight'], mb_convert_encoding($entry['description'], 'ISO-8859-1', 'UTF-8'), 0, $alignment[1]);
231 | $nextY = max($nextY, $this->getY());
232 | $this->setXY($this->layout['pagePaddingLeft'] + $widths[0] + $widths[1], $currentY);
233 | $this->cell($widths[2], $this->layout['contentCellHeight'], $this->numberFormat($entry['price']), '', 0, $alignment[2]);
234 | $total = $entry['quantity'] * $entry['price'];
235 | $this->cell($widths[3], $this->layout['contentCellHeight'], $this->numberFormat($total), '', 1, $alignment[3]);
236 | }
237 |
238 | $this->setY($nextY);
239 | $this->setFont('', 'b');
240 | $this->cell($widths[0], $this->layout['contentCellHeight'], '', 'BT');
241 | $this->cell($widths[1], $this->layout['contentCellHeight'], '', 'BT');
242 | $this->cell($widths[2], $this->layout['contentCellHeight'], '', 'BT');
243 | $this->cell($widths[3], $this->layout['contentCellHeight'], $this->variables['total'], 'BT', 0, $alignment[3]);
244 | $this->setFont('', '');
245 | }
246 |
247 | /**
248 | * Render the text after the item table.
249 | */
250 | protected function makeAfterInfo()
251 | {
252 | $this->setY($this->getY() + $this->layout['entriesPaddingBottom']);
253 | $this->renderInfo($this->content['afterInfo'] ?? []);
254 | }
255 |
256 | protected function renderInfo($lines)
257 | {
258 | foreach ($lines as $line) {
259 | $this->write($this->layout['contentCellHeight'], $line);
260 | $this->ln();
261 | if ($this->needsNewPage()) {
262 | $this->newPage();
263 | }
264 | }
265 | }
266 |
267 | /**
268 | * Write flowing text. Supports HTML-like tags for (bold), (italic), (underline) as well as variable substitutions (e.g. '{page}').
269 | *
270 | * @param int $h Line height.
271 | * @param string $txt Text
272 | * @param string $link URL or identifier returned by AddLink().
273 | */
274 | public function write($h, $txt, $link = '')
275 | {
276 | $txt = str_replace("\n",' ',$txt);
277 | $segments = preg_split('/(<\/?[uib]>)/', $txt, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
278 | foreach ($segments as $segment) {
279 | if ($segment === '') {
280 | $this->setFont('', 'b');
281 | } elseif ($segment === '') {
282 | $this->setFont('', 'i');
283 | } elseif ($segment === '') {
284 | $this->setFont('', 'u');
285 | } elseif (strpos($segment, '') === 0) {
286 | $this->setFont('', '');
287 | } else {
288 | foreach ($this->variables as $key => $value) {
289 | $segment = preg_replace("/\{{$key}\}/", $value, $segment);
290 | }
291 | parent::write($h, mb_convert_encoding($segment, 'ISO-8859-1', 'UTF-8'), $link);
292 | }
293 | }
294 | }
295 |
296 | /**
297 | * Calculate the total price of all invoice items.
298 | *
299 | * @return float
300 | */
301 | protected function getTotal()
302 | {
303 | $total = 0;
304 | foreach ($this->entries as $entry) {
305 | $total += $entry['price'] * $entry['quantity'];
306 | }
307 |
308 | return $total;
309 | }
310 |
311 | /**
312 | * Add a new page.
313 | */
314 | protected function newPage()
315 | {
316 | $this->addPage();
317 | $this->variables['page'] = $this->pageNo();
318 | if ($this->templatePages > 0) {
319 | $templateIndex = $this->importPage(min($this->pageNo(), $this->templatePages));
320 | $this->useImportedPage($templateIndex);
321 | }
322 |
323 | $this->setXY($this->layout['pageNoX'], $this->layout['pageNoY']);
324 | $this->write(5, $this->t('page'));
325 | $this->setY($this->layout['contentMarginTopPX']);
326 | }
327 |
328 | /**
329 | * Determine if a new page is needed.
330 | *
331 | * @return bool
332 | */
333 | protected function needsNewPage()
334 | {
335 | return $this->getY() >= $this->layout['pageMaxY'];
336 | }
337 |
338 | /**
339 | * Get a translated string.
340 | *
341 | * @param string $key Translation key.
342 | *
343 | * @return string
344 | */
345 | protected function t($key)
346 | {
347 | return static::translate($this->lang, $key);
348 | }
349 |
350 | /**
351 | * Get the page layout array.
352 | *
353 | * @param array $override User-defined overrides.
354 | *
355 | * @return array
356 | */
357 | protected function getLayout($override = [])
358 | {
359 | return array_merge([
360 | // Y coordinate to start the address text.
361 | 'addressMarginTop' => 52.5,
362 | // X coordinate to start the address text.
363 | 'addressPaddingLeft' => 30,
364 | // Height of a regular text line.
365 | 'contentCellHeight' => 5,
366 | // Y coordinate to start the content of the first page.
367 | 'contentMarginTopP1' => 105,
368 | // Y coordinate to start the content of the pages following the first.
369 | 'contentMarginTopPX' => 45,
370 | // Text alignment of the invoice entries columns.
371 | 'entriesColumnAlignment' => ['R', 'L', 'R', 'R'],
372 | // Width of the invoice entries columns.
373 | 'entriesColumnWidths' => [25, 105, 20, 25],
374 | // Space after the entry table.
375 | 'entriesPaddingBottom' => 15,
376 | // Space before the entry table.
377 | 'entriesPaddingTop' => 10,
378 | // Font to use for all text.
379 | 'font' => 'arial',
380 | // Font size of regular text.
381 | 'fontSize' => 12,
382 | // Y coordinate to initiate a pagebreak.
383 | 'pageMaxY' => 260,
384 | // X coordinate of the page number text.
385 | 'pageNoX' => 15,
386 | // Y coordinate of the page number text.
387 | 'pageNoY' => 277,
388 | // Left and right padding of the page.
389 | 'pagePaddingLeft' => 15,
390 | // Top and bottom padding of the page.
391 | 'pagePaddingTop' => 12.5,
392 | // Height of a title text line.
393 | 'titleCellHeight' => 6,
394 | // Font size of title text.
395 | 'titleFontSize' => 15,
396 | // Y coordinate to start the invoice title on the first page.
397 | 'titleMarginTop' => 85,
398 | ], $override);
399 | }
400 |
401 | /**
402 | * Get the page variables.
403 | *
404 | * @param array $variables User-defined variables.
405 | *
406 | * @return array
407 | */
408 | protected function getVariables($variables = [])
409 | {
410 | return array_merge($variables, [
411 | 'total' => $this->numberFormat($this->getTotal()),
412 | 'page' => $this->pageNo(),
413 | ]);
414 | }
415 |
416 | /**
417 | * Format a number string.
418 | *
419 | * @param int|float $number
420 | *
421 | * @return string
422 | */
423 | protected function numberFormat($number)
424 | {
425 | return number_format($number, 2, $this->t('decimalSeparator'), $this->t('thousandsSeparator'));
426 | }
427 | }
428 |
--------------------------------------------------------------------------------
/src/lang.php:
--------------------------------------------------------------------------------
1 | [
5 | 'decimalSeparator' => '.',
6 | 'item' => 'Item',
7 | 'page' => 'Page {page} of {pages}',
8 | 'price' => 'Price',
9 | 'quantity' => 'Quantity',
10 | 'thousandsSeparator' => ',',
11 | 'total' => 'Total',
12 | ],
13 | 'de' => [
14 | 'decimalSeparator' => ',',
15 | 'item' => 'Artikel',
16 | 'page' => 'Seite {page} von {pages}',
17 | 'price' => 'Preis',
18 | 'quantity' => 'Menge',
19 | 'thousandsSeparator' => '.',
20 | 'total' => 'Summe',
21 | ],
22 | ];
23 |
--------------------------------------------------------------------------------