sablon 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +12 -0
- data/lib/sablon/document_object_model/relationships.rb +4 -1
- data/lib/sablon/operations.rb +50 -8
- data/lib/sablon/parser/mail_merge.rb +33 -2
- data/lib/sablon/processor/document.rb +67 -191
- data/lib/sablon/processor/document/blocks.rb +134 -0
- data/lib/sablon/processor/document/field_handlers.rb +117 -0
- data/lib/sablon/processor/document/operation_construction.rb +71 -0
- data/lib/sablon/version.rb +1 -1
- data/test/custom_field_handler_test.rb +106 -0
- data/test/fixtures/conditionals_sample.docx +0 -0
- data/test/fixtures/conditionals_template.docx +0 -0
- data/test/fixtures/custom_field_handlers_sample.docx +0 -0
- data/test/fixtures/custom_field_handlers_template.docx +0 -0
- data/test/fixtures/xml/conditional_inline_with_elsif_else_clauses.xml +40 -0
- data/test/fixtures/xml/conditional_with_elsif_else_clauses.xml +41 -0
- data/test/processor/document_test.rb +174 -0
- data/test/sablon_test.rb +13 -3
- metadata +16 -3
@@ -0,0 +1,134 @@
|
|
1
|
+
module Sablon
|
2
|
+
module Processor
|
3
|
+
class Document
|
4
|
+
class Block
|
5
|
+
attr_accessor :start_field, :end_field
|
6
|
+
|
7
|
+
def self.enclosed_by(start_field, end_field)
|
8
|
+
@blocks ||= [ImageBlock, RowBlock, ParagraphBlock, InlineParagraphBlock]
|
9
|
+
block_class = @blocks.detect { |klass| klass.encloses?(start_field, end_field) }
|
10
|
+
block_class.new start_field, end_field
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.encloses?(start_field, end_field)
|
14
|
+
parent(start_field) && parent(end_field)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.parent(node)
|
18
|
+
node.ancestors(parent_selector).first
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.parent_selector
|
22
|
+
'.//w:p'
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(start_field, end_field)
|
26
|
+
@start_field = start_field
|
27
|
+
@end_field = end_field
|
28
|
+
|
29
|
+
# update reference counts for control fields
|
30
|
+
@start_field.block_reference_count += 1
|
31
|
+
@end_field.block_reference_count += 1
|
32
|
+
end
|
33
|
+
|
34
|
+
def process(env)
|
35
|
+
replaced_node = Nokogiri::XML::Node.new("tmp", start_node.document)
|
36
|
+
replaced_node.children = Nokogiri::XML::NodeSet.new(start_node.document, body.map(&:dup))
|
37
|
+
Processor::Document.process replaced_node, env
|
38
|
+
replaced_node.children
|
39
|
+
end
|
40
|
+
|
41
|
+
def replace(content)
|
42
|
+
content.each { |n| start_node.add_next_sibling n }
|
43
|
+
remove_control_elements
|
44
|
+
end
|
45
|
+
|
46
|
+
def remove_control_elements
|
47
|
+
body.each(&:remove)
|
48
|
+
# we only want to remove the start and end nodes if they belong
|
49
|
+
# to a single block.
|
50
|
+
start_field.remove_parent(self.class.parent_selector)
|
51
|
+
end_field.remove_parent(self.class.parent_selector)
|
52
|
+
end
|
53
|
+
|
54
|
+
def body
|
55
|
+
return @body if defined?(@body)
|
56
|
+
@body = []
|
57
|
+
node = start_node
|
58
|
+
while (node = node.next_element) && node != end_node
|
59
|
+
@body << node
|
60
|
+
end
|
61
|
+
@body
|
62
|
+
end
|
63
|
+
|
64
|
+
def start_node
|
65
|
+
@start_node ||= self.class.parent(start_field)
|
66
|
+
end
|
67
|
+
|
68
|
+
def end_node
|
69
|
+
@end_node ||= self.class.parent(end_field)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class RowBlock < Block
|
74
|
+
def self.parent_selector
|
75
|
+
'.//w:tr'
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.encloses?(start_field, end_field)
|
79
|
+
super && parent(start_field) != parent(end_field)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
class ParagraphBlock < Block
|
84
|
+
def self.encloses?(start_field, end_field)
|
85
|
+
super && parent(start_field) != parent(end_field)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
class ImageBlock < ParagraphBlock
|
90
|
+
def self.encloses?(start_field, end_field)
|
91
|
+
start_field.expression.start_with?('@')
|
92
|
+
end
|
93
|
+
|
94
|
+
def replace(image)
|
95
|
+
# we need to include the start and end nodes incase the image is
|
96
|
+
# inline with the merge fields
|
97
|
+
nodes = [start_node] + body + [end_node]
|
98
|
+
#
|
99
|
+
if image
|
100
|
+
nodes.each do |node|
|
101
|
+
pic_prop = node.at_xpath('.//pic:cNvPr', pic: 'http://schemas.openxmlformats.org/drawingml/2006/picture')
|
102
|
+
pic_prop.attributes['name'].value = image.name if pic_prop
|
103
|
+
blip = node.at_xpath('.//a:blip', a: 'http://schemas.openxmlformats.org/drawingml/2006/main')
|
104
|
+
blip.attributes['embed'].value = image.local_rid if blip
|
105
|
+
end
|
106
|
+
end
|
107
|
+
#
|
108
|
+
start_field.remove
|
109
|
+
end_field.remove
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
class InlineParagraphBlock < Block
|
114
|
+
def self.encloses?(start_field, end_field)
|
115
|
+
super && parent(start_field) == parent(end_field)
|
116
|
+
end
|
117
|
+
|
118
|
+
def remove_control_elements
|
119
|
+
body.each(&:remove)
|
120
|
+
start_field.remove
|
121
|
+
end_field.remove
|
122
|
+
end
|
123
|
+
|
124
|
+
def start_node
|
125
|
+
@start_node ||= start_field.end_node
|
126
|
+
end
|
127
|
+
|
128
|
+
def end_node
|
129
|
+
@end_node ||= end_field.start_node
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
module Sablon
|
2
|
+
module Processor
|
3
|
+
class Document
|
4
|
+
# This class is used to setup field handlers to process different
|
5
|
+
# merge field expressions based on the expression text. The #handles?
|
6
|
+
# and #build_statement methods form the standard FieldHandler API and can
|
7
|
+
# be implemented however they are needed to be as long as the call
|
8
|
+
# signature stays the same.
|
9
|
+
class FieldHandler
|
10
|
+
# Used when registering processors. The pattern tells the handler
|
11
|
+
# what expression text to search for.
|
12
|
+
def initialize(pattern)
|
13
|
+
@pattern = pattern
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns a non-nil value if the field expression matches the pattern
|
17
|
+
def handles?(field)
|
18
|
+
field.expression.match(@pattern)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Uses the provided arguments to construct a Statement object.
|
22
|
+
# The constructor is an instance of the OperationConstruction class,
|
23
|
+
# the field is the current merge field being evaluated and the options
|
24
|
+
# hash defines any other parameters passed in during
|
25
|
+
# OperationConstruction#consume. Currently the only option passed is
|
26
|
+
# `:allow_insertion`.
|
27
|
+
def build_statement(constructor, field, options = {}); end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Handles simple text insertion
|
31
|
+
class InsertionHandler < FieldHandler
|
32
|
+
def initialize
|
33
|
+
super(/^=/)
|
34
|
+
end
|
35
|
+
|
36
|
+
def build_statement(_constructor, field, options = {})
|
37
|
+
return unless options[:allow_insertion]
|
38
|
+
#
|
39
|
+
expr = Expression.parse(field.expression.gsub(/^=/, ''))
|
40
|
+
Statement::Insertion.new(expr, field)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Handles each loops in the template
|
45
|
+
class EachLoopHandler < FieldHandler
|
46
|
+
def initialize
|
47
|
+
super(/([^ ]+):each\(([^ ]+)\)/)
|
48
|
+
end
|
49
|
+
|
50
|
+
def build_statement(constructor, field, _options = {})
|
51
|
+
expr_name, item_name = field.expression.match(@pattern).to_a[1..2]
|
52
|
+
block = constructor.consume_block("#{expr_name}:endEach")
|
53
|
+
Statement::Loop.new(Expression.parse(expr_name), item_name, block)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Handles conditional blocks in the template
|
58
|
+
class ConditionalHandler < FieldHandler
|
59
|
+
def initialize
|
60
|
+
super(/([^ ]+):if(?:\(([^)]+)\))?/)
|
61
|
+
end
|
62
|
+
|
63
|
+
def build_statement(constructor, field, _options = {})
|
64
|
+
expr_name = field.expression.match(@pattern).to_a[1]
|
65
|
+
args = [
|
66
|
+
# end expression (first arg)
|
67
|
+
"#{expr_name}:endIf",
|
68
|
+
# sub block patterns to check for
|
69
|
+
/(#{expr_name}):els[iI]f(?:\(([^)]+)\))?/,
|
70
|
+
/(#{expr_name}):else/
|
71
|
+
]
|
72
|
+
blocks = process_blocks(constructor.consume_multi_block(*args))
|
73
|
+
Statement::Condition.new(blocks)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# Processes the main expression from each start field block to
|
79
|
+
# simplify usage in Statement
|
80
|
+
def process_blocks(blocks)
|
81
|
+
blocks.map do |block|
|
82
|
+
pattern = /([^ ]+):(?:if|els[iI]f|else)(?:\(([^)]+)\))?/
|
83
|
+
expr, pred = block.start_field.expression.match(pattern).to_a[1..2]
|
84
|
+
expr = Expression.parse(expr)
|
85
|
+
#
|
86
|
+
{ condition_expr: expr, predicate: pred, block: block }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Handles image insertion fields
|
92
|
+
class ImageHandler < FieldHandler
|
93
|
+
def initialize
|
94
|
+
super(/^@([^ ]+):start/)
|
95
|
+
end
|
96
|
+
|
97
|
+
def build_statement(constructor, field, _options = {})
|
98
|
+
expr_name = field.expression.match(@pattern).to_a[1]
|
99
|
+
block = constructor.consume_block("@#{expr_name}:end")
|
100
|
+
Statement::Image.new(Expression.parse(expr_name), block)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Handles comment blocks in the template
|
105
|
+
class CommentHandler < FieldHandler
|
106
|
+
def initialize
|
107
|
+
super(/^comment$/)
|
108
|
+
end
|
109
|
+
|
110
|
+
def build_statement(constructor, _field, _options = {})
|
111
|
+
block = constructor.consume_block('endComment')
|
112
|
+
Statement::Comment.new(block)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module Sablon
|
2
|
+
module Processor
|
3
|
+
class Document
|
4
|
+
class OperationConstruction
|
5
|
+
def initialize(fields, field_handlers, default_handler)
|
6
|
+
@fields = fields
|
7
|
+
@field_handlers = field_handlers
|
8
|
+
@default_handler = default_handler
|
9
|
+
@operations = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def operations
|
13
|
+
@operations << consume(true) while @fields.any?
|
14
|
+
@operations.compact
|
15
|
+
end
|
16
|
+
|
17
|
+
def consume(allow_insertion)
|
18
|
+
return unless (@field = @fields.shift)
|
19
|
+
#
|
20
|
+
# step over provided handlers to see if any can process the field
|
21
|
+
handler = @field_handlers.detect(proc { @default_handler }) do |fh|
|
22
|
+
fh.handles?(@field)
|
23
|
+
end
|
24
|
+
return if handler.nil?
|
25
|
+
#
|
26
|
+
# process and return
|
27
|
+
handler.build_statement(self, @field, allow_insertion: allow_insertion)
|
28
|
+
end
|
29
|
+
|
30
|
+
def consume_block(end_expression)
|
31
|
+
start_field = end_field = @field
|
32
|
+
while end_field && end_field.expression != end_expression
|
33
|
+
consume(false)
|
34
|
+
end_field = @field
|
35
|
+
end
|
36
|
+
|
37
|
+
unless end_field
|
38
|
+
raise TemplateError, "Could not find end field for «#{start_field.expression}». Was looking for «#{end_expression}»"
|
39
|
+
end
|
40
|
+
Block.enclosed_by(start_field, end_field) if end_field
|
41
|
+
end
|
42
|
+
|
43
|
+
# Creates multiple blocks based on the sub expression patterns supplied
|
44
|
+
# while searching for the end expresion. The start and end fields
|
45
|
+
# of adjacent blocks are shared. For example in an if-else-endif
|
46
|
+
# block the else field is the end for the if clause block and the
|
47
|
+
# start of the else clause block.
|
48
|
+
def consume_multi_block(end_expression, *sub_expr_patterns)
|
49
|
+
start_field = end_field = @field
|
50
|
+
blocks = []
|
51
|
+
while end_field && end_field.expression != end_expression
|
52
|
+
consume(false)
|
53
|
+
break unless (end_field = @field)
|
54
|
+
if sub_expr_patterns.any? { |pat| end_field.expression =~ pat }
|
55
|
+
blocks << Block.enclosed_by(start_field, end_field)
|
56
|
+
start_field = end_field
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# raise error if no final end field
|
61
|
+
unless end_field
|
62
|
+
raise TemplateError, "Could not find end field for «#{start_field.expression}». Was looking for «#{end_expression}»"
|
63
|
+
end
|
64
|
+
|
65
|
+
# add final block and return
|
66
|
+
blocks << Block.enclosed_by(start_field, end_field)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/sablon/version.rb
CHANGED
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class SablonCustomFieldHandlerTest < Sablon::TestCase
|
4
|
+
#
|
5
|
+
# This class supports more advanced conditional expressions and crafted
|
6
|
+
# by @moritzgloeckl in PR #73
|
7
|
+
class OperatorCondition < Sablon::Statement::Condition
|
8
|
+
def eval_conditional_blocks(env)
|
9
|
+
#
|
10
|
+
# evaluate each expression until a true one is found, false blocks
|
11
|
+
# are cleared from the document.
|
12
|
+
until @conditions.empty?
|
13
|
+
condition = @conditions.shift
|
14
|
+
conditon_expr = condition[:condition_expr]
|
15
|
+
predicate = condition[:predicate]
|
16
|
+
block = condition[:block]
|
17
|
+
#
|
18
|
+
# determine valeu of conditional expression + predicate
|
19
|
+
value = eval_condition_expr(conditon_expr, predicate, env.context)
|
20
|
+
#
|
21
|
+
# manipulate block based on truthy-ness of value
|
22
|
+
if truthy?(value)
|
23
|
+
block.replace(block.process(env).reverse)
|
24
|
+
break true
|
25
|
+
else
|
26
|
+
block.replace([])
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def eval_condition_expr(conditon_expr, predicate, context)
|
32
|
+
value = conditon_expr.evaluate(context)
|
33
|
+
#
|
34
|
+
if predicate.to_s =~ /^[!=]=/
|
35
|
+
operator = predicate[0..1]
|
36
|
+
cmpr_val = predicate[2..-1].tr("'", '')
|
37
|
+
compare_values(value.to_s, cmpr_val, operator)
|
38
|
+
elsif predicate
|
39
|
+
value.public_send(predicate)
|
40
|
+
else
|
41
|
+
value
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def compare_values(value_a, value_b, operator)
|
46
|
+
case operator
|
47
|
+
when '!='
|
48
|
+
value_a != value_b
|
49
|
+
when '=='
|
50
|
+
value_a == value_b
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Handles conditional blocks in the template that use an operator
|
56
|
+
class OperatorConditionalHandler < Sablon::Processor::Document::ConditionalHandler
|
57
|
+
def build_statement(constructor, field, _options = {})
|
58
|
+
expr_name = field.expression.match(@pattern).to_a[1]
|
59
|
+
args = [
|
60
|
+
# end expression (first arg)
|
61
|
+
"#{expr_name}:endIf",
|
62
|
+
# sub block patterns to check for
|
63
|
+
/(#{expr_name}):els[iI]f(?:\(([^)]+)\))?/,
|
64
|
+
/(#{expr_name}):else/
|
65
|
+
]
|
66
|
+
blocks = process_blocks(constructor.consume_multi_block(*args))
|
67
|
+
OperatorCondition.new(blocks)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def setup
|
72
|
+
super
|
73
|
+
@base_path = Pathname.new(File.expand_path('../', __FILE__))
|
74
|
+
@template_path = @base_path + 'fixtures/custom_field_handlers_template.docx'
|
75
|
+
@output_path = @base_path + 'sandbox/custom_field_handlers.docx'
|
76
|
+
@sample_path = @base_path + 'fixtures/custom_field_handlers_sample.docx'
|
77
|
+
#
|
78
|
+
# register new handlers to allow insertion without equals sign and
|
79
|
+
# advanced conditionals
|
80
|
+
klass = Sablon::Processor::Document
|
81
|
+
@orig_conditional_handler = klass.remove_field_handler :conditional
|
82
|
+
klass.register_field_handler :default, klass.field_handlers[:insertion]
|
83
|
+
klass.register_field_handler :conditional, OperatorConditionalHandler.new
|
84
|
+
end
|
85
|
+
|
86
|
+
def teardown
|
87
|
+
# remove extra handlers
|
88
|
+
Sablon::Processor::Document.remove_field_handler :default
|
89
|
+
Sablon::Processor::Document.replace_field_handler :conditional, @orig_conditional_handler
|
90
|
+
end
|
91
|
+
|
92
|
+
def test_generate_document_from_template
|
93
|
+
template = Sablon.template @template_path
|
94
|
+
context = {
|
95
|
+
normal_field: 'success1',
|
96
|
+
no_leading_equals: 'success2',
|
97
|
+
inside_if_no_op: 'success3',
|
98
|
+
no_operator: OpenStruct.new(test: 'success4'),
|
99
|
+
equals_operator: 'test',
|
100
|
+
inside_if_equals_op: 'success5'
|
101
|
+
}
|
102
|
+
#
|
103
|
+
template.render_to_file @output_path, context
|
104
|
+
assert_docx_equal @sample_path, @output_path
|
105
|
+
end
|
106
|
+
end
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,40 @@
|
|
1
|
+
<w:p>
|
2
|
+
<w:r><w:t>Before</w:t></w:r>
|
3
|
+
<w:r><w:t xml:space="preserve"> </w:t></w:r>
|
4
|
+
<w:fldSimple w:instr=" MERGEFIELD object:if(method_a) \* MERGEFORMAT ">
|
5
|
+
<w:r>
|
6
|
+
<w:rPr><w:noProof/></w:rPr>
|
7
|
+
<w:t>«object:if(method_a)»</w:t>
|
8
|
+
</w:r>
|
9
|
+
</w:fldSimple>
|
10
|
+
<w:r>
|
11
|
+
<w:t>Method A was true</w:t>
|
12
|
+
</w:r>
|
13
|
+
<w:fldSimple w:instr=" MERGEFIELD object:elsif(method_b) \* MERGEFORMAT ">
|
14
|
+
<w:r>
|
15
|
+
<w:rPr><w:noProof/></w:rPr>
|
16
|
+
<w:t>«object:elsif(method_b)»</w:t>
|
17
|
+
</w:r>
|
18
|
+
</w:fldSimple>
|
19
|
+
<w:r>
|
20
|
+
<w:t>Method B was true</w:t>
|
21
|
+
</w:r>
|
22
|
+
<w:fldSimple w:instr=" MERGEFIELD object:else \* MERGEFORMAT ">
|
23
|
+
<w:r>
|
24
|
+
<w:rPr><w:noProof/></w:rPr>
|
25
|
+
<w:t>«object:else»</w:t>
|
26
|
+
</w:r>
|
27
|
+
</w:fldSimple>
|
28
|
+
<w:r>
|
29
|
+
<w:t>Method A and B were false</w:t>
|
30
|
+
</w:r>
|
31
|
+
<w:fldSimple w:instr=" MERGEFIELD object:endIf \* MERGEFORMAT ">
|
32
|
+
<w:r>
|
33
|
+
<w:rPr><w:noProof/></w:rPr>
|
34
|
+
<w:t>object:endIf»</w:t>
|
35
|
+
</w:r>
|
36
|
+
</w:fldSimple>
|
37
|
+
<w:r>
|
38
|
+
<w:t>After</w:t>
|
39
|
+
</w:r>
|
40
|
+
</w:p>
|