prawn-html 0.1.4 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -11,10 +11,8 @@ module PrawnHtml
11
11
  end
12
12
 
13
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
14
+ top, left = fragment.top_left
15
+ @pdf.draw_rectangle(x: left, y: top, width: fragment.width, height: fragment.height, color: @color)
18
16
  end
19
17
  end
20
18
  end
@@ -8,10 +8,10 @@ module PrawnHtml
8
8
  end
9
9
 
10
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
11
+ x1 = fragment.left
12
+ x2 = fragment.right
13
+ y = (fragment.top + fragment.bottom) / 2
14
+ @pdf.underline(x1: x1, x2: x2, y: y)
15
15
  end
16
16
  end
17
17
  end
@@ -10,34 +10,50 @@ module PrawnHtml
10
10
  def initialize(*_args)
11
11
  super
12
12
  @last_margin = 0
13
+ @last_text_node = false
13
14
  end
14
15
 
15
- def before_content
16
- return '' if empty?
16
+ # Add an element to the context
17
+ #
18
+ # Set the parent for the previous element in the chain.
19
+ # Run `on_context_add` callback method on the added element.
20
+ #
21
+ # @param element [Tag] the element to add
22
+ #
23
+ # @return [Context] the context updated
24
+ def add(element)
25
+ element.parent = last
26
+ push(element)
27
+ element.on_context_add(self) if element.respond_to?(:on_context_add)
28
+ self
29
+ end
17
30
 
18
- last.options[:before_content].to_s
31
+ # Evaluate before content
32
+ #
33
+ # @return [String] before content string
34
+ def before_content
35
+ (last.respond_to?(:before_content) && last.before_content) || ''
19
36
  end
20
37
 
21
- # Merges the context options
38
+ # Merges the context block styles
22
39
  #
23
- # @return [Hash] the hash of merged options
24
- def merge_options
40
+ # @return [Hash] the hash of merged styles
41
+ def block_styles
25
42
  each_with_object({}) do |element, res|
26
- element.options.each do |key, value|
43
+ element.block_styles.each do |key, value|
27
44
  Attributes.merge_attr!(res, key, value)
28
45
  end
29
46
  end
30
47
  end
31
48
 
32
- # Merge the context styles
49
+ # Merge the context styles for text nodes
33
50
  #
34
51
  # @return [Hash] the hash of merged styles
35
- def merge_styles
36
- context_styles = each_with_object({}) do |element, res|
52
+ def text_node_styles
53
+ each_with_object(base_styles) do |element, res|
37
54
  evaluate_element_styles(element, res)
38
55
  element.update_styles(res) if element.respond_to?(:update_styles)
39
56
  end
40
- base_styles.merge(context_styles)
41
57
  end
42
58
 
43
59
  private
@@ -49,7 +65,8 @@ module PrawnHtml
49
65
  end
50
66
 
51
67
  def evaluate_element_styles(element, res)
52
- element.styles.each do |key, val|
68
+ styles = element.styles.slice(*Attributes::STYLES_APPLY[:text_node])
69
+ styles.each do |key, val|
53
70
  if res.include?(key) && res[key].is_a?(Array)
54
71
  res[key] += val
55
72
  else
@@ -4,48 +4,38 @@ module PrawnHtml
4
4
  class DocumentRenderer
5
5
  NEW_LINE = { text: "\n" }.freeze
6
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
7
 
9
8
  # Init the DocumentRenderer
10
9
  #
11
- # @param pdf [Prawn::Document] target Prawn PDF document
10
+ # @param pdf [PdfWrapper] target PDF wrapper
12
11
  def initialize(pdf)
13
12
  @buffer = []
14
13
  @context = Context.new
15
- @doc_styles = {}
16
14
  @pdf = pdf
17
15
  end
18
16
 
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
17
  # On tag close callback
29
18
  #
30
- # @param element [Tags::Base] closing element wrapper
19
+ # @param element [Tag] closing element wrapper
31
20
  def on_tag_close(element)
32
21
  render_if_needed(element)
33
- apply_post_styles(element&.post_styles)
22
+ apply_tag_close_styles(element)
34
23
  context.last_text_node = false
35
24
  context.pop
36
25
  end
37
26
 
38
27
  # On tag open callback
39
28
  #
40
- # @param tag [String] the tag name of the opening element
29
+ # @param tag_name [String] the tag name of the opening element
41
30
  # @param attributes [Hash] an hash of the element attributes
31
+ # @param element_styles [String] document styles to apply to the element
42
32
  #
43
- # @return [Tags::Base] the opening element wrapper
44
- def on_tag_open(tag, attributes)
45
- tag_class = tag_classes[tag]
33
+ # @return [Tag] the opening element wrapper
34
+ def on_tag_open(tag_name, attributes:, element_styles: '')
35
+ tag_class = Tag.class_for(tag_name)
46
36
  return unless tag_class
47
37
 
