sablon 0.0.21 → 0.0.22

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +4 -3
  3. data/Gemfile.lock +9 -9
  4. data/README.md +120 -11
  5. data/lib/sablon.rb +7 -1
  6. data/lib/sablon/configuration/configuration.rb +165 -0
  7. data/lib/sablon/configuration/html_tag.rb +99 -0
  8. data/lib/sablon/content.rb +12 -9
  9. data/lib/sablon/context.rb +27 -20
  10. data/lib/sablon/environment.rb +31 -0
  11. data/lib/sablon/html/ast.rb +290 -75
  12. data/lib/sablon/html/ast_builder.rb +90 -0
  13. data/lib/sablon/html/converter.rb +3 -123
  14. data/lib/sablon/numbering.rb +0 -5
  15. data/lib/sablon/operations.rb +11 -11
  16. data/lib/sablon/parser/mail_merge.rb +7 -6
  17. data/lib/sablon/processor/document.rb +9 -9
  18. data/lib/sablon/processor/numbering.rb +4 -4
  19. data/lib/sablon/template.rb +5 -4
  20. data/lib/sablon/version.rb +1 -1
  21. data/sablon.gemspec +3 -3
  22. data/test/configuration_test.rb +122 -0
  23. data/test/content_test.rb +7 -6
  24. data/test/context_test.rb +11 -11
  25. data/test/environment_test.rb +27 -0
  26. data/test/expression_test.rb +2 -2
  27. data/test/fixtures/html/html_test_content.html +174 -0
  28. data/test/fixtures/html_sample.docx +0 -0
  29. data/test/fixtures/xml/comment_block_and_comment_as_key.xml +31 -0
  30. data/test/html/ast_builder_test.rb +65 -0
  31. data/test/html/ast_test.rb +117 -0
  32. data/test/html/converter_test.rb +386 -87
  33. data/test/html/node_properties_test.rb +113 -0
  34. data/test/html_test.rb +10 -10
  35. data/test/mail_merge_parser_test.rb +3 -2
  36. data/test/processor/document_test.rb +20 -2
  37. data/test/section_properties_test.rb +1 -1
  38. data/test/support/html_snippets.rb +9 -0
  39. data/test/test_helper.rb +0 -1
  40. metadata +27 -7
@@ -0,0 +1,90 @@
1
+ module Sablon
2
+ class HTMLConverter
3
+ # Converts a nokogiri HTML fragment into an equivalent AST structure
4
+ class ASTBuilder
5
+ attr_reader :nodes
6
+
7
+ def self.html_to_ast(env, nodes, properties)
8
+ builder = new(env, nodes, properties)
9
+ builder.nodes
10
+ end
11
+
12
+ private
13
+
14
+ def initialize(env, nodes, properties)
15
+ @env = env
16
+ @nodes = process_nodes(nodes, properties).compact
17
+ end
18
+
19
+ # Loops over HTML nodes converting them to their configured AST class
20
+ def process_nodes(html_nodes, properties)
21
+ html_nodes.flat_map do |node|
22
+ # get tags from config
23
+ parent_tag = fetch_tag(node.parent.name) if node.parent.name
24
+ tag = fetch_tag(node.name)
25
+
26
+ # remove all text nodes if the tag doesn't accept them
27
+ node.search('./text()').remove if drop_text?(tag)
28
+
29
+ # check node hierarchy
30
+ validate_structure(parent_tag, tag)
31
+
32
+ # merge properties
33
+ local_props = merge_node_properties(node, tag, properties)
34
+ if tag.ast_class
35
+ tag.ast_class.new(@env, node, local_props)
36
+ else
37
+ process_nodes(node.children, local_props)
38
+ end
39
+ end
40
+ end
41
+
42
+ # retrieves a HTMLTag instance from the cpermitted_html_tags hash or
43
+ # raises an ArgumentError if the tag is not registered in the hash
44
+ def fetch_tag(tag_name)
45
+ tag_name = tag_name.to_sym
46
+ unless Sablon::Configuration.instance.permitted_html_tags[tag_name]
47
+ raise ArgumentError, "Don't know how to handle HTML tag: #{tag_name}"
48
+ end
49
+ Sablon::Configuration.instance.permitted_html_tags[tag_name]
50
+ end
51
+
52
+ # Checking that the current tag is an allowed child of the parent_tag.
53
+ # If the parent tag is nil then a block level tag is required.
54
+ def validate_structure(parent, child)
55
+ if parent.ast_class == Root && child.type == :inline
56
+ msg = "#{child.name} needs to be wrapped in a block level tag."
57
+ elsif parent && !parent.allowed_child?(child)
58
+ msg = "#{child.name} is not a valid child element of #{parent.name}."
59
+ else
60
+ return
61
+ end
62
+ raise ContextError, "Invalid HTML structure: #{msg}"
63
+ end
64
+
65
+ # If the node doesn't allow inline elements, or text specifically,
66
+ # drop all text nodes. This is largely meant to prevent whitespace
67
+ # between tags from rasing an invalid structure error. Although it
68
+ # will purge the node whether it contains nonblank characters or not.
69
+ def drop_text?(child)
70
+ text = fetch_tag(:text)
71
+ !child.allowed_child?(text)
72
+ end
73
+
74
+ # Merges node properties in a sppecifc
75
+ def merge_node_properties(node, tag, parent_properties)
76
+ # Process any styles, defined on the node into a hash
77
+ if node['style']
78
+ style_props = node['style'].split(';').map do |prop|
79
+ prop.split(':').map(&:strip)
80
+ end
81
+ style_props = Hash[style_props]
82
+ else
83
+ style_props = {}
84
+ end
85
+ # allow inline styles to override parent styles passed down
86
+ parent_properties.merge(tag.properties).merge(style_props)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -3,67 +3,8 @@ require "sablon/html/visitor"
3
3
 
