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