prawn-markup 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|