sablon 0.0.21 → 0.0.22
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +4 -3
- data/Gemfile.lock +9 -9
- data/README.md +120 -11
- data/lib/sablon.rb +7 -1
- data/lib/sablon/configuration/configuration.rb +165 -0
- data/lib/sablon/configuration/html_tag.rb +99 -0
- data/lib/sablon/content.rb +12 -9
- data/lib/sablon/context.rb +27 -20
- data/lib/sablon/environment.rb +31 -0
- data/lib/sablon/html/ast.rb +290 -75
- data/lib/sablon/html/ast_builder.rb +90 -0
- data/lib/sablon/html/converter.rb +3 -123
- data/lib/sablon/numbering.rb +0 -5
- data/lib/sablon/operations.rb +11 -11
- data/lib/sablon/parser/mail_merge.rb +7 -6
- data/lib/sablon/processor/document.rb +9 -9
- data/lib/sablon/processor/numbering.rb +4 -4
- data/lib/sablon/template.rb +5 -4
- data/lib/sablon/version.rb +1 -1
- data/sablon.gemspec +3 -3
- data/test/configuration_test.rb +122 -0
- data/test/content_test.rb +7 -6
- data/test/context_test.rb +11 -11
- data/test/environment_test.rb +27 -0
- data/test/expression_test.rb +2 -2
- data/test/fixtures/html/html_test_content.html +174 -0
- data/test/fixtures/html_sample.docx +0 -0
- data/test/fixtures/xml/comment_block_and_comment_as_key.xml +31 -0
- data/test/html/ast_builder_test.rb +65 -0
- data/test/html/ast_test.rb +117 -0
- data/test/html/converter_test.rb +386 -87
- data/test/html/node_properties_test.rb +113 -0
- data/test/html_test.rb +10 -10
- data/test/mail_merge_parser_test.rb +3 -2
- data/test/processor/document_test.rb +20 -2
- data/test/section_properties_test.rb +1 -1
- data/test/support/html_snippets.rb +9 -0
- data/test/test_helper.rb +0 -1
- 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
|
-
|
7
|
-
|
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
|
-
|
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
|
data/lib/sablon/numbering.rb
CHANGED
@@ -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
|
data/lib/sablon/operations.rb
CHANGED
@@ -2,9 +2,9 @@
|
|
2
2
|
module Sablon
|
3
3
|
module Statement
|
4
4
|
class Insertion < Struct.new(:expr, :field)
|
5
|
-
def evaluate(
|
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(
|
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
|
-
|
22
|
-
block.process(
|
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(
|
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(
|
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(
|
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
|
-
|
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,
|
5
|
+
def self.process(xml_node, env, properties = {})
|
6
6
|
processor = new(parser)
|
7
|
-
processor.manipulate xml_node,
|
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,
|
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
|
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(
|
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,
|
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
|
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
|
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
|
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
|
-
|
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
|
data/lib/sablon/template.rb
CHANGED
@@ -17,8 +17,9 @@ module Sablon
|
|
17
17
|
end
|
18
18
|
|
19
19
|
private
|
20
|
+
|
20
21
|
def render(context, properties = {})
|
21
|
-
Sablon::
|
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,
|
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,
|
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
|