sablon 0.2.1 → 0.3.0
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/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>
|