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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3e4309c45c7d9eb7360f7acb59f0ec625d2c0eef
|
4
|
+
data.tar.gz: 20b6e2f1917f5af629078ec07d507058392ad88e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c37fe0cea7659f36f3498667bb9ea984308112306fb074b6ce9b771674423c52672ac81943628c48673fb80009d6a830c1cb12bbc11e90cd079ca38a9ca816f2
|
7
|
+
data.tar.gz: a4040805a95867d75573b0b7d2ae705e673b4c9a588793c2eaa05dbd563ff7585006a12a1e81fcf549e3f3939ca3c17e2639a77d7c0836d3e8a43389aa8fd89e
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -295,6 +295,18 @@ For more complex conditionals you can use a predicate like so:
|
|
295
295
|
«body:endIf»
|
296
296
|
```
|
297
297
|
|
298
|
+
Finally, you can also mix in `elsif` and `else` clauses as well.
|
299
|
+
|
300
|
+
```
|
301
|
+
«body:if(present?)»
|
302
|
+
... arbitrary document markup ...
|
303
|
+
«body:elsif(nil?)»
|
304
|
+
... arbitrary document markup ...
|
305
|
+
[additional elsif blocks...]
|
306
|
+
«body:else»
|
307
|
+
... arbitrary document markup ...
|
308
|
+
«body:endIf»
|
309
|
+
```
|
298
310
|
|
299
311
|
#### Loops
|
300
312
|
|
@@ -24,7 +24,10 @@ module Sablon
|
|
24
24
|
# existing file
|
25
25
|
define_method(:add_media) do |name, data, rel_attr|
|
26
26
|
rel_attr[:Target] = "media/#{name}"
|
27
|
-
|
27
|
+
# This matches any characters after the last "." in the filename
|
28
|
+
unless (extension = name.match(/.+\.(.+?$)/).to_a[1])
|
29
|
+
raise ArgumentError, "Filename: '#{name}' has no discernable extension"
|
30
|
+
end
|
28
31
|
type = rel_attr[:Type].match(%r{/(\w+?)$}).to_a[1] + "/#{extension}"
|
29
32
|
#
|
30
33
|
if @zip_contents["word/#{rel_attr[:Target]}"]
|
data/lib/sablon/operations.rb
CHANGED
@@ -53,22 +53,64 @@ module Sablon
|
|
53
53
|
end
|
54
54
|
end
|
55
55
|
|
56
|
-
class Condition
|
56
|
+
class Condition
|
57
|
+
def initialize(conditions)
|
58
|
+
@conditions = conditions
|
59
|
+
@else_block = nil
|
60
|
+
return unless @conditions.last[:block].start_field.expression =~ /:else/
|
61
|
+
#
|
62
|
+
# store the else block separately because it is always "true"
|
63
|
+
@else_block = @conditions.pop[:block]
|
64
|
+
end
|
65
|
+
|
57
66
|
def evaluate(env)
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
67
|
+
#
|
68
|
+
# process conditional blocks, if and elsif(s)
|
69
|
+
any_true = eval_conditional_blocks(env)
|
70
|
+
#
|
71
|
+
# clear the blocks for any remaining conditions
|
72
|
+
@conditions.map { |cond| cond[:block].replace([]) }
|
73
|
+
return unless @else_block
|
74
|
+
#
|
75
|
+
# apply the else clause if none of the conditions were true
|
76
|
+
if any_true
|
77
|
+
@else_block.replace([])
|
78
|
+
elsif @else_block
|
79
|
+
@else_block.replace(@else_block.process(env).reverse)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def eval_conditional_blocks(env)
|
86
|
+
#
|
87
|
+
# evaluate each expression until a true one is found, false blocks
|
88
|
+
# are cleared from the document.
|
89
|
+
until @conditions.empty?
|
90
|
+
condition = @conditions.shift
|
91
|
+
conditon_expr = condition[:condition_expr]
|
92
|
+
predicate = condition[:predicate]
|
93
|
+
block = condition[:block]
|
94
|
+
#
|
95
|
+
# fetch value optionally calling a predicate method
|
96
|
+
value = conditon_expr.evaluate(env.context)
|
97
|
+
value = value.public_send(predicate) if predicate
|
98
|
+
#
|
99
|
+
if truthy?(value)
|
100
|
+
block.replace(block.process(env).reverse)
|
101
|
+
break true
|
102
|
+
else
|
103
|
+
block.replace([])
|
104
|
+
end
|
63
105
|
end
|
64
106
|
end
|
65
107
|
|
66
108
|
def truthy?(value)
|
67
109
|
case value
|
68
|
-
when Array
|
110
|
+
when Array
|
69
111
|
!value.empty?
|
70
112
|
else
|
71
|
-
|
113
|
+
value ? true : false
|
72
114
|
end
|
73
115
|
end
|
74
116
|
end
|
@@ -2,8 +2,13 @@ module Sablon
|
|
2
2
|
module Parser
|
3
3
|
class MailMerge
|
4
4
|
class MergeField
|
5
|
+
attr_accessor :block_reference_count
|
5
6
|
KEY_PATTERN = /^\s*MERGEFIELD\s+([^ ]+)\s+\\\*\s+MERGEFORMAT\s*$/
|
6
7
|
|
8
|
+
def initialize
|
9
|
+
@block_reference_count = 0
|
10
|
+
end
|
11
|
+
|
7
12
|
def valid?
|
8
13
|
expression
|
9
14
|
end
|
@@ -14,6 +19,16 @@ module Sablon
|
|
14
19
|
|
15
20
|
private
|
16
21
|
|
22
|
+
# removes all nodes associated with the merge field if the reference
|
23
|
+
# count is less than or equal to 1
|
24
|
+
def remove_or_decrement_ref(*nodes)
|
25
|
+
if @block_reference_count > 1
|
26
|
+
@block_reference_count -= 1
|
27
|
+
else
|
28
|
+
nodes.each(&:remove)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
17
32
|
def replace_field_display(node, content, env)
|
18
33
|
paragraph = node.ancestors(".//w:p").first
|
19
34
|
display_node = get_display_node(node)
|
@@ -28,6 +43,7 @@ module Sablon
|
|
28
43
|
|
29
44
|
class ComplexField < MergeField
|
30
45
|
def initialize(nodes)
|
46
|
+
super()
|
31
47
|
@nodes = nodes
|
32
48
|
@raw_expression = @nodes.flat_map {|n| n.search(".//w:instrText").map(&:content) }.join
|
33
49
|
end
|
@@ -41,8 +57,14 @@ module Sablon
|
|
41
57
|
(@nodes - [pattern_node]).each(&:remove)
|
42
58
|
end
|
43
59
|
|
60
|
+
# removes only the merge field in question
|
44
61
|
def remove
|
45
|
-
|
62
|
+
remove_or_decrement_ref(*@nodes)
|
63
|
+
end
|
64
|
+
|
65
|
+
def remove_parent(selector)
|
66
|
+
node = @nodes.first
|
67
|
+
remove_or_decrement_ref(node.ancestors(selector).first)
|
46
68
|
end
|
47
69
|
|
48
70
|
def ancestors(*args)
|
@@ -58,6 +80,7 @@ module Sablon
|
|
58
80
|
end
|
59
81
|
|
60
82
|
private
|
83
|
+
|
61
84
|
def pattern_node
|
62
85
|
separate_node.next_element
|
63
86
|
end
|
@@ -69,6 +92,7 @@ module Sablon
|
|
69
92
|
|
70
93
|
class SimpleField < MergeField
|
71
94
|
def initialize(node)
|
95
|
+
super()
|
72
96
|
@node = node
|
73
97
|
@raw_expression = @node["w:instr"]
|
74
98
|
end
|
@@ -79,8 +103,14 @@ module Sablon
|
|
79
103
|
@node.replace(@node.children)
|
80
104
|
end
|
81
105
|
|
106
|
+
# removes only the merge field in question
|
82
107
|
def remove
|
83
|
-
@node
|
108
|
+
remove_or_decrement_ref(@node)
|
109
|
+
end
|
110
|
+
|
111
|
+
# removes the parent node containing the merge field
|
112
|
+
def remove_parent(selector)
|
113
|
+
remove_or_decrement_ref(@node.ancestors(selector).first)
|
84
114
|
end
|
85
115
|
|
86
116
|
def ancestors(*args)
|
@@ -93,6 +123,7 @@ module Sablon
|
|
93
123
|
alias_method :end_node, :start_node
|
94
124
|
|
95
125
|
private
|
126
|
+
|
96
127
|
def remove_extra_runs!
|
97
128
|
@node.search(".//w:r")[1..-1].each(&:remove)
|
98
129
|
end
|
@@ -1,7 +1,61 @@
|
|
1
|
-
|
1
|
+
require 'sablon/processor/document/blocks'
|
2
|
+
require 'sablon/processor/document/field_handlers'
|
3
|
+
require 'sablon/processor/document/operation_construction'
|
4
|
+
|
2
5
|
module Sablon
|
3
6
|
module Processor
|
7
|
+
# This class manages processing of the XML portions of a word document
|
8
|
+
# that can contain mailmerge fields
|
4
9
|
class Document
|
10
|
+
class << self
|
11
|
+
# Adds a new handler to the OperationConstruction class. The handler
|
12
|
+
# passed in should be an instance of the Handler class or implement
|
13
|
+
# the same interface. Handlers cannot be replaced by this method,
|
14
|
+
# instead the `replace_field_handler` method should be used which
|
15
|
+
# internally removes the existing hander and registers the one passed
|
16
|
+
# in. The name 'default' is special and will be called if no other
|
17
|
+
# handlers can use the provided field.
|
18
|
+
def register_field_handler(name, handler)
|
19
|
+
name = name.to_sym
|
20
|
+
if field_handlers[name] || (name == :default && !default_field_handler.nil?)
|
21
|
+
msg = "Handler named: '#{name}' already exists. Use `replace_field_handler` instead."
|
22
|
+
raise ArgumentError, msg
|
23
|
+
end
|
24
|
+
#
|
25
|
+
if name == :default
|
26
|
+
@default_field_handler = handler
|
27
|
+
else
|
28
|
+
field_handlers[name] = handler
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Removes a handler from the hash and returns it
|
33
|
+
def remove_field_handler(name)
|
34
|
+
name = name.to_sym
|
35
|
+
if name == :default
|
36
|
+
handler = @default_field_handler
|
37
|
+
@default_field_handler = nil
|
38
|
+
handler
|
39
|
+
else
|
40
|
+
field_handlers.delete(name)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Replaces an existing handler
|
45
|
+
def replace_field_handler(name, handler)
|
46
|
+
remove_field_handler(name)
|
47
|
+
register_field_handler(name, handler)
|
48
|
+
end
|
49
|
+
|
50
|
+
def field_handlers
|
51
|
+
@field_handlers ||= {}
|
52
|
+
end
|
53
|
+
|
54
|
+
def default_field_handler
|
55
|
+
@default_field_handler ||= nil
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
5
59
|
def self.process(xml_node, env)
|
6
60
|
processor = new(parser)
|
7
61
|
processor.manipulate xml_node, env
|
@@ -27,7 +81,9 @@ module Sablon
|
|
27
81
|
private
|
28
82
|
|
29
83
|
def build_operations(fields)
|
30
|
-
OperationConstruction.new(fields
|
84
|
+
OperationConstruction.new(fields,
|
85
|
+
self.class.field_handlers.values,
|
86
|
+
self.class.default_field_handler).operations
|
31
87
|
end
|
32
88
|
|
33
89
|
def cleanup(xml_node)
|
@@ -35,199 +91,19 @@ module Sablon
|
|
35
91
|
end
|
36
92
|
|
37
93
|
def fill_empty_table_cells(xml_node)
|
38
|
-
|
39
|
-
|
94
|
+
selector = "//w:tc[count(*[name() = 'w:p'])=0 or not(*)]"
|
95
|
+
xml_node.xpath(selector).each do |blank_cell|
|
96
|
+
filler = Nokogiri::XML::Node.new('w:p', xml_node.document)
|
40
97
|
blank_cell.add_child filler
|
41
98
|
end
|
42
99
|
end
|
43
100
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
def process(env)
|
52
|
-
replaced_node = Nokogiri::XML::Node.new("tmp", start_node.document)
|
53
|
-
replaced_node.children = Nokogiri::XML::NodeSet.new(start_node.document, body.map(&:dup))
|
54
|
-
Processor::Document.process replaced_node, env
|
55
|
-
replaced_node.children
|
56
|
-
end
|
57
|
-
|
58
|
-
def replace(content)
|
59
|
-
content.each { |n| start_node.add_next_sibling n }
|
60
|
-
remove_control_elements
|
61
|
-
end
|
62
|
-
|
63
|
-
def remove_control_elements
|
64
|
-
body.each(&:remove)
|
65
|
-
start_node.remove
|
66
|
-
end_node.remove
|
67
|
-
end
|
68
|
-
|
69
|
-
def body
|
70
|
-
return @body if defined?(@body)
|
71
|
-
@body = []
|
72
|
-
node = start_node
|
73
|
-
while (node = node.next_element) && node != end_node
|
74
|
-
@body << node
|
75
|
-
end
|
76
|
-
@body
|
77
|
-
end
|
78
|
-
|
79
|
-
def start_node
|
80
|
-
@start_node ||= self.class.parent(start_field).first
|
81
|
-
end
|
82
|
-
|
83
|
-
def end_node
|
84
|
-
@end_node ||= self.class.parent(end_field).first
|
85
|
-
end
|
86
|
-
|
87
|
-
def self.encloses?(start_field, end_field)
|
88
|
-
parent(start_field).any? && parent(end_field).any?
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
class RowBlock < Block
|
93
|
-
def self.parent(node)
|
94
|
-
node.ancestors ".//w:tr"
|
95
|
-
end
|
96
|
-
|
97
|
-
def self.encloses?(start_field, end_field)
|
98
|
-
super && parent(start_field) != parent(end_field)
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
class ParagraphBlock < Block
|
103
|
-
def self.parent(node)
|
104
|
-
node.ancestors ".//w:p"
|
105
|
-
end
|
106
|
-
|
107
|
-
def self.encloses?(start_field, end_field)
|
108
|
-
super && parent(start_field) != parent(end_field)
|
109
|
-
end
|
110
|
-
end
|
111
|
-
|
112
|
-
class ImageBlock < ParagraphBlock
|
113
|
-
def self.parent(node)
|
114
|
-
node.ancestors(".//w:p").first
|
115
|
-
end
|
116
|
-
|
117
|
-
def self.encloses?(start_field, end_field)
|
118
|
-
start_field.expression.start_with?('@')
|
119
|
-
end
|
120
|
-
|
121
|
-
def replace(image)
|
122
|
-
#
|
123
|
-
if image
|
124
|
-
nodes_between_fields.each do |node|
|
125
|
-
pic_prop = node.at_xpath('.//pic:cNvPr', pic: 'http://schemas.openxmlformats.org/drawingml/2006/picture')
|
126
|
-
pic_prop.attributes['name'].value = image.name if pic_prop
|
127
|
-
blip = node.at_xpath('.//a:blip', a: 'http://schemas.openxmlformats.org/drawingml/2006/main')
|
128
|
-
blip.attributes['embed'].value = image.local_rid if blip
|
129
|
-
end
|
130
|
-
end
|
131
|
-
#
|
132
|
-
start_field.remove
|
133
|
-
end_field.remove
|
134
|
-
end
|
135
|
-
|
136
|
-
private
|
137
|
-
|
138
|
-
# Collects all nodes between the two nodes provided into an array.
|
139
|
-
# Each entry in the array should be a paragraph tag.
|
140
|
-
# https://stackoverflow.com/a/820776
|
141
|
-
def nodes_between_fields
|
142
|
-
first = self.class.parent(start_field)
|
143
|
-
last = self.class.parent(end_field)
|
144
|
-
#
|
145
|
-
result = [first]
|
146
|
-
until first == last
|
147
|
-
first = first.next
|
148
|
-
result << first
|
149
|
-
end
|
150
|
-
result
|
151
|
-
end
|
152
|
-
end
|
153
|
-
|
154
|
-
class InlineParagraphBlock < Block
|
155
|
-
def self.parent(node)
|
156
|
-
node.ancestors ".//w:p"
|
157
|
-
end
|
158
|
-
|
159
|
-
def remove_control_elements
|
160
|
-
body.each(&:remove)
|
161
|
-
start_field.remove
|
162
|
-
end_field.remove
|
163
|
-
end
|
164
|
-
|
165
|
-
def start_node
|
166
|
-
@start_node ||= start_field.end_node
|
167
|
-
end
|
168
|
-
|
169
|
-
def end_node
|
170
|
-
@end_node ||= end_field.start_node
|
171
|
-
end
|
172
|
-
|
173
|
-
def self.encloses?(start_field, end_field)
|
174
|
-
super && parent(start_field) == parent(end_field)
|
175
|
-
end
|
176
|
-
end
|
177
|
-
|
178
|
-
class OperationConstruction
|
179
|
-
def initialize(fields)
|
180
|
-
@fields = fields
|
181
|
-
@operations = []
|
182
|
-
end
|
183
|
-
|
184
|
-
def operations
|
185
|
-
while @fields.any?
|
186
|
-
@operations << consume(true)
|
187
|
-
end
|
188
|
-
@operations.compact
|
189
|
-
end
|
190
|
-
|
191
|
-
def consume(allow_insertion)
|
192
|
-
@field = @fields.shift
|
193
|
-
return unless @field
|
194
|
-
case @field.expression
|
195
|
-
when /^=/
|
196
|
-
if allow_insertion
|
197
|
-
Statement::Insertion.new(Expression.parse(@field.expression[1..-1]), @field)
|
198
|
-
end
|
199
|
-
when /([^ ]+):each\(([^ ]+)\)/
|
200
|
-
block = consume_block("#{$1}:endEach")
|
201
|
-
Statement::Loop.new(Expression.parse($1), $2, block)
|
202
|
-
when /([^ ]+):if\(([^)]+)\)/
|
203
|
-
block = consume_block("#{$1}:endIf")
|
204
|
-
Statement::Condition.new(Expression.parse($1), block, $2)
|
205
|
-
when /([^ ]+):if/
|
206
|
-
block = consume_block("#{$1}:endIf")
|
207
|
-
Statement::Condition.new(Expression.parse($1), block)
|
208
|
-
when /^@([^ ]+):start/
|
209
|
-
block = consume_block("@#{$1}:end")
|
210
|
-
Statement::Image.new(Expression.parse($1), block)
|
211
|
-
when /^comment$/
|
212
|
-
block = consume_block("endComment")
|
213
|
-
Statement::Comment.new(block)
|
214
|
-
end
|
215
|
-
end
|
216
|
-
|
217
|
-
def consume_block(end_expression)
|
218
|
-
start_field = end_field = @field
|
219
|
-
while end_field && end_field.expression != end_expression
|
220
|
-
consume(false)
|
221
|
-
end_field = @field
|
222
|
-
end
|
223
|
-
|
224
|
-
if end_field
|
225
|
-
Block.enclosed_by start_field, end_field
|
226
|
-
else
|
227
|
-
raise TemplateError, "Could not find end field for «#{start_field.expression}». Was looking for «#{end_expression}»"
|
228
|
-
end
|
229
|
-
end
|
230
|
-
end
|
101
|
+
# register "builtin" handlers
|
102
|
+
register_field_handler :insertion, InsertionHandler.new
|
103
|
+
register_field_handler :each_loop, EachLoopHandler.new
|
104
|
+
register_field_handler :conditional, ConditionalHandler.new
|
105
|
+
register_field_handler :image, ImageHandler.new
|
106
|
+
register_field_handler :comment, CommentHandler.new
|
231
107
|
end
|
232
108
|
end
|
233
109
|
end
|