sablon 0.0.21 → 0.0.22

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.
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