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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a074319e14c0aaf9a5e4776d854c90810273092e58d7672046f7a6ef4d01a6d5
4
- data.tar.gz: e4d3bf9feb0a135357a6666ca46a8c9298d0d623223101c0649c7c9b2b45248f
3
+ metadata.gz: a47085bc4aed90ea312b2824ded3d8160c62479d59041b482ff8bb0cdf7776b3
4
+ data.tar.gz: 7d356f573664804db0684f104f3a8df60549413fc0d16918f9062ede95d604ff
5
5
  SHA512:
6
- metadata.gz: e22ececba2faf58b41ed4b677bbf351b6dadb51eb0c2e54b9930949eceb7bbc68d29aba7a642e44c47d76d9486669d324cd642befb920576398a13d392e8e303
7
- data.tar.gz: 179cbdbf4fbaa3de6de2d1e76a0c9087bc91b9fa36c639f3d5824f112a32a1199662fd0ae87efa3d18f69be6744d3762e495e85cb111e67106a7bde81b90c7e3
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
- * Add `display: []` for configuring company details that are rendered
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
@@ -6,4 +6,3 @@ gemspec
6
6
  gem "minitest", "~> 5.0"
7
7
  gem "rake", "~> 13.0"
8
8
  gem "standard"
9
- gem "matrix", "~> 0.4"
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. Receipts uses Prawn to generate the PDFs.
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
- You can specify the default font for all PDFs by defining the following in an initializer:
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
- You may set an option to configure the line items table's columns width in order to accommodate shortcomings of Prawn's width guessing ability to render header and content reasonably sized.
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
- If not set, it will fall back to Prawn's default behavior.
153
+ Columns without a width are sized automatically.
151
154
 
152
155
  ### Formatting
153
156
 
154
- `details` and `line_items` allow inline formatting with Prawn. This allows you to use HTML tags to format text: `<b>` `<i>` `<u>` `<strikethrough>` `<sub>` `<sup>` `<font>` `<color>` `<link>`
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
- See [the Prawn docs](https://prawnpdf.org/) for more information.
164
+ Use `&lt;`, `&gt;` and `&amp;` 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 Prawn::Document. This allows you to choose what is rendered and include any custom Prawn content you like.
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
Binary file
data/lib/receipts/base.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Receipts
2
- class Base < Prawn::Document
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
- if logo.is_a? String
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, overflow: :expand})
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
@@ -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