prawn-html 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/LICENSE.txt +20 -0
- data/README.md +84 -0
- data/lib/prawn-html.rb +17 -0
- data/lib/prawn_html/attributes.rb +157 -0
- data/lib/prawn_html/callbacks/highlight.rb +21 -0
- data/lib/prawn_html/callbacks/strike_through.rb +18 -0
- data/lib/prawn_html/context.rb +61 -0
- data/lib/prawn_html/document_renderer.rb +134 -0
- data/lib/prawn_html/html_handler.rb +68 -0
- data/lib/prawn_html/tags/a.rb +17 -0
- data/lib/prawn_html/tags/b.rb +17 -0
- data/lib/prawn_html/tags/base.rb +54 -0
- data/lib/prawn_html/tags/body.rb +11 -0
- data/lib/prawn_html/tags/br.rb +24 -0
- data/lib/prawn_html/tags/del.rb +17 -0
- data/lib/prawn_html/tags/div.rb +15 -0
- data/lib/prawn_html/tags/h.rb +51 -0
- data/lib/prawn_html/tags/hr.rb +29 -0
- data/lib/prawn_html/tags/i.rb +17 -0
- data/lib/prawn_html/tags/img.rb +32 -0
- data/lib/prawn_html/tags/li.rb +21 -0
- data/lib/prawn_html/tags/mark.rb +17 -0
- data/lib/prawn_html/tags/p.rb +25 -0
- data/lib/prawn_html/tags/small.rb +16 -0
- data/lib/prawn_html/tags/span.rb +11 -0
- data/lib/prawn_html/tags/u.rb +15 -0
- data/lib/prawn_html/tags/ul.rb +23 -0
- data/lib/prawn_html/version.rb +5 -0
- metadata +98 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a5a671ae12f6a66358c10f739404ab709f3fdcaacb059b9943433a7535a539a5
|
4
|
+
data.tar.gz: 274afa7a2d6cbb2a75e1ca03e2b82ee38985bf279f41ecd3878166ce9eabf15a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a67e743e2c77c1b0e9caecefd9b6f1fc2d33243d0b86b37548f5bbed7e8e569ea11562d849c82fbdfd8ca7eeb5bdf2921cf789464868f0e90de4b631dcf89a10
|
7
|
+
data.tar.gz: dd06b9b0410cceb28eaf68ab6eaeb6007086047478f3ec1ba2ffd37a0a38de38264f56fae3232b4025a5d6f45b776fe2d79bb23eb76926a8a87139e82294e14d
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2021 Mattia Roccoberton
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Prawn HTML
|
2
|
+
[](https://github.com/blocknotes/prawn-html/actions/workflows/linters.yml)
|
3
|
+
[](https://github.com/blocknotes/prawn-html/actions/workflows/specs.yml)
|
4
|
+
|
5
|
+
HTML to PDF renderer using [Prawn PDF](https://github.com/prawnpdf/prawn).
|
6
|
+
|
7
|
+
> Still in beta. [prawn-styled-text](https://github.com/blocknotes/prawn-styled-text) rewritten from scratch
|
8
|
+
|
9
|
+
**Notice**: render HTML documents properly is not an easy task, this gem support only some HTML tags and a small set of CSS attributes. If you need more rendering accuracy take a look at other projects like WickedPDF.
|
10
|
+
|
11
|
+
Please :star: if you like it.
|
12
|
+
|
13
|
+
## Install
|
14
|
+
|
15
|
+
- Add to your Gemfile: `gem 'prawn-html', git: 'https://github.com/blocknotes/prawn-html.git'` (and execute `bundle`)
|
16
|
+
- Use the class `HtmlHandler` on a `Prawn::Document` instance
|
17
|
+
|
18
|
+
## Examples
|
19
|
+
|
20
|
+
```rb
|
21
|
+
require 'prawn-html'
|
22
|
+
pdf = Prawn::Document.new(page_size: 'A4')
|
23
|
+
PrawnHtml::HtmlHandler.new(pdf).process('<h1 style="text-align: center">Just a test</h1>')
|
24
|
+
pdf.render_file('test.pdf')
|
25
|
+
```
|
26
|
+
|
27
|
+
## Supported tags & attributes
|
28
|
+
|
29
|
+
HTML tags:
|
30
|
+
|
31
|
+
- **a**: link
|
32
|
+
- **b**: bold
|
33
|
+
- **br**: new line
|
34
|
+
- **del**: strike-through
|
35
|
+
- **div**: block element
|
36
|
+
- **em**: italic
|
37
|
+
- **h1** - **h6**: headings
|
38
|
+
- **hr**: horizontal line
|
39
|
+
- **i**: italic
|
40
|
+
- **ins**: underline
|
41
|
+
- **img**: image
|
42
|
+
- **li**: list item
|
43
|
+
- **mark**: highlight
|
44
|
+
- **p**: block element
|
45
|
+
- **s**: strike-through
|
46
|
+
- **small**: smaller text
|
47
|
+
- **span**: inline element
|
48
|
+
- **strong**: bold
|
49
|
+
- **u**: underline
|
50
|
+
- **ul**: list
|
51
|
+
|
52
|
+
CSS attributes (dimensional units are ignored and considered in pixel):
|
53
|
+
|
54
|
+
- **background**: for *mark* tag, only 3 or 6 hex digits format, ex. `style="background: #FECD08"`
|
55
|
+
- **color**: only 3 or 6 hex digits format - ex. `style="color: #FB1"`
|
56
|
+
- **font-family**: font must be registered, quotes are optional, ex. `style="font-family: Courier"`
|
57
|
+
- **font-size**: ex. `style="font-size: 20px"`
|
58
|
+
- **font-style**: values: *:italic*, ex. `style="font-style: italic"`
|
59
|
+
- **font-weight**: values: *:bold*, ex. `style="font-weight: bold"`
|
60
|
+
- **height**: for *img* tag, ex. `<img src="image.jpg" style="height: 200px"/>`
|
61
|
+
- **href**: for *a* tag, ex. `<a href="http://www.google.com/">Google</a>`
|
62
|
+
- **letter-spacing**: ex. `style="letter-spacing: 1.5"`
|
63
|
+
- **line-height**: ex. `style="line-height: 10px"`
|
64
|
+
- **margin-bottom**: ex. `style="margin-bottom: 10px"`
|
65
|
+
- **margin-left**: ex. `style="margin-left: 15px"`
|
66
|
+
- **margin-top**: ex. `style="margin-top: 20px"`
|
67
|
+
- **src**: for *img* tag, ex. `<img src="image.jpg"/>`
|
68
|
+
- **text-align**: `left` | `center` | `right` | `justify`, ex. `style="text-align: center"`
|
69
|
+
- **text-decoration**: `underline`, ex. `style="text-decoration: underline"`
|
70
|
+
- **width**: for *img* tag, support also percentage, ex. `<img src="image.jpg" style="width: 50%; height: 200px"/>`
|
71
|
+
|
72
|
+
## Do you like it? Star it!
|
73
|
+
|
74
|
+
If you use this component just star it. A developer is more motivated to improve a project when there is some interest.
|
75
|
+
|
76
|
+
Or consider offering me a coffee, it's a small thing but it is greatly appreciated: [about me](https://www.blocknot.es/about-me).
|
77
|
+
|
78
|
+
## Contributors
|
79
|
+
|
80
|
+
- [Mattia Roccoberton](https://www.blocknot.es): author
|
81
|
+
|
82
|
+
## License
|
83
|
+
|
84
|
+
The gem is available as open-source under the terms of the [MIT](LICENSE.txt).
|
data/lib/prawn-html.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'prawn'
|
4
|
+
|
5
|
+
require 'prawn_html/tags/base'
|
6
|
+
Dir["#{__dir__}/prawn_html/tags/*.rb"].sort.each { |f| require f }
|
7
|
+
|
8
|
+
Dir["#{__dir__}/prawn_html/callbacks/*.rb"].sort.each { |f| require f }
|
9
|
+
|
10
|
+
require 'prawn_html/attributes'
|
11
|
+
require 'prawn_html/context'
|
12
|
+
require 'prawn_html/document_renderer'
|
13
|
+
require 'prawn_html/html_handler'
|
14
|
+
|
15
|
+
module PrawnHtml
|
16
|
+
PX = 0.66 # conversion costant for pixel sixes
|
17
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ostruct'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
class Attributes
|
7
|
+
attr_reader :hash, :options, :post_styles, :pre_styles, :styles
|
8
|
+
|
9
|
+
STYLES_LIST = {
|
10
|
+
# styles
|
11
|
+
'background' => { key: :background, set: :convert_color, dest: :styles },
|
12
|
+
'color' => { key: :color, set: :convert_color, dest: :styles },
|
13
|
+
'font-family' => { key: :font, set: :unquote, dest: :styles },
|
14
|
+
'font-size' => { key: :size, set: :convert_size, dest: :styles },
|
15
|
+
'font-style' => { key: :styles, set: :append_symbol, dest: :styles },
|
16
|
+
'font-weight' => { key: :styles, set: :append_symbol, dest: :styles },
|
17
|
+
'letter-spacing' => { key: :character_spacing, set: :convert_float, dest: :styles },
|
18
|
+
# pre styles
|
19
|
+
'margin-top' => { key: :margin_top, set: :convert_size, dest: :pre_styles },
|
20
|
+
# post styles
|
21
|
+
'margin-bottom' => { key: :margin_bottom, set: :convert_size, dest: :post_styles },
|
22
|
+
'padding-bottom' => { key: :padding_bottom, set: :convert_size, dest: :post_styles },
|
23
|
+
# options
|
24
|
+
'line-height' => { key: :leading, set: :convert_size, dest: :options },
|
25
|
+
'margin-left' => { key: :margin_left, set: :convert_size, dest: :options },
|
26
|
+
'padding-left' => { key: :padding_left, set: :convert_size, dest: :options },
|
27
|
+
'padding-top' => { key: :padding_top, set: :convert_size, dest: :options },
|
28
|
+
'text-align' => { key: :align, set: :convert_symbol, dest: :options },
|
29
|
+
'text-decoration' => { key: :styles, set: :append_symbol, dest: :styles }
|
30
|
+
}.freeze
|
31
|
+
|
32
|
+
STYLES_MERGE = %i[margin_left padding_left].freeze
|
33
|
+
|
34
|
+
# Init the Attributes
|
35
|
+
#
|
36
|
+
# @param attributes [Hash] hash of attributes to parse
|
37
|
+
def initialize(attributes)
|
38
|
+
@hash = ::OpenStruct.new(attributes)
|
39
|
+
@options = {}
|
40
|
+
@post_styles = {}
|
41
|
+
@pre_styles = {}
|
42
|
+
@styles = {} # result styles
|
43
|
+
parsed_styles = Attributes.parse_styles(hash.style)
|
44
|
+
process_styles(parsed_styles)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Processes the styles attributes
|
48
|
+
#
|
49
|
+
# @param attributes [Hash] hash of styles attributes
|
50
|
+
def process_styles(styles)
|
51
|
+
styles.each do |key, value|
|
52
|
+
rule = STYLES_LIST[key]
|
53
|
+
next unless rule
|
54
|
+
|
55
|
+
apply_rule(rule, value)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
class << self
|
60
|
+
# Converts a color string
|
61
|
+
#
|
62
|
+
# @param value [String] HTML string color
|
63
|
+
#
|
64
|
+
# @return [String] adjusted string color
|
65
|
+
def convert_color(value)
|
66
|
+
val = value&.downcase || +''
|
67
|
+
val.gsub!(/[^a-f0-9]/, '')
|
68
|
+
return val unless val.size == 3
|
69
|
+
|
70
|
+
a, b, c = val.chars
|
71
|
+
a * 2 + b * 2 + c * 2
|
72
|
+
end
|
73
|
+
|
74
|
+
# Converts a decimal number string
|
75
|
+
#
|
76
|
+
# @param value [String] string decimal
|
77
|
+
#
|
78
|
+
# @return [Float] converted and rounded float number
|
79
|
+
def convert_float(value)
|
80
|
+
val = value&.gsub(/[^0-9.]/, '') || ''
|
81
|
+
val.to_f.round(4)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Converts a size string
|
85
|
+
#
|
86
|
+
# @param value [String] size string
|
87
|
+
# @param container_size [Numeric] container size
|
88
|
+
#
|
89
|
+
# @return [Float] converted and rounded size
|
90
|
+
def convert_size(value, container_size = nil)
|
91
|
+
val = value&.gsub(/[^0-9.]/, '') || ''
|
92
|
+
val =
|
93
|
+
if container_size && value.include?('%')
|
94
|
+
val.to_f * container_size * 0.01
|
95
|
+
else
|
96
|
+
val.to_f * PX
|
97
|
+
end
|
98
|
+
# pdf.bounds.height
|
99
|
+
val.round(4)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Converts a string to symbol
|
103
|
+
#
|
104
|
+
# @param value [String] string
|
105
|
+
#
|
106
|
+
# @return [Symbol] symbol
|
107
|
+
def convert_symbol(value)
|
108
|
+
value.to_sym if value && !value.match?(/\A\s*\Z/)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Merges attributes
|
112
|
+
#
|
113
|
+
# @param hash [Hash] target attributes hash
|
114
|
+
# @param key [Symbol] key
|
115
|
+
# @param value
|
116
|
+
#
|
117
|
+
# @return [Hash] the updated hash of attributes
|
118
|
+
def merge_attr!(hash, key, value)
|
119
|
+
return unless key
|
120
|
+
return (hash[key] = value) unless Attributes::STYLES_MERGE.include?(key)
|
121
|
+
|
122
|
+
hash[key] ||= 0
|
123
|
+
hash[key] += value
|
124
|
+
end
|
125
|
+
|
126
|
+
# Parses a string of styles
|
127
|
+
#
|
128
|
+
# @param styles [String] styles to parse
|
129
|
+
#
|
130
|
+
# @return [Hash] hash of styles
|
131
|
+
def parse_styles(styles)
|
132
|
+
(styles || '').scan(/\s*([^:;]+)\s*:\s*([^;]+)\s*/).to_h
|
133
|
+
end
|
134
|
+
|
135
|
+
# Unquotes a string
|
136
|
+
#
|
137
|
+
# @param value [String] string
|
138
|
+
#
|
139
|
+
# @return [String] string without quotes at the beginning/ending
|
140
|
+
def unquote(value)
|
141
|
+
(value&.strip || +'').tap do |val|
|
142
|
+
val.gsub!(/\A['"]|["']\Z/, '')
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
private
|
148
|
+
|
149
|
+
def apply_rule(rule, value)
|
150
|
+
if rule[:set] == :append_symbol
|
151
|
+
(send(rule[:dest])[rule[:key]] ||= []) << Attributes.convert_symbol(value)
|
152
|
+
else
|
153
|
+
send(rule[:dest])[rule[:key]] = Attributes.send(rule[:set], value)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrawnHtml
|
4
|
+
module Callbacks
|
5
|
+
class Highlight
|
6
|
+
DEF_HIGHLIGHT = 'ffff00'
|
7
|
+
|
8
|
+
def initialize(pdf, item)
|
9
|
+
@pdf = pdf
|
10
|
+
@color = item.delete(:background) || DEF_HIGHLIGHT
|
11
|
+
end
|
12
|
+
|
13
|
+
def render_behind(fragment)
|
14
|
+
original_color = @pdf.fill_color
|
15
|
+
@pdf.fill_color = @color
|
16
|
+
@pdf.fill_rectangle(fragment.top_left, fragment.width, fragment.height)
|
17
|
+
@pdf.fill_color = original_color
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrawnHtml
|
4
|
+
module Callbacks
|
5
|
+
class StrikeThrough
|
6
|
+
def initialize(pdf, _item)
|
7
|
+
@pdf = pdf
|
8
|
+
end
|
9
|
+
|
10
|
+
def render_in_front(fragment)
|
11
|
+
y = (fragment.top_left[1] + fragment.bottom_left[1]) / 2
|
12
|
+
@pdf.stroke do
|
13
|
+
@pdf.line [fragment.top_left[0], y], [fragment.top_right[0], y]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrawnHtml
|
4
|
+
class Context < Array
|
5
|
+
DEF_FONT_SIZE = 10.3
|
6
|
+
|
7
|
+
attr_accessor :last_margin, :last_text_node
|
8
|
+
|
9
|
+
# Init the Context
|
10
|
+
def initialize(*_args)
|
11
|
+
super
|
12
|
+
@last_margin = 0
|
13
|
+
end
|
14
|
+
|
15
|
+
def before_content
|
16
|
+
return '' if empty?
|
17
|
+
|
18
|
+
last.options[:before_content].to_s
|
19
|
+
end
|
20
|
+
|
21
|
+
# Merges the context options
|
22
|
+
#
|
23
|
+
# @return [Hash] the hash of merged options
|
24
|
+
def merge_options
|
25
|
+
each_with_object({}) do |element, res|
|
26
|
+
element.options.each do |key, value|
|
27
|
+
Attributes.merge_attr!(res, key, value)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Merge the context styles
|
33
|
+
#
|
34
|
+
# @return [Hash] the hash of merged styles
|
35
|
+
def merge_styles
|
36
|
+
context_styles = each_with_object({}) do |element, res|
|
37
|
+
evaluate_element_styles(element, res)
|
38
|
+
element.update_styles(res) if element.respond_to?(:update_styles)
|
39
|
+
end
|
40
|
+
base_styles.merge(context_styles)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def base_styles
|
46
|
+
{
|
47
|
+
size: DEF_FONT_SIZE
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
def evaluate_element_styles(element, res)
|
52
|
+
element.styles.each do |key, val|
|
53
|
+
if res.include?(key) && res[key].is_a?(Array)
|
54
|
+
res[key] += val
|
55
|
+
else
|
56
|
+
res[key] = val
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrawnHtml
|
4
|
+
class DocumentRenderer
|
5
|
+
NEW_LINE = { text: "\n" }.freeze
|
6
|
+
SPACE = { text: ' ' }.freeze
|
7
|
+
TAG_CLASSES = [Tags::A, Tags::B, Tags::Body, Tags::Br, Tags::Del, Tags::Div, Tags::H, Tags::Hr, Tags::I, Tags::Img, Tags::Li, Tags::Mark, Tags::P, Tags::Small, Tags::Span, Tags::U, Tags::Ul].freeze
|
8
|
+
|
9
|
+
# Init the DocumentRenderer
|
10
|
+
#
|
11
|
+
# @param pdf [Prawn::Document] target Prawn PDF document
|
12
|
+
def initialize(pdf)
|
13
|
+
@buffer = []
|
14
|
+
@context = Context.new
|
15
|
+
@doc_styles = {}
|
16
|
+
@pdf = pdf
|
17
|
+
end
|
18
|
+
|
19
|
+
# Assigns the document styles
|
20
|
+
#
|
21
|
+
# @param styles [Hash] styles hash with CSS selectors as keys and rules as values
|
22
|
+
def assign_document_styles(styles)
|
23
|
+
@doc_styles = styles.transform_values do |style_rules|
|
24
|
+
Attributes.new(style: style_rules).styles
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# On tag close callback
|
29
|
+
#
|
30
|
+
# @param element [Tags::Base] closing element wrapper
|
31
|
+
def on_tag_close(element)
|
32
|
+
render_if_needed(element)
|
33
|
+
apply_post_styles(element&.post_styles)
|
34
|
+
context.last_text_node = false
|
35
|
+
context.pop
|
36
|
+
end
|
37
|
+
|
38
|
+
# On tag open callback
|
39
|
+
#
|
40
|
+
# @param tag [String] the tag name of the opening element
|
41
|
+
# @param attributes [Hash] an hash of the element attributes
|
42
|
+
#
|
43
|
+
# @return [Tags::Base] the opening element wrapper
|
44
|
+
def on_tag_open(tag, attributes)
|
45
|
+
tag_class = tag_classes[tag]
|
46
|
+
return unless tag_class
|
47
|
+
|
48
|
+
tag_class.new(tag, attributes).tap do |element|
|
49
|
+
setup_element(element)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# On text node callback
|
54
|
+
#
|
55
|
+
# @param content [String] the text node content
|
56
|
+
#
|
57
|
+
# @return [NilClass] nil value (=> no element)
|
58
|
+
def on_text_node(content)
|
59
|
+
return if content.match?(/\A\s*\Z/)
|
60
|
+
|
61
|
+
text = content.gsub(/\A\s*\n\s*|\s*\n\s*\Z/, '').delete("\n").squeeze(' ')
|
62
|
+
buffer << context.merge_styles.merge(text: ::Oga::HTML::Entities.decode(context.before_content) + text)
|
63
|
+
context.last_text_node = true
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
# Render the buffer content to the PDF document
|
68
|
+
def render
|
69
|
+
return if buffer.empty?
|
70
|
+
|
71
|
+
options = context.merge_options.slice(:align, :leading, :margin_left, :padding_left)
|
72
|
+
output_content(buffer.dup, options)
|
73
|
+
buffer.clear
|
74
|
+
context.last_margin = 0
|
75
|
+
end
|
76
|
+
|
77
|
+
alias_method :flush, :render
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
attr_reader :buffer, :context, :doc_styles, :pdf
|
82
|
+
|
83
|
+
def tag_classes
|
84
|
+
@tag_classes ||= TAG_CLASSES.each_with_object({}) do |klass, res|
|
85
|
+
res.merge!(klass.elements)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def setup_element(element)
|
90
|
+
add_space_if_needed unless render_if_needed(element)
|
91
|
+
apply_pre_styles(element)
|
92
|
+
element.apply_doc_styles(doc_styles)
|
93
|
+
context.push(element)
|
94
|
+
element.custom_render(pdf, context) if element.respond_to?(:custom_render)
|
95
|
+
end
|
96
|
+
|
97
|
+
def add_space_if_needed
|
98
|
+
buffer << SPACE if buffer.any? && !context.last_text_node && ![NEW_LINE, SPACE].include?(buffer.last)
|
99
|
+
end
|
100
|
+
|
101
|
+
def render_if_needed(element)
|
102
|
+
render_needed = element&.block? && buffer.any? && buffer.last != NEW_LINE
|
103
|
+
return false unless render_needed
|
104
|
+
|
105
|
+
render
|
106
|
+
true
|
107
|
+
end
|
108
|
+
|
109
|
+
def apply_post_styles(styles)
|
110
|
+
context.last_margin = styles[:margin_bottom].to_f
|
111
|
+
return if !styles || styles.empty?
|
112
|
+
|
113
|
+
pdf.move_down(context.last_margin.round(4)) if context.last_margin > 0
|
114
|
+
pdf.move_down(styles[:padding_bottom].round(4)) if styles[:padding_bottom].to_f > 0
|
115
|
+
end
|
116
|
+
|
117
|
+
def apply_pre_styles(element)
|
118
|
+
pdf.move_down(element.options[:padding_top].round(4)) if element.options.include?(:padding_top)
|
119
|
+
return if !element.pre_styles || element.pre_styles.empty?
|
120
|
+
|
121
|
+
margin = (element.pre_styles[:margin_top] - context.last_margin).round(4)
|
122
|
+
pdf.move_down(margin) if margin > 0
|
123
|
+
end
|
124
|
+
|
125
|
+
def output_content(buffer, options)
|
126
|
+
buffer.each { |item| item[:callback] = item[:callback].new(pdf, item) if item[:callback] }
|
127
|
+
if (left = options.delete(:margin_left).to_f + options.delete(:padding_left).to_f) > 0
|
128
|
+
pdf.indent(left) { pdf.formatted_text(buffer, options) }
|
129
|
+
else
|
130
|
+
pdf.formatted_text(buffer, options)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'oga'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
class HtmlHandler
|
7
|
+
# Init the HtmlHandler
|
8
|
+
#
|
9
|
+
# @param pdf [Prawn::Document] Target Prawn PDF document
|
10
|
+
def initialize(pdf)
|
11
|
+
@processing = false
|
12
|
+
@renderer = DocumentRenderer.new(pdf)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Processes HTML and renders it on the PDF document
|
16
|
+
#
|
17
|
+
# @param html [String] The HTML content to process
|
18
|
+
def process(html)
|
19
|
+
@processing = !html.include?('<body')
|
20
|
+
doc = Oga.parse_html(html)
|
21
|
+
traverse_nodes(doc.children)
|
22
|
+
renderer.flush
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :processing, :renderer
|
28
|
+
|
29
|
+
def traverse_nodes(nodes)
|
30
|
+
nodes.each do |node|
|
31
|
+
element = node_open(node)
|
32
|
+
traverse_nodes(node.children) if node.children.any?
|
33
|
+
node_close(element) if element
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def node_open(node)
|
38
|
+
tag = node.is_a?(Oga::XML::Element) && init_element(node)
|
39
|
+
return unless processing
|
40
|
+
return renderer.on_text_node(node.text) unless tag
|
41
|
+
|
42
|
+
attributes = prepare_attributes(node)
|
43
|
+
renderer.on_tag_open(tag, attributes)
|
44
|
+
end
|
45
|
+
|
46
|
+
def init_element(node)
|
47
|
+
node.name.downcase.to_sym.tap do |tag_name|
|
48
|
+
@processing = true if tag_name == :body
|
49
|
+
renderer.assign_document_styles(extract_styles(node.text)) if tag_name == :style && !@processing
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def extract_styles(text)
|
54
|
+
text.scan(/\s*([^{\s]+)\s*{\s*([^}]*?)\s*}/m).to_h
|
55
|
+
end
|
56
|
+
|
57
|
+
def prepare_attributes(node)
|
58
|
+
node.attributes.each_with_object({}) do |attr, res|
|
59
|
+
res[attr.name] = attr.value
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def node_close(element)
|
64
|
+
renderer.on_tag_close(element) if @processing
|
65
|
+
@processing = false if element.tag == :body
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrawnHtml
|
4
|
+
module Tags
|
5
|
+
class Base
|
6
|
+
attr_reader :attrs, :styles, :tag
|
7
|
+
|
8
|
+
def initialize(tag, attributes = {})
|
9
|
+
@attrs = Attributes.new(attributes)
|
10
|
+
@styles = attrs.styles
|
11
|
+
@tag = tag
|
12
|
+
attrs.process_styles(extra_attrs) unless extra_attrs.empty?
|
13
|
+
end
|
14
|
+
|
15
|
+
def apply_doc_styles(document_styles)
|
16
|
+
selectors = [
|
17
|
+
tag.to_s,
|
18
|
+
attrs.hash['class'] ? ".#{attrs.hash['class']}" : nil,
|
19
|
+
attrs.hash['id'] ? "##{attrs.hash['id']}" : nil
|
20
|
+
].compact!
|
21
|
+
merged_styles = document_styles.each_with_object({}) do |(sel, attributes), res|
|
22
|
+
res.merge!(attributes) if selectors.include?(sel)
|
23
|
+
end
|
24
|
+
styles.merge!(merged_styles)
|
25
|
+
end
|
26
|
+
|
27
|
+
def block?
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
def extra_attrs
|
32
|
+
{}
|
33
|
+
end
|
34
|
+
|
35
|
+
def options
|
36
|
+
attrs.options
|
37
|
+
end
|
38
|
+
|
39
|
+
def post_styles
|
40
|
+
attrs.post_styles
|
41
|
+
end
|
42
|
+
|
43
|
+
def pre_styles
|
44
|
+
attrs.pre_styles
|
45
|
+
end
|
46
|
+
|
47
|
+
class << self
|
48
|
+
def elements
|
49
|
+
self::ELEMENTS.each_with_object({}) { |el, list| list[el] = self }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class Br < Base
|
8
|
+
ELEMENTS = [:br].freeze
|
9
|
+
|
10
|
+
BR_SPACING = 12
|
11
|
+
|
12
|
+
def block?
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
def custom_render(pdf, context)
|
17
|
+
return if context.last_text_node
|
18
|
+
|
19
|
+
@spacing ||= Attributes.convert_size(BR_SPACING.to_s)
|
20
|
+
pdf.move_down(@spacing)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class H < Base
|
8
|
+
ELEMENTS = [:h1, :h2, :h3, :h4, :h5, :h6].freeze
|
9
|
+
|
10
|
+
MARGINS_TOP = {
|
11
|
+
h1: 25.5,
|
12
|
+
h2: 20.5,
|
13
|
+
h3: 19,
|
14
|
+
h4: 20,
|
15
|
+
h5: 21.2,
|
16
|
+
h6: 23.5
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
MARGINS_BOTTOM = {
|
20
|
+
h1: 18.2,
|
21
|
+
h2: 17.5,
|
22
|
+
h3: 17.5,
|
23
|
+
h4: 22,
|
24
|
+
h5: 22,
|
25
|
+
h6: 26.5
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
SIZES = {
|
29
|
+
h1: 31,
|
30
|
+
h2: 23.5,
|
31
|
+
h3: 18.2,
|
32
|
+
h4: 16,
|
33
|
+
h5: 13,
|
34
|
+
h6: 10.5
|
35
|
+
}.freeze
|
36
|
+
|
37
|
+
def block?
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
def extra_attrs
|
42
|
+
@extra_attrs ||= {
|
43
|
+
'font-size' => SIZES[tag].to_s,
|
44
|
+
'font-weight' => 'bold',
|
45
|
+
'margin-bottom' => MARGINS_BOTTOM[tag].to_s,
|
46
|
+
'margin-top' => MARGINS_TOP[tag].to_s
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class Hr < Base
|
8
|
+
ELEMENTS = [:hr].freeze
|
9
|
+
|
10
|
+
MARGIN_BOTTOM = 12
|
11
|
+
MARGIN_TOP = 6
|
12
|
+
|
13
|
+
def block?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
def custom_render(pdf, _context)
|
18
|
+
pdf.stroke_horizontal_rule
|
19
|
+
end
|
20
|
+
|
21
|
+
def extra_attrs
|
22
|
+
@extra_attrs ||= {
|
23
|
+
'margin-bottom' => MARGIN_BOTTOM.to_s,
|
24
|
+
'margin-top' => MARGIN_TOP.to_s,
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class Img < Base
|
8
|
+
ELEMENTS = [:img].freeze
|
9
|
+
|
10
|
+
def block?
|
11
|
+
true
|
12
|
+
end
|
13
|
+
|
14
|
+
def custom_render(pdf, context)
|
15
|
+
styles = Attributes.parse_styles(attrs.hash.style)
|
16
|
+
context_options = context.merge_options
|
17
|
+
options = evaluate_styles(pdf, context_options.merge(styles))
|
18
|
+
pdf.image(@attrs.hash.src, options)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def evaluate_styles(pdf, styles)
|
24
|
+
options = {}
|
25
|
+
options[:width] = Attributes.convert_size(styles['width'], pdf.bounds.width) if styles.include?('width')
|
26
|
+
options[:height] = Attributes.convert_size(styles['height'], pdf.bounds.height) if styles.include?('height')
|
27
|
+
options[:position] = styles[:align] if %i[left center right].include?(styles[:align])
|
28
|
+
options
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class Li < Base
|
8
|
+
ELEMENTS = [:li].freeze
|
9
|
+
|
10
|
+
def block?
|
11
|
+
true
|
12
|
+
end
|
13
|
+
|
14
|
+
def options
|
15
|
+
super.merge(
|
16
|
+
before_content: '• '
|
17
|
+
)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class P < Base
|
8
|
+
ELEMENTS = [:p].freeze
|
9
|
+
|
10
|
+
MARGIN_BOTTOM = 6
|
11
|
+
MARGIN_TOP = 6
|
12
|
+
|
13
|
+
def block?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
def extra_attrs
|
18
|
+
@extra_attrs ||= {
|
19
|
+
'margin-bottom' => MARGIN_BOTTOM.to_s,
|
20
|
+
'margin-top' => MARGIN_TOP.to_s
|
21
|
+
}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class Small < Base
|
8
|
+
ELEMENTS = [:small].freeze
|
9
|
+
|
10
|
+
def update_styles(styles)
|
11
|
+
size = (styles[:size] || Context::DEF_FONT_SIZE) * 0.85
|
12
|
+
styles[:size] = size
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module PrawnHtml
|
6
|
+
module Tags
|
7
|
+
class Ul < Base
|
8
|
+
ELEMENTS = [:ul].freeze
|
9
|
+
|
10
|
+
MARGIN_LEFT = 25
|
11
|
+
|
12
|
+
def block?
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
def extra_attrs
|
17
|
+
@extra_attrs ||= {
|
18
|
+
'margin-left' => MARGIN_LEFT.to_s,
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
metadata
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: prawn-html
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mattia Roccoberton
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-08-10 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: oga
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: prawn
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.4'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.4'
|
41
|
+
description: HTML to PDF with Prawn PDF
|
42
|
+
email: mat@blocknot.es
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- LICENSE.txt
|
48
|
+
- README.md
|
49
|
+
- lib/prawn-html.rb
|
50
|
+
- lib/prawn_html/attributes.rb
|
51
|
+
- lib/prawn_html/callbacks/highlight.rb
|
52
|
+
- lib/prawn_html/callbacks/strike_through.rb
|
53
|
+
- lib/prawn_html/context.rb
|
54
|
+
- lib/prawn_html/document_renderer.rb
|
55
|
+
- lib/prawn_html/html_handler.rb
|
56
|
+
- lib/prawn_html/tags/a.rb
|
57
|
+
- lib/prawn_html/tags/b.rb
|
58
|
+
- lib/prawn_html/tags/base.rb
|
59
|
+
- lib/prawn_html/tags/body.rb
|
60
|
+
- lib/prawn_html/tags/br.rb
|
61
|
+
- lib/prawn_html/tags/del.rb
|
62
|
+
- lib/prawn_html/tags/div.rb
|
63
|
+
- lib/prawn_html/tags/h.rb
|
64
|
+
- lib/prawn_html/tags/hr.rb
|
65
|
+
- lib/prawn_html/tags/i.rb
|
66
|
+
- lib/prawn_html/tags/img.rb
|
67
|
+
- lib/prawn_html/tags/li.rb
|
68
|
+
- lib/prawn_html/tags/mark.rb
|
69
|
+
- lib/prawn_html/tags/p.rb
|
70
|
+
- lib/prawn_html/tags/small.rb
|
71
|
+
- lib/prawn_html/tags/span.rb
|
72
|
+
- lib/prawn_html/tags/u.rb
|
73
|
+
- lib/prawn_html/tags/ul.rb
|
74
|
+
- lib/prawn_html/version.rb
|
75
|
+
homepage: https://github.com/blocknotes/prawn-html
|
76
|
+
licenses:
|
77
|
+
- MIT
|
78
|
+
metadata: {}
|
79
|
+
post_install_message:
|
80
|
+
rdoc_options: []
|
81
|
+
require_paths:
|
82
|
+
- lib
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - ">="
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: 2.5.0
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
requirements: []
|
94
|
+
rubygems_version: 3.1.4
|
95
|
+
signing_key:
|
96
|
+
specification_version: 4
|
97
|
+
summary: Prawn PDF - HTML renderer
|
98
|
+
test_files: []
|