4
4
  module Sablon
5
5
  class HTMLConverter
6
- class ASTBuilder
7
- Layer = Struct.new(:items, :ilvl)
8
-
9
- def initialize(nodes)
10
- @layers = [Layer.new(nodes, false)]
11
- @root = Root.new([])
12
- end
13
-
14
- def to_ast
15
- @root
16
- end
17
-
18
- def new_layer(ilvl: false)
19
- @layers.push Layer.new([], ilvl)
20
- end
21
-
22
- def next
23
- current_layer.items.shift
24
- end
25
-
26
- def push(node)
27
- @layers.last.items.push node
28
- end
29
-
30
- def push_all(nodes)
31
- nodes.each(&method(:push))
32
- end
33
-
34
- def done?
35
- !current_layer.items.any?
36
- end
37
-
38
- def nested?
39
- ilvl > 0
40
- end
41
-
42
- def ilvl
43
- @layers.select { |layer| layer.ilvl }.size - 1
44
- end
45
-
46
- def emit(node)
47
- @root.nodes << node
48
- end
49
-
50
- private
51
- def current_layer
52
- if @layers.any?
53
- last_layer = @layers.last
54
- if last_layer.items.any?
55
- last_layer
56
- else
57
- @layers.pop
58
- current_layer
59
- end
60
- else
61
- Layer.new([], false)
62
- end
63
- end
64
- end
65
-
66
- def process(input)
6
+ def process(input, env)
7
+ @env = env
67
8
  processed_ast(input).to_docx
68
9
  end
69
10
 
@@ -75,68 +16,7 @@ module Sablon
75
16
 
76
17
  def build_ast(input)
77
18
  doc = Nokogiri::HTML.fragment(input)
78
- @builder = ASTBuilder.new(doc.children)
79
-
80
- while !@builder.done?
81
- ast_next_paragraph
82
- end
83
- @builder.to_ast
84
- end
85
-
86
- private
87
- def ast_next_paragraph
88
- node = @builder.next
89
- if node.name == 'div'
90
- @builder.new_layer
91
- @builder.emit Paragraph.new('Normal', ast_text(node.children))
92
- elsif node.name == 'p'
93
- @builder.new_layer
94
- @builder.emit Paragraph.new('Paragraph', ast_text(node.children))
95
- elsif node.name =~ /h(\d+)/
96
- @builder.new_layer
97
- @builder.emit Paragraph.new("Heading#{$1}", ast_text(node.children))
98
- elsif node.name == 'ul'
99
- @builder.new_layer ilvl: true
100
- unless @builder.nested?
101
- @definition = Sablon::Numbering.instance.register('ListBullet')
102
- end
103
- @builder.push_all(node.children)
104
- elsif node.name == 'ol'
105
- @builder.new_layer ilvl: true
106
- unless @builder.nested?
107
- @definition = Sablon::Numbering.instance.register('ListNumber')
108
- end
109
- @builder.push_all(node.children)
110
- elsif node.name == 'li'
111
- @builder.new_layer
112
- @builder.emit ListParagraph.new(@definition.style, ast_text(node.children), @definition.numid, @builder.ilvl)
113
- elsif node.text?
114
- # SKIP?
115
- else
116
- raise ArgumentError, "Don't know how to handle node: #{node.inspect}"
117
- end
118
- end
119
-
120
- def ast_text(nodes, format: TextFormat.default)
121
- runs = nodes.flat_map do |node|
122
- if node.text?
123
- Text.new(node.text, format)
124
- elsif node.name == 'br'
125
- Newline.new
126
- elsif node.name == 'strong' || node.name == 'b'
127
- ast_text(node.children, format: format.with_bold).nodes
128
- elsif node.name == 'em' || node.name == 'i'
129
- ast_text(node.children, format: format.with_italic).nodes
130
- elsif node.name == 'u'
131
- ast_text(node.children, format: format.with_underline).nodes
132
- elsif ['ul', 'ol', 'p', 'div'].include?(node.name)
133
- @builder.push(node)
134
- nil
135
- else
136
- raise ArgumentError, "Don't know how to handle node: #{node.inspect}"
137
- end
138
- end
139
- Collection.new(runs.compact)
19
+ Root.new(@env, doc)
140
20
  end
