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