receipts 2.4.0 → 3.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.
- checksums.yaml +4 -4
- data/.standard.yml +1 -0
- data/CHANGELOG.md +13 -1
- data/Gemfile +0 -1
- data/README.md +29 -10
- data/examples/invoice.pdf +0 -0
- data/examples/receipt.pdf +0 -0
- data/examples/statement.pdf +0 -0
- data/lib/receipts/base.rb +3 -7
- data/lib/receipts/fonts/Inter-Bold.ttf +0 -0
- data/lib/receipts/fonts/Inter-Regular.ttf +0 -0
- data/lib/receipts/fonts/OFL.txt +92 -0
- data/lib/receipts/pdf/document.rb +383 -0
- data/lib/receipts/pdf/font.rb +168 -0
- data/lib/receipts/pdf/geometry.rb +29 -0
- data/lib/receipts/pdf/image.rb +242 -0
- data/lib/receipts/pdf/inline_format.rb +79 -0
- data/lib/receipts/pdf/table.rb +250 -0
- data/lib/receipts/pdf/text_layout.rb +175 -0
- data/lib/receipts/pdf/true_type.rb +387 -0
- data/lib/receipts/pdf/writer.rb +119 -0
- data/lib/receipts/version.rb +1 -1
- data/lib/receipts.rb +9 -2
- metadata +18 -43
- data/receipts.gemspec +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a47085bc4aed90ea312b2824ded3d8160c62479d59041b482ff8bb0cdf7776b3
|
|
4
|
+
data.tar.gz: 7d356f573664804db0684f104f3a8df60549413fc0d16918f9062ede95d604ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 329d7d5947440183086281c55a54e340550783fbbc8f9c21fff7dee0c02b9c6e0a0ba3bd7512efb2a15bf3ce091f0c10b98fe49909e8a09e7208f9a5adc3fd45
|
|
7
|
+
data.tar.gz: b390f51b8bc9e506b90cc4ab8486f278edde18945f726498faf27c4ab2b022c0e3f31adb4d0793a86992440e1db394ba9d3aaa04889c51b18c1669055587d727
|
data/.standard.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby_version: 2.7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
### Unreleased
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
### 3.0.0
|
|
4
|
+
|
|
5
|
+
* Replace Prawn with a built-in, pure Ruby PDF generator. Receipts no longer has any gem dependencies.
|
|
6
|
+
* Embed the Inter font by default, adding Unicode support (Latin, Greek, Cyrillic) without configuring a custom font
|
|
7
|
+
* Custom fonts must be TrueType (`.ttf`) files; only used characters are embedded
|
|
8
|
+
* **Breaking:** Receipts objects inherit from `Receipts::PDF::Document` instead of `Prawn::Document`. Custom content using Prawn APIs beyond `text`, `image`, `table`, `move_down`/`move_up`, `font`, `font_size`, `bounds` and `start_new_page` needs updating.
|
|
9
|
+
* **Breaking:** Table cells no longer accept Prawn's `overflow:` option. Rows always grow to fit their content, so remove `overflow:` from any custom `cell_style`.
|
|
10
|
+
|
|
11
|
+
### 2.4.0
|
|
12
|
+
|
|
13
|
+
* Add `display: []` for configuring company details that are rendered #38 - @excid3
|
|
4
14
|
|
|
5
15
|
```ruby
|
|
6
16
|
r = Receipts::Receipt.new(
|
|
@@ -17,6 +27,8 @@ r = Receipts::Receipt.new(
|
|
|
17
27
|
)
|
|
18
28
|
```
|
|
19
29
|
|
|
30
|
+
* Fix Prawn docs link in README #36 - @ocarreterom
|
|
31
|
+
|
|
20
32
|
### 2.3.0
|
|
21
33
|
|
|
22
34
|
* Add `column_widths:` option to specify line item column widths #35
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Receipts Gem
|
|
4
4
|
|
|
5
|
-
Receipts, Invoices, and Statements for your Rails application that works with any payment provider.
|
|
5
|
+
Receipts, Invoices, and Statements for your Rails application that works with any payment provider. PDFs are generated in pure Ruby with no dependencies.
|
|
6
6
|
|
|
7
7
|
Check out the [example PDFs](https://github.com/excid3/receipts/blob/master/examples/).
|
|
8
8
|
|
|
@@ -72,7 +72,9 @@ r.render_file "examples/receipt.pdf"
|
|
|
72
72
|
|
|
73
73
|
### Configuration
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
PDFs use the bundled [Inter](https://rsms.me/inter/) font by default, which supports Latin, Greek, and Cyrillic characters.
|
|
76
|
+
|
|
77
|
+
You can specify a different default font for all PDFs by defining the following in an initializer:
|
|
76
78
|
|
|
77
79
|
```ruby
|
|
78
80
|
Receipts.default_font = {
|
|
@@ -81,6 +83,8 @@ Receipts.default_font = {
|
|
|
81
83
|
}
|
|
82
84
|
```
|
|
83
85
|
|
|
86
|
+
Fonts must be TrueType (`.ttf`) files. Only the characters used are embedded in each PDF. You may also provide `italic:` and `bold_italic:` fonts; otherwise italic text is simulated by slanting the normal font.
|
|
87
|
+
|
|
84
88
|
### Options
|
|
85
89
|
|
|
86
90
|
You can pass the following options to generate a PDF:
|
|
@@ -132,8 +136,7 @@ Here's an example of where each option is displayed.
|
|
|
132
136
|
|
|
133
137
|
#### Line Items Table - Column Widths
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
The configuration depends on your line item column count and follows the prawn/table configuration as documented [here](https://prawnpdf.org/prawn-table-manual.pdf):
|
|
139
|
+
By default, columns are sized to fit their content and any leftover space is split evenly between columns. You may set the width of specific columns in points (1/72 inch) to override this.
|
|
137
140
|
|
|
138
141
|
This will size the second column to 400 and the fourth column to 50.
|
|
139
142
|
|
|
@@ -147,13 +150,18 @@ This will set all column widths, considering your table has 4 columns.
|
|
|
147
150
|
column_widths: [100, 200, 240]
|
|
148
151
|
```
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
Columns without a width are sized automatically.
|
|
151
154
|
|
|
152
155
|
### Formatting
|
|
153
156
|
|
|
154
|
-
`details` and `
|
|
157
|
+
`details`, `line_items`, `recipient` and `footer` allow inline formatting using HTML-like tags:
|
|
158
|
+
|
|
159
|
+
* `<b>` / `<strong>`, `<i>` / `<em>`, `<u>`, `<strikethrough>`, `<sub>`, `<sup>`, `<br>`
|
|
160
|
+
* `<font name="Primary" size="12" character_spacing="1">`
|
|
161
|
+
* `<color rgb="#5eba7d">` or `<color c="0" m="100" y="100" k="0">`
|
|
162
|
+
* `<link href="https://example.com">`
|
|
155
163
|
|
|
156
|
-
|
|
164
|
+
Use `<`, `>` and `&` for literal `<`, `>` and `&` characters.
|
|
157
165
|
|
|
158
166
|
#### Page Size
|
|
159
167
|
|
|
@@ -183,17 +191,28 @@ You can change the entire PDF content by instantiating an Receipts object withou
|
|
|
183
191
|
receipt = Receipts::Receipt.new # creates an empty PDF
|
|
184
192
|
```
|
|
185
193
|
|
|
186
|
-
Each Receipts object inherits from
|
|
194
|
+
Each Receipts object inherits from `Receipts::PDF::Document`, which provides a small, Prawn-like API for adding your own content:
|
|
187
195
|
|
|
188
196
|
```ruby
|
|
189
|
-
receipt.text("hello world")
|
|
197
|
+
receipt.text("hello world", size: 12, style: :bold, align: :center, color: "4b5563")
|
|
198
|
+
receipt.text("<b>Bold</b> and <i>italic</i>", inline_format: true)
|
|
199
|
+
receipt.move_down 20
|
|
200
|
+
receipt.image "logo.png", height: 24, position: :right
|
|
201
|
+
receipt.table([["Item", "Amount"], ["Product", "$10"]], width: receipt.bounds.width) do
|
|
202
|
+
row(0).font_style = :bold
|
|
203
|
+
column(1).align = :right
|
|
204
|
+
end
|
|
205
|
+
receipt.stroke_horizontal_rule
|
|
206
|
+
receipt.start_new_page
|
|
190
207
|
```
|
|
191
208
|
|
|
209
|
+
Images must be PNG (non-interlaced) or JPEG files.
|
|
210
|
+
|
|
192
211
|
You can also use the Receipts helpers in your custom PDFs at the current cursor position.
|
|
193
212
|
|
|
194
213
|
```ruby
|
|
195
214
|
receipt.text("Custom header")
|
|
196
|
-
receipt.render_line_items([
|
|
215
|
+
receipt.render_line_items(line_items: [
|
|
197
216
|
["my line items"]
|
|
198
217
|
])
|
|
199
218
|
receipt.render_footer("This is a custom footer using the Receipts helper")
|
data/examples/invoice.pdf
CHANGED
|
Binary file
|
data/examples/receipt.pdf
CHANGED
|
Binary file
|
data/examples/statement.pdf
CHANGED
|
Binary file
|
data/lib/receipts/base.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Receipts
|
|
2
|
-
class Base <
|
|
2
|
+
class Base < PDF::Document
|
|
3
3
|
attr_accessor :title, :company
|
|
4
4
|
|
|
5
5
|
class << self
|
|
@@ -39,11 +39,7 @@ module Receipts
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def load_image(logo)
|
|
42
|
-
|
|
43
|
-
logo.start_with?("http") ? URI.parse(logo).open : File.open(logo)
|
|
44
|
-
else
|
|
45
|
-
logo
|
|
46
|
-
end
|
|
42
|
+
(logo.is_a?(String) && logo.start_with?("http")) ? URI.parse(logo).open : logo
|
|
47
43
|
end
|
|
48
44
|
|
|
49
45
|
def header(company: {}, height: 16)
|
|
@@ -76,7 +72,7 @@ module Receipts
|
|
|
76
72
|
{content: Array(recipient).join("\n"), padding: [0, 12, 0, 0]}
|
|
77
73
|
]
|
|
78
74
|
]
|
|
79
|
-
table(line_items, width: bounds.width, cell_style: {borders: [], inline_format: true
|
|
75
|
+
table(line_items, width: bounds.width, cell_style: {borders: [], inline_format: true})
|
|
80
76
|
end
|
|
81
77
|
|
|
82
78
|
def render_line_items(line_items:, margin_top: 30, column_widths: nil)
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
-----------------------------------------------------------
|
|
8
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
9
|
+
-----------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
PREAMBLE
|
|
12
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
13
|
+
development of collaborative font projects, to support the font creation
|
|
14
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
15
|
+
open framework in which fonts may be shared and improved in partnership
|
|
16
|
+
with others.
|
|
17
|
+
|
|
18
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
19
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
20
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
21
|
+
redistributed and/or sold with any software provided that any reserved
|
|
22
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
23
|
+
however, cannot be released under any other type of license. The
|
|
24
|
+
requirement for fonts to remain under this license does not apply
|
|
25
|
+
to any document created using the fonts or their derivatives.
|
|
26
|
+
|
|
27
|
+
DEFINITIONS
|
|
28
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
29
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
30
|
+
include source files, build scripts and documentation.
|
|
31
|
+
|
|
32
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
33
|
+
copyright statement(s).
|
|
34
|
+
|
|
35
|
+
"Original Version" refers to the collection of Font Software components as
|
|
36
|
+
distributed by the Copyright Holder(s).
|
|
37
|
+
|
|
38
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
39
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
40
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
41
|
+
new environment.
|
|
42
|
+
|
|
43
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
44
|
+
writer or other person who contributed to the Font Software.
|
|
45
|
+
|
|
46
|
+
PERMISSION AND CONDITIONS
|
|
47
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
48
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
49
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
50
|
+
Software, subject to the following conditions:
|
|
51
|
+
|
|
52
|
+
1) Neither the Font Software nor any of its individual components,
|
|
53
|
+
in Original or Modified Versions, may be sold by itself.
|
|
54
|
+
|
|
55
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
56
|
+
redistributed and/or sold with any software, provided that each copy
|
|
57
|
+
contains the above copyright notice and this license. These can be
|
|
58
|
+
included either as stand-alone text files, human-readable headers or
|
|
59
|
+
in the appropriate machine-readable metadata fields within text or
|
|
60
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
61
|
+
|
|
62
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
63
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
64
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
65
|
+
presented to the users.
|
|
66
|
+
|
|
67
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
68
|
+
Software shall not be used to promote, endorse or advertise any
|
|
69
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
70
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
71
|
+
permission.
|
|
72
|
+
|
|
73
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
74
|
+
must be distributed entirely under this license, and must not be
|
|
75
|
+
distributed under any other license. The requirement for fonts to
|
|
76
|
+
remain under this license does not apply to any document created
|
|
77
|
+
using the Font Software.
|
|
78
|
+
|
|
79
|
+
TERMINATION
|
|
80
|
+
This license becomes null and void if any of the above conditions are
|
|
81
|
+
not met.
|
|
82
|
+
|
|
83
|
+
DISCLAIMER
|
|
84
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
85
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
86
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
87
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
88
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
89
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
90
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
91
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
92
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
module Receipts
|
|
2
|
+
module PDF
|
|
3
|
+
# A minimal PDF document with a Prawn-like API for flowing text, images and tables.
|
|
4
|
+
#
|
|
5
|
+
# Coordinates follow PDF conventions: the origin is the bottom left of the page
|
|
6
|
+
# and units are points (1/72 inch).
|
|
7
|
+
class Document
|
|
8
|
+
PAGE_SIZES = {
|
|
9
|
+
"A3" => [841.89, 1190.55],
|
|
10
|
+
"A4" => [595.28, 841.89],
|
|
11
|
+
"A5" => [419.53, 595.28],
|
|
12
|
+
"LEGAL" => [612.0, 1008.0],
|
|
13
|
+
"LETTER" => [612.0, 792.0],
|
|
14
|
+
"TABLOID" => [792.0, 1224.0]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
FONTS_PATH = File.expand_path("../fonts", __dir__)
|
|
18
|
+
DEFAULT_FONT_FAMILY = "Inter"
|
|
19
|
+
DEFAULT_FONT = {
|
|
20
|
+
normal: File.join(FONTS_PATH, "Inter-Regular.ttf"),
|
|
21
|
+
bold: File.join(FONTS_PATH, "Inter-Bold.ttf")
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# The area inside the page margins. Like Prawn, `left`, `right`, `top` and
|
|
25
|
+
# `bottom` are relative to the box, while the absolute_* values are page coordinates.
|
|
26
|
+
Bounds = Struct.new(:absolute_left, :absolute_bottom, :width, :height) do
|
|
27
|
+
def left
|
|
28
|
+
0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bottom
|
|
32
|
+
0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def right
|
|
36
|
+
width
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def top
|
|
40
|
+
height
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def absolute_right
|
|
44
|
+
absolute_left + width
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def absolute_top
|
|
48
|
+
absolute_bottom + height
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Page = Struct.new(:content, :annotations)
|
|
53
|
+
|
|
54
|
+
attr_reader :bounds, :font_families, :page_width, :page_height
|
|
55
|
+
attr_accessor :y, :fill_color, :stroke_color, :line_width
|
|
56
|
+
|
|
57
|
+
def initialize(page_size: "LETTER", page_layout: :portrait, margin: 36, info: {})
|
|
58
|
+
width, height = page_size.is_a?(Array) ? page_size : PAGE_SIZES.fetch(page_size.to_s.upcase) {
|
|
59
|
+
raise ArgumentError, "unknown page size #{page_size.inspect}, use one of #{PAGE_SIZES.keys.join(", ")} or [width, height]"
|
|
60
|
+
}
|
|
61
|
+
width, height = height, width if page_layout == :landscape
|
|
62
|
+
@page_width = width
|
|
63
|
+
@page_height = height
|
|
64
|
+
|
|
65
|
+
top, right, bottom, left = Geometry.expand_box(margin)
|
|
66
|
+
@bounds = Bounds.new(left, bottom, width - left - right, height - top - bottom)
|
|
67
|
+
|
|
68
|
+
@info = info
|
|
69
|
+
@font_families = {DEFAULT_FONT_FAMILY => DEFAULT_FONT.dup}
|
|
70
|
+
@font_family = DEFAULT_FONT_FAMILY
|
|
71
|
+
@font_size = 12
|
|
72
|
+
@fill_color = "000000"
|
|
73
|
+
@stroke_color = "000000"
|
|
74
|
+
@line_width = 1
|
|
75
|
+
@fonts = {}
|
|
76
|
+
@font_resources = {}.compare_by_identity
|
|
77
|
+
@images = {}.compare_by_identity
|
|
78
|
+
@pages = []
|
|
79
|
+
|
|
80
|
+
start_new_page
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def start_new_page
|
|
84
|
+
@pages << Page.new(String.new(encoding: Encoding::BINARY), [])
|
|
85
|
+
@y = @bounds.absolute_top
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Starts a new page if there isn't room for height, unless already at the top of one.
|
|
89
|
+
# Returns true when a new page was started.
|
|
90
|
+
def start_new_page_if_needed(height)
|
|
91
|
+
return false unless @y - height < @bounds.absolute_bottom && @y < @bounds.absolute_top
|
|
92
|
+
start_new_page
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def page_count
|
|
97
|
+
@pages.size
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Distance from the current position to the bottom margin
|
|
101
|
+
def cursor
|
|
102
|
+
@y - @bounds.absolute_bottom
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def move_down(amount)
|
|
106
|
+
@y -= amount
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def move_up(amount)
|
|
110
|
+
@y += amount
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def move_cursor_to(position)
|
|
114
|
+
@y = @bounds.absolute_bottom + position
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Sets the font family (or a path to a .ttf file), optionally just for the given block
|
|
118
|
+
def font(name = nil, size: nil)
|
|
119
|
+
return @font_family if name.nil? && size.nil?
|
|
120
|
+
|
|
121
|
+
previous = [@font_family, @font_size]
|
|
122
|
+
@font_family = name.to_s if name
|
|
123
|
+
@font_size = size if size
|
|
124
|
+
return unless block_given?
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
yield
|
|
128
|
+
ensure
|
|
129
|
+
@font_family, @font_size = previous
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def font_size(size = nil, &block)
|
|
134
|
+
return @font_size if size.nil?
|
|
135
|
+
return @font_size = size unless block
|
|
136
|
+
font(nil, size: size, &block)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def width_of(string, options = {})
|
|
140
|
+
TextLayout.natural_width(text_chunks(string, options), text_style(options))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def height_of(string, options = {})
|
|
144
|
+
TextLayout.height_of(text_lines(string, options, @bounds.width), options.fetch(:leading, 0))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Writes flowing text at the cursor, wrapping lines and starting new pages as needed.
|
|
148
|
+
#
|
|
149
|
+
# Options: :size, :style (:bold, :italic, :bold_italic), :align (:left, :center, :right),
|
|
150
|
+
# :color (hex RGB), :font, :character_spacing, :leading, :inline_format
|
|
151
|
+
def text(string, options = {})
|
|
152
|
+
lines = text_lines(string, options, @bounds.width)
|
|
153
|
+
leading = options.fetch(:leading, 0)
|
|
154
|
+
|
|
155
|
+
lines.each_with_index do |line, index|
|
|
156
|
+
start_new_page_if_needed(line.height)
|
|
157
|
+
draw_text_line(line, @bounds.absolute_left, @y - line.ascender, @bounds.width, options.fetch(:align, :left))
|
|
158
|
+
@y -= line.height
|
|
159
|
+
@y -= line.line_gap + leading if index < lines.size - 1
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Draws an image at the cursor. Given only a width or height, the other is
|
|
165
|
+
# scaled proportionally. Given neither, images wider than the bounds are scaled down to fit.
|
|
166
|
+
#
|
|
167
|
+
# Position is :left, :center, :right or an x offset from the left bound.
|
|
168
|
+
def image(source, width: nil, height: nil, position: :left)
|
|
169
|
+
image = Image.load(source)
|
|
170
|
+
width ||= height ? image.width * height.to_f / image.height : [image.width, @bounds.width].min.to_f
|
|
171
|
+
height ||= image.height * width.to_f / image.width
|
|
172
|
+
x = @bounds.absolute_left + Geometry.align_offset(position, @bounds.width, width)
|
|
173
|
+
|
|
174
|
+
start_new_page_if_needed(height)
|
|
175
|
+
|
|
176
|
+
name = @images[image] ||= :"I#{@images.size + 1}"
|
|
177
|
+
add_content "q #{n(width)} 0 0 #{n(height)} #{n(x)} #{n(@y - height)} cm /#{name} Do Q"
|
|
178
|
+
@y -= height
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Draws a table at the cursor. See Receipts::PDF::Table for options.
|
|
183
|
+
def table(data, options = {}, &block)
|
|
184
|
+
Table.new(self, data, options, &block).draw
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def stroke_horizontal_rule
|
|
188
|
+
stroke_line(@bounds.absolute_left, @y, @bounds.absolute_right, @y)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def stroke_line(x1, y1, x2, y2, color: @stroke_color, width: @line_width)
|
|
192
|
+
add_content "q #{color_operator(color, stroke: true)} #{n(width)} w #{n(x1)} #{n(y1)} m #{n(x2)} #{n(y2)} l S Q"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def fill_rectangle(x, y, width, height, color: @fill_color)
|
|
196
|
+
add_content "q #{color_operator(color)} #{n(x)} #{n(y)} #{n(width)} #{n(height)} re f Q"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def render
|
|
200
|
+
writer = Writer.new
|
|
201
|
+
|
|
202
|
+
fonts = @font_resources.map { |font, name| [name, font.build(writer)] }.to_h
|
|
203
|
+
images = @images.map { |image, name| [name, image.build(writer)] }.to_h
|
|
204
|
+
resources = {}
|
|
205
|
+
resources[:Font] = fonts if fonts.any?
|
|
206
|
+
resources[:XObject] = images if images.any?
|
|
207
|
+
resources = writer.add(resources)
|
|
208
|
+
|
|
209
|
+
pages = writer.reserve
|
|
210
|
+
kids = @pages.map do |page|
|
|
211
|
+
dictionary = {
|
|
212
|
+
Type: :Page,
|
|
213
|
+
Parent: pages,
|
|
214
|
+
MediaBox: [0, 0, @page_width, @page_height],
|
|
215
|
+
Resources: resources,
|
|
216
|
+
Contents: writer.add(Stream.new(page.content))
|
|
217
|
+
}
|
|
218
|
+
dictionary[:Annots] = page.annotations.map { |annotation| writer.add(annotation) } if page.annotations.any?
|
|
219
|
+
writer.add(dictionary)
|
|
220
|
+
end
|
|
221
|
+
writer.set(pages, Type: :Pages, Kids: kids, Count: kids.size)
|
|
222
|
+
|
|
223
|
+
info = {Producer: "Receipts"}.merge(@info).transform_values { |value| Serializer.text_string(value) }
|
|
224
|
+
writer.render(root: writer.add(Type: :Catalog, Pages: pages), info: writer.add(info))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def render_file(path)
|
|
228
|
+
File.binwrite(path, render)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# [natural width, widest word] of text, used to size table columns
|
|
232
|
+
def measure_text(string, options = {})
|
|
233
|
+
chunks = text_chunks(string, options)
|
|
234
|
+
style = text_style(options)
|
|
235
|
+
[TextLayout.natural_width(chunks, style), TextLayout.minimum_width(chunks, style)]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Lays out text into lines for the given width without drawing it
|
|
239
|
+
def text_lines(string, options, width)
|
|
240
|
+
TextLayout.wrap(text_chunks(string, options), width, text_style(options))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Draws lines of text top-down from top, returning the height used
|
|
244
|
+
def draw_text_lines(lines, x, top, width, align: :left, leading: 0)
|
|
245
|
+
y = top
|
|
246
|
+
lines.each_with_index do |line, index|
|
|
247
|
+
draw_text_line(line, x, y - line.ascender, width, align)
|
|
248
|
+
y -= line.height
|
|
249
|
+
y -= line.line_gap + leading if index < lines.size - 1
|
|
250
|
+
end
|
|
251
|
+
top - y
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
def add_content(operators)
|
|
257
|
+
@pages.last.content << operators << "\n"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def n(value)
|
|
261
|
+
Serializer.number(value)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def text_style(options, fragment = {})
|
|
265
|
+
styles = style_list(options[:style]) | Array(fragment[:styles])
|
|
266
|
+
size = fragment[:size] || options[:size] || @font_size
|
|
267
|
+
rise = 0
|
|
268
|
+
|
|
269
|
+
if styles.include?(:superscript)
|
|
270
|
+
rise = size * 0.33
|
|
271
|
+
size *= 0.583
|
|
272
|
+
elsif styles.include?(:subscript)
|
|
273
|
+
rise = -size * 0.2
|
|
274
|
+
size *= 0.583
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
font, fake_bold, oblique = resolve_font(fragment[:font] || options[:font] || @font_family, styles)
|
|
278
|
+
|
|
279
|
+
TextLayout::Style.new(
|
|
280
|
+
font: font,
|
|
281
|
+
size: size,
|
|
282
|
+
color: fragment[:color] || options[:color] || @fill_color,
|
|
283
|
+
link: fragment[:link],
|
|
284
|
+
underline: styles.include?(:underline),
|
|
285
|
+
strikethrough: styles.include?(:strikethrough),
|
|
286
|
+
rise: rise,
|
|
287
|
+
character_spacing: fragment[:character_spacing] || options[:character_spacing] || 0,
|
|
288
|
+
oblique: oblique,
|
|
289
|
+
fake_bold: fake_bold
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def text_chunks(string, options)
|
|
294
|
+
string = string.to_s
|
|
295
|
+
string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
|
|
296
|
+
string = string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub.delete("\r")
|
|
297
|
+
|
|
298
|
+
fragments = options[:inline_format] ? InlineFormat.parse(string) : [{text: string}]
|
|
299
|
+
fragments.map { |fragment| [fragment[:text], text_style(options, fragment)] }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def style_list(style)
|
|
303
|
+
case style
|
|
304
|
+
when nil, :normal then []
|
|
305
|
+
when :bold_italic then [:bold, :italic]
|
|
306
|
+
when Array then style
|
|
307
|
+
else [style.to_sym]
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Picks the font for a style, faking bold or italic when the family doesn't include it.
|
|
312
|
+
# Returns [font, fake_bold, oblique]
|
|
313
|
+
def resolve_font(name, styles)
|
|
314
|
+
name = name.to_s
|
|
315
|
+
family = @font_families[name] || (name.end_with?(".ttf") && File.exist?(name) && {normal: name})
|
|
316
|
+
raise ArgumentError, "unknown font #{name.inspect}, register it with font_families.update(#{name.inspect} => {normal: \"path/to/font.ttf\"})" unless family
|
|
317
|
+
|
|
318
|
+
family = family.transform_keys(&:to_sym)
|
|
319
|
+
bold = styles.include?(:bold)
|
|
320
|
+
italic = styles.include?(:italic)
|
|
321
|
+
key = [(:bold_italic if bold && italic), (:bold if bold), (:italic if italic), :normal].compact.find { |style| family[style] }
|
|
322
|
+
raise ArgumentError, "font family #{name.inspect} needs a :normal font" unless key
|
|
323
|
+
|
|
324
|
+
path = File.expand_path(family[key].to_s)
|
|
325
|
+
font = @fonts[path] ||= Font.new(path).tap { |f| @font_resources[f] = :"F#{@font_resources.size + 1}" }
|
|
326
|
+
[font, bold && !key.to_s.include?("bold"), italic && !key.to_s.include?("italic")]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def draw_text_line(line, x, baseline, width, align)
|
|
330
|
+
x += Geometry.align_offset(align, width, line.width)
|
|
331
|
+
|
|
332
|
+
line.runs.each do |run|
|
|
333
|
+
draw_run(run, x, baseline)
|
|
334
|
+
x += run.width
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def draw_run(run, x, baseline)
|
|
339
|
+
style = run.style
|
|
340
|
+
y = baseline + style.rise
|
|
341
|
+
|
|
342
|
+
ops = ["q BT", "/#{@font_resources.fetch(style.font)} #{n(style.size)} Tf", color_operator(style.color)]
|
|
343
|
+
ops << "#{n(style.character_spacing)} Tc" unless style.character_spacing.zero?
|
|
344
|
+
ops << "2 Tr #{n(style.size * 0.03)} w #{color_operator(style.color, stroke: true)}" if style.fake_bold
|
|
345
|
+
ops << "1 0 #{style.oblique ? n(Font::OBLIQUE_SKEW) : 0} 1 #{n(x)} #{n(y)} Tm"
|
|
346
|
+
ops << "<#{style.font.encode(run.text).unpack1("H*")}> Tj ET Q"
|
|
347
|
+
add_content ops.join(" ")
|
|
348
|
+
|
|
349
|
+
font = style.font
|
|
350
|
+
stroke_decoration(run, x, y, font.underline_position(style.size), font.underline_thickness(style.size)) if style.underline
|
|
351
|
+
stroke_decoration(run, x, y, font.strikeout_position(style.size), font.strikeout_size(style.size)) if style.strikethrough
|
|
352
|
+
|
|
353
|
+
if style.link
|
|
354
|
+
@pages.last.annotations << {
|
|
355
|
+
Type: :Annot,
|
|
356
|
+
Subtype: :Link,
|
|
357
|
+
Rect: [x, y - style.font.descender(style.size), x + run.width, y + style.font.ascender(style.size)],
|
|
358
|
+
Border: [0, 0, 0],
|
|
359
|
+
A: {Type: :Action, S: :URI, URI: style.link.to_s}
|
|
360
|
+
}
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Draws an underline or strikethrough line across a run at an offset from its baseline
|
|
365
|
+
def stroke_decoration(run, x, baseline, offset, thickness)
|
|
366
|
+
y = baseline + offset
|
|
367
|
+
stroke_line(x, y, x + run.width, y, color: run.style.color, width: thickness)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Hex RGB ("ff0000" or "#ff0000") or CMYK percentages ([0, 100, 100, 0])
|
|
371
|
+
def color_operator(color, stroke: false)
|
|
372
|
+
if color.is_a?(Array)
|
|
373
|
+
"#{color.map { |c| n(c / 100.0) }.join(" ")} #{stroke ? "K" : "k"}"
|
|
374
|
+
else
|
|
375
|
+
hex = color.to_s.delete("#")
|
|
376
|
+
hex = hex.chars.map { |c| c * 2 }.join if hex.size == 3
|
|
377
|
+
raise ArgumentError, "invalid color #{color.inspect}" unless hex.match?(/\A\h{6}\z/)
|
|
378
|
+
"#{[hex].pack("H*").bytes.map { |c| n(c / 255.0) }.join(" ")} #{stroke ? "RG" : "rg"}"
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|