141
21
  end
142
22
  end
@@ -1,6 +1,5 @@
1
1
  module Sablon
2
2
  class Numbering
3
- include Singleton
4
3
  attr_reader :definitions
5
4
 
6
5
  Definition = Struct.new(:numid, :style) do
@@ -10,10 +9,6 @@ module Sablon
10
9
  end
11
10
 
12
11
  def initialize
13
- reset!
14
- end
15
-
16
- def reset!
17
12
  @numid = 1000
18
13
  @definitions = []
19
14
  end
@@ -2,9 +2,9 @@
2
2
  module Sablon
3
3
  module Statement
4
4
  class Insertion < Struct.new(:expr, :field)
5
- def evaluate(context)
6
- if content = expr.evaluate(context)
7
- field.replace(Sablon::Content.wrap(content))
5
+ def evaluate(env)
6
+ if content = expr.evaluate(env.context)
7
+ field.replace(Sablon::Content.wrap(content), env)
8
8
  else
9
9
  field.remove
10
10
  end
@@ -12,24 +12,24 @@ module Sablon
12
12
  end
13
13
 
14
14
  class Loop < Struct.new(:list_expr, :iterator_name, :block)
15
- def evaluate(context)
16
- value = list_expr.evaluate(context)
15
+ def evaluate(env)
16
+ value = list_expr.evaluate(env.context)
17
17
  value = value.to_ary if value.respond_to?(:to_ary)
18
18
  raise ContextError, "The expression #{list_expr.inspect} should evaluate to an enumerable but was: #{value.inspect}" unless value.is_a?(Enumerable)
19
19
 
20
20
  content = value.flat_map do |item|
21
- iteration_context = context.merge(iterator_name => item)
22
- block.process(iteration_context)
21
+ iter_env = env.alter_context(iterator_name => item)
22
+ block.process(iter_env)
23
23
  end
24
24
  block.replace(content.reverse)
25
25
  end
26
26
  end
27
27
 
28
28
  class Condition < Struct.new(:conditon_expr, :block, :predicate)
29
- def evaluate(context)
30
- value = conditon_expr.evaluate(context)
29
+ def evaluate(env)
30
+ value = conditon_expr.evaluate(env.context)
31
31
  if truthy?(predicate ? value.public_send(predicate) : value)
32
- block.replace(block.process(context).reverse)
32
+ block.replace(block.process(env).reverse)
33
33
  else
34
34
  block.replace([])
35
35
  end
@@ -46,7 +46,7 @@ module Sablon
46
46
  end
47
47
 
48
48
  class Comment < Struct.new(:block)
49
- def evaluate(context)
49
+ def evaluate(_env)
50
50
  block.replace []
51
51
  end
52
52
  end
@@ -13,10 +13,11 @@ module Sablon
13
13
  end
14
14
 
15
15
  private
16
- def replace_field_display(node, content)
16
+
17
+ def replace_field_display(node, content, env)
17
18
  paragraph = node.ancestors(".//w:p").first
18
19
  display_node = get_display_node(node)
19
- content.append_to(paragraph, display_node)
20
+ content.append_to(paragraph, display_node, env)
20
21
  display_node.remove
21
22
  end
22
23
 
@@ -35,8 +36,8 @@ module Sablon
35
36
  separate_node && get_display_node(pattern_node) && expression
36
37
  end
37
38
 
38
- def replace(content)
39
- replace_field_display(pattern_node, content)
39
+ def replace(content, env)
40
+ replace_field_display(pattern_node, content, env)
40
41
  (@nodes - [pattern_node]).each(&:remove)
41
42
  end
42
43
 
@@ -72,9 +73,9 @@ module Sablon
72
73
  @raw_expression = @node["w:instr"]
73
74
  end
74
75
 
75
- def replace(content)
76
+ def replace(content, env)
76
77
  remove_extra_runs!
