prawn-html 0.1.4 → 0.4.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.
@@ -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