prawn-markup 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/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +64 -0
- data/.travis.yml +25 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +89 -0
- data/LICENSE.txt +21 -0
- data/README.md +106 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/prawn/markup.rb +23 -0
- data/lib/prawn/markup/builders/list_builder.rb +165 -0
- data/lib/prawn/markup/builders/nestable_builder.rb +68 -0
- data/lib/prawn/markup/builders/table_builder.rb +253 -0
- data/lib/prawn/markup/elements/cell.rb +15 -0
- data/lib/prawn/markup/elements/item.rb +17 -0
- data/lib/prawn/markup/elements/list.rb +14 -0
- data/lib/prawn/markup/interface.rb +17 -0
- data/lib/prawn/markup/processor.rb +145 -0
- data/lib/prawn/markup/processor/headings.rb +64 -0
- data/lib/prawn/markup/processor/images.rb +91 -0
- data/lib/prawn/markup/processor/lists.rb +95 -0
- data/lib/prawn/markup/processor/tables.rb +97 -0
- data/lib/prawn/markup/processor/text.rb +176 -0
- data/lib/prawn/markup/support/hash_merger.rb +19 -0
- data/lib/prawn/markup/support/normalizer.rb +48 -0
- data/lib/prawn/markup/support/size_converter.rb +31 -0
- data/lib/prawn/markup/version.rb +5 -0
- data/prawn-markup.gemspec +37 -0
- metadata +217 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
module Prawn
|
2
|
+
module Markup
|
3
|
+
module Interface
|
4
|
+
attr_writer :markup_options
|
5
|
+
|
6
|
+
def markup(html, options = {})
|
7
|
+
options = HashMerger.deep(markup_options, options)
|
8
|
+
Processor.new(self, options).parse(html)
|
9
|
+
end
|
10
|
+
|
11
|
+
def markup_options
|
12
|
+
@markup_options ||= {}
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
module Prawn
|
2
|
+
module Markup
|
3
|
+
# Processes known HTML tags. Unknown tags are ignored.
|
4
|
+
class Processor < Nokogiri::XML::SAX::Document
|
5
|
+
class Error < StandardError; end
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def known_elements
|
9
|
+
@@known_elments ||= []
|
10
|
+
end
|
11
|
+
|
12
|
+
def logger
|
13
|
+
@@logger
|
14
|
+
end
|
15
|
+
|
16
|
+
def logger=(logger)
|
17
|
+
@@logger = logger
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
self.logger = defined?(Rails) ? Rails.logger : nil
|
22
|
+
|
23
|
+
require 'prawn/markup/processor/text'
|
24
|
+
require 'prawn/markup/processor/headings'
|
25
|
+
require 'prawn/markup/processor/images'
|
26
|
+
require 'prawn/markup/processor/tables'
|
27
|
+
require 'prawn/markup/processor/lists'
|
28
|
+
|
29
|
+
prepend Prawn::Markup::Processor::Text
|
30
|
+
prepend Prawn::Markup::Processor::Headings
|
31
|
+
prepend Prawn::Markup::Processor::Images
|
32
|
+
prepend Prawn::Markup::Processor::Tables
|
33
|
+
prepend Prawn::Markup::Processor::Lists
|
34
|
+
|
35
|
+
def initialize(pdf, options = {})
|
36
|
+
@pdf = pdf
|
37
|
+
@options = options
|
38
|
+
end
|
39
|
+
|
40
|
+
def parse(html)
|
41
|
+
return if html.to_s.strip.empty?
|
42
|
+
reset
|
43
|
+
html = Prawn::Markup::Normalizer.new(html).normalize
|
44
|
+
Nokogiri::HTML::SAX::Parser.new(self).parse(html) { |ctx| ctx.recovery = true }
|
45
|
+
end
|
46
|
+
|
47
|
+
def start_element(name, attrs = [])
|
48
|
+
stack.push(name: name, attrs: Hash[attrs])
|
49
|
+
if self.class.known_elements.include?(name)
|
50
|
+
send("start_#{name}") if respond_to?("start_#{name}", true)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def end_element(name)
|
55
|
+
send("end_#{name}") if respond_to?("end_#{name}", true)
|
56
|
+
stack.pop
|
57
|
+
end
|
58
|
+
|
59
|
+
def characters(string)
|
60
|
+
# entities will be replaced again later by inline_format
|
61
|
+
append_text(string.gsub('&', '&').gsub('<', '<').gsub('>', '>'))
|
62
|
+
end
|
63
|
+
|
64
|
+
def error(string)
|
65
|
+
logger.debug('SAX parsing error: ' + string) if logger
|
66
|
+
end
|
67
|
+
|
68
|
+
def warning(string)
|
69
|
+
logger.warn(string) if logger
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
attr_reader :pdf, :stack, :text_buffer, :bottom_margin, :options
|
75
|
+
|
76
|
+
def reset
|
77
|
+
@stack = []
|
78
|
+
@text_buffer = ''
|
79
|
+
end
|
80
|
+
|
81
|
+
def append_text(string)
|
82
|
+
text_buffer.concat(string)
|
83
|
+
end
|
84
|
+
|
85
|
+
def buffered_text?
|
86
|
+
!text_buffer.strip.empty?
|
87
|
+
end
|
88
|
+
|
89
|
+
def dump_text
|
90
|
+
text = process_text(text_buffer.dup)
|
91
|
+
text_buffer.clear
|
92
|
+
text
|
93
|
+
end
|
94
|
+
|
95
|
+
def put_bottom_margin(value)
|
96
|
+
@bottom_margin = value
|
97
|
+
end
|
98
|
+
|
99
|
+
def inside_container?
|
100
|
+
false
|
101
|
+
end
|
102
|
+
|
103
|
+
def current_attrs
|
104
|
+
stack.last[:attrs]
|
105
|
+
end
|
106
|
+
|
107
|
+
def process_text(text)
|
108
|
+
if options[:text] && options[:text][:preprocessor]
|
109
|
+
options[:text][:preprocessor].call(text)
|
110
|
+
else
|
111
|
+
text
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def style_properties
|
116
|
+
style = current_attrs['style']
|
117
|
+
if style
|
118
|
+
tokens = style.split(';').map { |p| p.split(':', 2).map(&:strip) }
|
119
|
+
Hash[tokens]
|
120
|
+
else
|
121
|
+
{}
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def placeholder_value(keys, *args)
|
126
|
+
placeholder = dig_options(keys)
|
127
|
+
return if placeholder.nil?
|
128
|
+
|
129
|
+
if placeholder.respond_to?(:call)
|
130
|
+
placeholder.call(*args)
|
131
|
+
else
|
132
|
+
placeholder.to_s
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def dig_options(keys)
|
137
|
+
keys.inject(options) { |opts, key| opts ? opts[key] : nil }
|
138
|
+
end
|
139
|
+
|
140
|
+
def logger
|
141
|
+
self.class.logger
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Prawn
|
2
|
+
module Markup
|
3
|
+
module Processor::Headings
|
4
|
+
def self.prepended(base)
|
5
|
+
base.known_elements.push('h1', 'h2', 'h3', 'h4', 'h5', 'h6')
|
6
|
+
end
|
7
|
+
|
8
|
+
(1..6).each do |i|
|
9
|
+
define_method("start_h#{i}") do
|
10
|
+
start_heading(i)
|
11
|
+
end
|
12
|
+
|
13
|
+
define_method("end_h#{i}") do
|
14
|
+
end_heading(i)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def start_heading(level)
|
19
|
+
if current_table
|
20
|
+
add_cell_text_node(current_cell)
|
21
|
+
elsif current_list
|
22
|
+
add_cell_text_node(current_list_item)
|
23
|
+
else
|
24
|
+
add_current_text if buffered_text?
|
25
|
+
pdf.move_down(current_heading_margin_top(level))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def end_heading(level)
|
30
|
+
options = heading_options(level)
|
31
|
+
if current_table
|
32
|
+
add_cell_text_node(current_cell, options)
|
33
|
+
elsif current_list
|
34
|
+
add_cell_text_node(current_list_item, options)
|
35
|
+
else
|
36
|
+
add_current_text(options)
|
37
|
+
put_bottom_margin(options[:margin_bottom])
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def current_heading_margin_top(level)
|
44
|
+
top_margin = heading_options(level)[:margin_top] || 0
|
45
|
+
margin = [top_margin, @bottom_margin || 0].max
|
46
|
+
put_bottom_margin(nil)
|
47
|
+
margin
|
48
|
+
end
|
49
|
+
|
50
|
+
def heading_options(level)
|
51
|
+
@heading_options ||= {}
|
52
|
+
@heading_options[level] ||= default_options_with_size(level)
|
53
|
+
end
|
54
|
+
|
55
|
+
def default_options_with_size(level)
|
56
|
+
default = text_options.dup
|
57
|
+
default[:size] ||= pdf.font_size
|
58
|
+
default[:size] *= 2.5 - level * 0.25
|
59
|
+
HashMerger.deep(default, options[:"heading#{level}"] || {})
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'open-uri'
|
2
|
+
|
3
|
+
module Prawn
|
4
|
+
module Markup
|
5
|
+
module Processor::Images
|
6
|
+
ALLOWED_IMAGE_TYPES = %w[image/png image/jpeg].freeze
|
7
|
+
|
8
|
+
def self.prepended(base)
|
9
|
+
base.known_elements.push('img', 'iframe')
|
10
|
+
end
|
11
|
+
|
12
|
+
def start_img
|
13
|
+
add_current_text
|
14
|
+
add_image_or_placeholder(current_attrs['src'])
|
15
|
+
end
|
16
|
+
|
17
|
+
def start_iframe
|
18
|
+
placeholder = iframe_placeholder
|
19
|
+
append_text("\n#{placeholder}\n") if placeholder
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def add_image_or_placeholder(src)
|
25
|
+
img = image_properties(src)
|
26
|
+
if img
|
27
|
+
add_image(img)
|
28
|
+
else
|
29
|
+
append_text("\n#{invalid_image_placeholder}\n")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def add_image(img)
|
34
|
+
# parse width in the current context
|
35
|
+
img[:width] = SizeConverter.new(pdf.bounds.width).parse(style_properties['width'])
|
36
|
+
pdf.image(img.delete(:image), img)
|
37
|
+
put_bottom_margin(text_margin_bottom)
|
38
|
+
rescue Prawn::Errors::UnsupportedImageType
|
39
|
+
append_text("\n#{invalid_image_placeholder}\n")
|
40
|
+
end
|
41
|
+
|
42
|
+
def image_properties(src)
|
43
|
+
img = load_image(src)
|
44
|
+
if img
|
45
|
+
props = style_properties
|
46
|
+
{
|
47
|
+
image: img,
|
48
|
+
width: props['width'],
|
49
|
+
position: convert_float_to_position(props['float'])
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def load_image(src)
|
55
|
+
if options[:image] && options[:image][:loader]
|
56
|
+
options[:image][:loader].call(src)
|
57
|
+
else
|
58
|
+
decode_base64_image(src) || load_remote_image(src)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def decode_base64_image(src)
|
63
|
+
match = src.match(/^data:(.*?);(.*?),(.*)$/)
|
64
|
+
if match && ALLOWED_IMAGE_TYPES.include?(match[1])
|
65
|
+
StringIO.new(Base64.decode64(match[3]))
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def load_remote_image(src)
|
70
|
+
if src =~ %r{^https?:/}
|
71
|
+
URI.parse(src).open
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def convert_float_to_position(float)
|
76
|
+
{ nil => nil,
|
77
|
+
'none' => nil,
|
78
|
+
'left' => :left,
|
79
|
+
'right' => :right }[float]
|
80
|
+
end
|
81
|
+
|
82
|
+
def invalid_image_placeholder
|
83
|
+
placeholder_value(%i[image placeholder]) || '[unsupported image]'
|
84
|
+
end
|
85
|
+
|
86
|
+
def iframe_placeholder
|
87
|
+
placeholder_value(%i[iframe placeholder], current_attrs['src'])
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Prawn
|
2
|
+
module Markup
|
3
|
+
module Processor::Lists
|
4
|
+
def self.prepended(base)
|
5
|
+
base.known_elements.push('ol', 'ul', 'li')
|
6
|
+
end
|
7
|
+
|
8
|
+
def start_ol
|
9
|
+
start_list(true)
|
10
|
+
end
|
11
|
+
|
12
|
+
def start_ul
|
13
|
+
start_list(false)
|
14
|
+
end
|
15
|
+
|
16
|
+
def start_list(ordered)
|
17
|
+
if current_list
|
18
|
+
add_cell_text_node(current_list_item)
|
19
|
+
elsif current_table
|
20
|
+
add_cell_text_node(current_cell)
|
21
|
+
else
|
22
|
+
add_current_text
|
23
|
+
end
|
24
|
+
@list_stack.push(Elements::List.new(ordered))
|
25
|
+
end
|
26
|
+
|
27
|
+
def end_list
|
28
|
+
list = list_stack.pop
|
29
|
+
append_list(list) unless list.items.empty?
|
30
|
+
end
|
31
|
+
alias end_ol end_list
|
32
|
+
alias end_ul end_list
|
33
|
+
|
34
|
+
def start_li
|
35
|
+
current_list.items << Elements::Item.new
|
36
|
+
end
|
37
|
+
|
38
|
+
def end_li
|
39
|
+
add_cell_text_node(current_list_item)
|
40
|
+
end
|
41
|
+
|
42
|
+
def start_img
|
43
|
+
if current_list
|
44
|
+
add_cell_image(current_list_item)
|
45
|
+
else
|
46
|
+
super
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :list_stack
|
53
|
+
|
54
|
+
def reset
|
55
|
+
@list_stack = []
|
56
|
+
super
|
57
|
+
end
|
58
|
+
|
59
|
+
def current_list
|
60
|
+
list_stack.last
|
61
|
+
end
|
62
|
+
|
63
|
+
def current_list_item
|
64
|
+
current_list.items.last
|
65
|
+
end
|
66
|
+
|
67
|
+
def inside_container?
|
68
|
+
super || current_list
|
69
|
+
end
|
70
|
+
|
71
|
+
def append_list(list)
|
72
|
+
if list_stack.empty?
|
73
|
+
if current_table
|
74
|
+
current_cell.nodes << list
|
75
|
+
else
|
76
|
+
add_list(list)
|
77
|
+
end
|
78
|
+
else
|
79
|
+
current_list_item.nodes << list
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def add_list(list)
|
84
|
+
Builders::ListBuilder.new(pdf, list, pdf.bounds.width, options).draw
|
85
|
+
put_bottom_margin(text_margin_bottom)
|
86
|
+
rescue Prawn::Errors::CannotFit => e
|
87
|
+
append_text(list_too_large_placeholder(e))
|
88
|
+
end
|
89
|
+
|
90
|
+
def list_too_large_placeholder(error)
|
91
|
+
placeholder_value(%i[list placeholder too_large], error) || '[list content too large]'
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|