77
- replace_field_display(@node, content)
78
+ replace_field_display(@node, content, env)
78
79
  @node.replace(@node.children)
79
80
  end
80
81
 
@@ -2,9 +2,9 @@
2
2
  module Sablon
3
3
  module Processor
4
4
  class Document
5
- def self.process(xml_node, context, properties = {})
5
+ def self.process(xml_node, env, properties = {})
6
6
  processor = new(parser)
7
- processor.manipulate xml_node, Sablon::Context.transform(context)
7
+ processor.manipulate xml_node, env
8
8
  processor.write_properties xml_node, properties if properties.any?
9
9
  xml_node
10
10
  end
@@ -17,10 +17,10 @@ module Sablon
17
17
  @parser = parser
18
18
  end
19
19
 
20
- def manipulate(xml_node, context)
20
+ def manipulate(xml_node, env)
21
21
  operations = build_operations(@parser.parse_fields(xml_node))
22
22
  operations.each do |step|
23
- step.evaluate context
23
+ step.evaluate env
24
24
  end
25
25
  cleanup(xml_node)
26
26
  xml_node
@@ -56,10 +56,10 @@ module Sablon
56
56
  block_class.new start_field, end_field
57
57
  end
58
58
 
59
- def process(context)
59
+ def process(env)
60
60
  replaced_node = Nokogiri::XML::Node.new("tmp", start_node.document)
61
61
  replaced_node.children = Nokogiri::XML::NodeSet.new(start_node.document, body.map(&:dup))
62
- Processor::Document.process replaced_node, context
62
+ Processor::Document.process replaced_node, env
63
63
  replaced_node.children
64
64
  end
65
65
 
@@ -69,7 +69,7 @@ module Sablon
69
69
  end
70
70
 
71
71
  def remove_control_elements
72
- body.each &:remove
72
+ body.each(&:remove)
73
73
  start_node.remove
74
74
  end_node.remove
75
75
  end
@@ -123,7 +123,7 @@ module Sablon
123
123
  end
124
124
 
125
125
  def remove_control_elements
126
- body.each &:remove
126
+ body.each(&:remove)
127
127
  start_field.remove
128
128
  end_field.remove
129
129
  end
@@ -171,7 +171,7 @@ module Sablon
171
171
  when /([^ ]+):if/
172
172
  block = consume_block("#{$1}:endIf")
173
173
  Statement::Condition.new(Expression.parse($1), block)
174
- when /comment/
174
+ when /^comment$/
175
175
  block = consume_block("endComment")
176
176
  Statement::Comment.new(block)
177
177
  end
@@ -7,9 +7,9 @@ module Sablon
7
7
  </w:num>
8
8
  XML
9
9
 
10
- def self.process(doc)
10
+ def self.process(doc, env)
11
11
  processor = new(doc)
12
- processor.manipulate
12
+ processor.manipulate env
13
13
  doc
14
14
  end
15
15
 
@@ -17,8 +17,8 @@ module Sablon
17
17
  @doc = doc
18
18
  end
19
19
 
20
- def manipulate
21
- Sablon::Numbering.instance.definitions.each do |definition|
20
+ def manipulate(env)
21
+ env.numbering.definitions.each do |definition|
22
22
  abstract_num_ref = find_definition(definition.style)
23
23
  abstract_num_copy = abstract_num_ref.dup
24
24
  abstract_num_copy['w:abstractNumId'] = definition.numid
@@ -17,8 +17,9 @@ module Sablon
17
17
  end
18
18
 
19
19
  private
20
+
20
21
  def render(context, properties = {})
21
- Sablon::Numbering.instance.reset!
22
+ env = Sablon::Environment.new(self, context)
22
23
  Zip.sort_entries = true # required to process document.xml before numbering.xml
23
24
  Zip::OutputStream.write_buffer(StringIO.new) do |out|
24
25
  Zip::File.open(@path).each do |entry|
@@ -26,11 +27,11 @@ module Sablon
26
27
  out.put_next_entry(entry_name)
27
28
  content = entry.get_input_stream.read
28
29
  if entry_name == 'word/document.xml'
29
- out.write(process(Processor::Document, content, context, properties))
30
+ out.write(process(Processor::Document, content, env, properties))
30
31
  elsif entry_name =~ /word\/header\d*\.xml/ || entry_name =~ /word\/footer\d*\.xml/
31
- out.write(process(Processor::Document, content, context))
32
+ out.write(process(Processor::Document, content, env))
32
33
  elsif entry_name == 'word/numbering.xml'
33
- out.write(process(Processor::Numbering, content))
34
+ out.write(process(Processor::Numbering, content, env))
34
35
  else
35
36
  out.write(content)
36
37
  end