48
- tag_class.new(tag, attributes).tap do |element|
38
+ tag_class.new(tag_name, attributes: attributes, element_styles: element_styles).tap do |element|
49
39
  setup_element(element)
50
40
  end
51
41
  end
@@ -58,8 +48,9 @@ module PrawnHtml
58
48
  def on_text_node(content)
59
49
  return if content.match?(/\A\s*\Z/)
60
50
 
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)
51
+ text = ::Oga::HTML::Entities.decode(context.before_content)
52
+ text += content.gsub(/\A\s*\n\s*|\s*\n\s*\Z/, '').delete("\n").squeeze(' ')
53
+ buffer << context.text_node_styles.merge(text: text)
63
54
  context.last_text_node = true
64
55
  nil
65
56
  end
@@ -68,8 +59,7 @@ module PrawnHtml
68
59
  def render
69
60
  return if buffer.empty?
70
61
 
71
- options = context.merge_options.slice(:align, :leading, :margin_left, :padding_left)
72
- output_content(buffer.dup, options)
62
+ output_content(buffer.dup, context.block_styles)
73
63
  buffer.clear
74
64
  context.last_margin = 0
75
65
  end
@@ -78,19 +68,12 @@ module PrawnHtml
78
68
 
79
69
  private
80
70
 
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
71
+ attr_reader :buffer, :context, :pdf
88
72
 
89
73
  def setup_element(element)
90
74
  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)
75
+ apply_tag_open_styles(element)
76
+ context.add(element)
94
77
  element.custom_render(pdf, context) if element.respond_to?(:custom_render)
95
78
  end
96
79
 
@@ -106,29 +89,41 @@ module PrawnHtml
106
89
  true
107
90
  end
108
91
 
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
92
+ def apply_tag_close_styles(element)
93
+ tag_styles = element.tag_close_styles
94
+ context.last_margin = tag_styles[:margin_bottom].to_f
95
+ pdf.advance_cursor(context.last_margin + tag_styles[:padding_bottom].to_f)
96
+ pdf.start_new_page if tag_styles[:break_after]
115
97
  end
116
98
 
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?
99
+ def apply_tag_open_styles(element)
100
+ tag_styles = element.tag_open_styles
101
+ move_down = (tag_styles[:margin_top].to_f - context.last_margin) + tag_styles[:padding_top].to_f
102
+ pdf.advance_cursor(move_down) if move_down > 0
103
+ pdf.start_new_page if tag_styles[:break_before]
104
+ end
120
105
 
121
- margin = (element.pre_styles[:margin_top] - context.last_margin).round(4)
122
- pdf.move_down(margin) if margin > 0
106
+ def output_content(buffer, block_styles)
107
+ apply_callbacks(buffer)
108
+ left_indent = block_styles[:margin_left].to_f + block_styles[:padding_left].to_f
109
+ options = block_styles.slice(:align, :leading, :mode, :padding_left)
110
+ options[:indent_paragraphs] = left_indent if left_indent > 0
111
+ pdf.puts(buffer, options, bounding_box: bounds(block_styles))
123
112
  end
124
113
 
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)
114
+ def apply_callbacks(buffer)
115
+ buffer.select { |item| item[:callback] }.each do |item|
116
+ callback = Tag::CALLBACKS[item[:callback]]
117
+ item[:callback] = callback.new(pdf, item)
131
118
  end
132
119
  end
120
+
121
+ def bounds(block_styles)
122
+ return unless block_styles[:position] == :absolute
123
+
124
+ y = pdf.bounds.height - (block_styles[:top] || 0)
125
+ w = pdf.bounds.width - (block_styles[:left] || 0)
126
+ [[block_styles[:left] || 0, y], { width: w }]
127
+ end
133
128
  end
