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.
@@ -0,0 +1,15 @@
1
+ module Prawn
2
+ module Markup
3
+ module Elements
4
+ class Cell < Item
5
+ attr_reader :header, :width
6
+
7
+ def initialize(header: false, width: 'auto')
8
+ super()
9
+ @header = header
10
+ @width = width
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module Prawn
2
+ module Markup
3
+ module Elements
4
+ class Item
5
+ attr_reader :nodes
6
+
7
+ def initialize
8
+ @nodes = []
9
+ end
10
+
11
+ def single?
12
+ nodes.size <= 1
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ module Prawn
2
+ module Markup
3
+ module Elements
4
+ class List
5
+ attr_reader :ordered, :items
6
+
7
+ def initialize(ordered)
8
+ @ordered = ordered
9
+ @items = []
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;'))
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