134
129
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oga'
4
+
5
+ module PrawnHtml
6
+ class HtmlParser
7
+ REGEXP_STYLES = /\s*([^{\s]+)\s*{\s*([^}]*?)\s*}/m.freeze
8
+
9
+ # Init the HtmlParser
10
+ #
11
+ # @param renderer [DocumentRenderer] document renderer
12
+ # @param ignore_content_tags [Array] array of tags (symbols) to skip their contents while preparing the PDF document
13
+ def initialize(renderer, ignore_content_tags: %i[script style])
14
+ @processing = false
15
+ @ignore = false
16
+ @ignore_content_tags = ignore_content_tags
17
+ @renderer = renderer
18
+ @styles = {}
19
+ end
20
+
21
+ # Processes HTML and renders it
22
+ #
23
+ # @param html [String] The HTML content to process
24
+ def process(html)
25
+ @processing = !html.include?('<body')
26
+ @document = Oga.parse_html(html)
27
+ traverse_nodes(document.children)
28
+ renderer.flush
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :document, :ignore, :processing, :renderer, :styles
34
+
35
+ def traverse_nodes(nodes)
36
+ nodes.each do |node|
37
+ next if node.is_a?(Oga::XML::Comment)
38
+
39
+ element = node_open(node)
40
+ traverse_nodes(node.children) if node.children.any?
41
+ node_close(element) if element
42
+ end
43
+ end
44
+
45
+ def node_open(node)
46
+ tag = node.is_a?(Oga::XML::Element) && init_element(node)
47
+ return unless processing
48
+ return IgnoredTag.new(tag) if ignore
49
+ return renderer.on_text_node(node.text) unless tag
50
+
51
+ renderer.on_tag_open(tag, attributes: prepare_attributes(node), element_styles: styles[node])
52
+ end
53
+
54
+ def init_element(node)
55
+ node.name.downcase.to_sym.tap do |tag_name|
56
+ @processing = true if tag_name == :body
57
+ @ignore = true if @processing && @ignore_content_tags.include?(tag_name)
58
+ process_styles(node.text) if tag_name == :style
59
+ end
60
+ end
61
+
62
+ def process_styles(text_styles)
63
+ hash_styles = text_styles.scan(REGEXP_STYLES).to_h
64
+ hash_styles.each do |selector, rule|
65
+ document.css(selector).each do |node|
66
+ styles[node] = rule
67
+ end
68
+ end
69
+ end
70
+
71
+ def prepare_attributes(node)
72
+ node.attributes.each_with_object({}) do |attr, res|
73
+ res[attr.name] = attr.value
74
+ end
75
+ end
76
+
77
+ def node_close(element)
78
+ if processing
79
+ renderer.on_tag_close(element) unless ignore
80
+ @ignore = false if ignore && @ignore_content_tags.include?(element.tag)
81
+ end
82
+ @processing = false if element.tag == :body
83
+ end
84
+ end
85
+
86
+ class IgnoredTag
87
+ attr_accessor :tag
88
+
89
+ def initialize(tag_name)
90
+ @tag = tag_name
91
+ end
92
+ end
93
+
94
+ HtmlHandler = HtmlParser
95
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module PrawnHtml
6
+ class PdfWrapper
7
+ extend Forwardable
8
+
9
+ def_delegators :@pdf, :bounds, :start_new_page
10
+
11
+ # Wrapper for Prawn PDF Document
12
+ #
13
+ # @param pdf_document [Prawn::Document] PDF document to wrap
14
+ def initialize(pdf_document)
15
+ @pdf = pdf_document
16
+ end
17
+
18
+ # Advance the cursor
19
+ #
20
+ # @param move_down [Float] Quantity to advance (move down)
21
+ def advance_cursor(move_down)
22
+ return if !move_down || move_down == 0
23
+
24
+ pdf.move_down(move_down)
25
+ end
26
+
27
+ # Draw a rectangle
28
+ #
29
+ # @param x [Float] left position of the rectangle
30
+ # @param y [Float] top position of the rectangle
31
+ # @param width [Float] width of the rectangle
32
+ # @param height [Float] height of the rectangle
33
+ # @param color [String] fill color
34
+ def draw_rectangle(x:, y:, width:, height:, color:)
35
+ current_fill_color = pdf.fill_color
36
+ pdf.fill_color = color
37
+ pdf.fill_rectangle([y, x], width, height)
38
+ pdf.fill_color = current_fill_color
39
+ end
40
+
41
+ # Horizontal line
42
+ #
43
+ # @param color [String] line color
44
+ # @param dash [Integer|Array] integer or array of integer with dash options
45
+ def horizontal_rule(color:, dash:)
46
+ current_color = pdf.stroke_color
47
+ pdf.dash(dash) if dash
48
+ pdf.stroke_color = color if color
49
+ pdf.stroke_horizontal_rule
50
+ pdf.stroke_color = current_color if color
51
+ pdf.undash if dash
52
+ end
53
+
54
+ # Image
55
+ #
56
+ # @param src [String] image source path
57
+ # @param options [Hash] hash of options
58
+ def image(src, options = {})
59
+ return unless src
60
+
61
+ pdf.image(src, options)
62
+ end
63
+
64
+ # Output to the PDF document
65
+ #
66
+ # @param buffer [Array] array of text items
67
+ # @param options [Hash] hash of options
68
+ # @param bounding_box [Array] bounding box arguments, if bounded
69
+ def puts(buffer, options, bounding_box: nil)
70
+ return pdf.formatted_text(buffer, options) unless bounding_box
71
+
72
+ current_y = pdf.cursor
73
+ pdf.bounding_box(*bounding_box) do
74
+ pdf.formatted_text(buffer, options)
75
+ end
76
+ pdf.move_cursor_to(current_y)
77
+ end
78
+
79
+ # Underline
80
+ #
81
+ # @param x1 [Float] left position of the line
82
+ # @param x2 [Float] right position of the line
83
+ # @param y [Float] vertical position of the line
84
+ def underline(x1:, x2:, y:)
85
+ pdf.stroke do
86
+ pdf.line [x1, y], [x2, y]
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ attr_reader :pdf
93
+ end
94
+ end