sablon 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,34 @@
1
+ module Sablon
2
+ class Processor
3
+ class SectionProperties
4
+ def self.from_document(document_xml)
5
+ new document_xml.at_xpath(".//w:sectPr")
6
+ end
7
+
8
+ def initialize(properties_node)
9
+ @properties_node = properties_node
10
+ end
11
+
12
+ def start_page_number
13
+ pg_num_type && pg_num_type["w:start"]
14
+ end
15
+
16
+ def start_page_number=(number)
17
+ find_or_add_pg_num_type["w:start"] = number
18
+ end
19
+
20
+ private
21
+ def find_or_add_pg_num_type
22
+ pg_num_type || begin
23
+ node = Nokogiri::XML::Node.new "w:pgNumType", @properties_node.document
24
+ @properties_node.children.after node
25
+ node
26
+ end
27
+ end
28
+
29
+ def pg_num_type
30
+ @pg_num_type ||= @properties_node.at_xpath(".//w:pgNumType")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ module Sablon
2
+ class Template
3
+ def initialize(path)
4
+ @path = path
5
+ end
6
+
7
+ # Same as +render_to_string+ but writes the processed template to +output_path+.
8
+ def render_to_file(output_path, context, properties = {})
9
+ File.open(output_path, 'w') do |f|
10
+ f.write render_to_string(context, properties)
11
+ end
12
+ end
13
+
14
+ # Process the template. The +context+ hash will be available in the template.
15
+ def render_to_string(context, properties = {})
16
+ render(context, properties).string
17
+ end
18
+
19
+ private
20
+ def render(context, properties = {})
21
+ Zip::OutputStream.write_buffer(StringIO.new) do |out|
22
+ Zip::File.open(@path).each do |entry|
23
+ entry_name = entry.name
24
+ out.put_next_entry(entry_name)
25
+ content = entry.get_input_stream.read
26
+ if entry_name == 'word/document.xml'
27
+ out.write(Processor.process(Nokogiri::XML(content), context, properties).to_xml)
28
+ else
29
+ out.write(content)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ module Sablon
2
+ VERSION = "0.0.1"
3
+ end
data/sablon.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'sablon/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "sablon"
8
+ spec.version = Sablon::VERSION
9
+ spec.authors = ["Yves Senn"]
10
+ spec.email = ["yves.senn@gmail.com"]
11
+ spec.summary = %q{docx tempalte processor}
12
+ spec.description = %q{Sablon is a document template processor. At this time it works only with docx and MailMerge fields.}
13
+ spec.homepage = "http://github.com/senny/sablon"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_runtime_dependency 'nokogiri'
22
+ spec.add_runtime_dependency 'rubyzip'
23
+
24
+ spec.add_development_dependency "bundler", ">= 1.6"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "minitest", "~> 5.4"
27
+ spec.add_development_dependency "xml-simple"
28
+ end
@@ -0,0 +1,15 @@
1
+ # -*- coding: utf-8 -*-
2
+ require "test_helper"
3
+
4
+ class ExpressionTest < Sablon::TestCase
5
+ def test_variable_expression
6
+ expr = Sablon::Expression.parse("first_name")
7
+ assert_equal "Jane", expr.evaluate({"first_name" => "Jane", "last_name" => "Doe"})
8
+ end
9
+
10
+ def test_simple_method_call
11
+ user = OpenStruct.new(first_name: "Jack")
12
+ expr = Sablon::Expression.parse("user.first_name")
13
+ assert_equal "Jack", expr.evaluate({"user" => user})
14
+ end
15
+ end
Binary file
Binary file
@@ -0,0 +1,131 @@
1
+ # -*- coding: utf-8 -*-
2
+ require "test_helper"
3
+ require "support/document_xml_helper"
4
+
5
+ module MailMergeParser
6
+ module SharedBehavior
7
+ include DocumentXMLHelper
8
+ def setup
9
+ super
10
+ @parser = Sablon::Parser::MailMerge.new
11
+ end
12
+
13
+ def fields
14
+ @document = xml
15
+ @parser.parse_fields(@document)
16
+ end
17
+
18
+ def body_xml
19
+ @document.search(".//w:body").children.map(&:to_xml).join.strip
20
+ end
21
+ end
22
+
23
+ class FldSimpleTest < Sablon::TestCase
24
+ include SharedBehavior
25
+
26
+ def test_recognizes_expression
27
+ assert_equal ["=first_name"], fields.map(&:expression)
28
+ end
29
+
30
+ def test_replace
31
+ field = fields.first
32
+ field.replace("Hello")
33
+ assert_equal <<-body_xml.strip, body_xml
34
+ <w:r w:rsidR=\"004B49F0\">
35
+ <w:rPr><w:noProof/></w:rPr>
36
+ <w:t>Hello</w:t>
37
+ </w:r>
38
+ body_xml
39
+ end
40
+
41
+ def test_replace_with_newlines
42
+ field = fields.first
43
+ field.replace("First\nSecond\n\nThird")
44
+
45
+ assert_equal <<-body_xml.strip, body_xml
46
+ <w:r w:rsidR=\"004B49F0\">
47
+ <w:rPr><w:noProof/></w:rPr>
48
+ <w:t>First</w:t><w:br/><w:t>Second</w:t><w:br/><w:br/><w:t>Third</w:t>
49
+ </w:r>
50
+ body_xml
51
+ end
52
+
53
+ private
54
+ def xml
55
+ wrap(<<-xml)
56
+ <w:fldSimple w:instr=" MERGEFIELD =first_name \\* MERGEFORMAT ">
57
+ <w:r w:rsidR="004B49F0">
58
+ <w:rPr><w:noProof/></w:rPr>
59
+ <w:t>«=first_name»</w:t>
60
+ </w:r>
61
+ </w:fldSimple>
62
+ xml
63
+ end
64
+ end
65
+
66
+ class FldCharTest < Sablon::TestCase
67
+ include SharedBehavior
68
+
69
+ def test_recognizes_expression
70
+ assert_equal ["=last_name"], fields.map(&:expression)
71
+ end
72
+
73
+ def test_replace
74
+ field = fields.first
75
+ field.replace("Hello")
76
+ assert_equal <<-body_xml.strip, body_xml
77
+ <w:r w:rsidR="004B49F0">
78
+ <w:rPr>
79
+ <w:b/>
80
+ <w:noProof/>
81
+ </w:rPr>
82
+ <w:t>Hello</w:t>
83
+ </w:r>
84
+ body_xml
85
+ end
86
+
87
+ def test_replace_with_newlines
88
+ field = fields.first
89
+ field.replace("First\nSecond\n\nThird")
90
+
91
+ assert_equal <<-body_xml.strip, body_xml
92
+ <w:r w:rsidR="004B49F0">
93
+ <w:rPr>
94
+ <w:b/>
95
+ <w:noProof/>
96
+ </w:rPr>
97
+ <w:t>First</w:t><w:br/><w:t>Second</w:t><w:br/><w:br/><w:t>Third</w:t>
98
+ </w:r>
99
+ body_xml
100
+ end
101
+
102
+ private
103
+ def xml
104
+ wrap(<<-xml)
105
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
106
+ <w:rPr><w:b/></w:rPr>
107
+ <w:fldChar w:fldCharType="begin"/>
108
+ </w:r>
109
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
110
+ <w:rPr><w:b/></w:rPr>
111
+ <w:instrText xml:space="preserve"> MERGEFIELD =last_name \\* MERGEFORMAT </w:instrText>
112
+ </w:r>
113
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
114
+ <w:rPr><w:b/></w:rPr>
115
+ <w:fldChar w:fldCharType="separate"/>
116
+ </w:r>
117
+ <w:r w:rsidR="004B49F0">
118
+ <w:rPr>
119
+ <w:b/>
120
+ <w:noProof/>
121
+ </w:rPr>
122
+ <w:t>«=last_name»</w:t>
123
+ </w:r>
124
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
125
+ <w:rPr><w:b/></w:rPr>
126
+ <w:fldChar w:fldCharType="end"/>
127
+ </w:r>
128
+ xml
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,697 @@
1
+ # -*- coding: utf-8 -*-
2
+ require "test_helper"
3
+ require "support/document_xml_helper"
4
+
5
+ class ProcessorTest < Sablon::TestCase
6
+ include DocumentXMLHelper
7
+
8
+ def setup
9
+ super
10
+ @processor = Sablon::Processor
11
+ end
12
+
13
+ def test_simple_field_replacement
14
+ result = process(<<-documentxml, {"first_name" => "Jack"})
15
+ <w:r><w:t xml:space="preserve">Hello! My Name is </w:t></w:r>
16
+ <w:fldSimple w:instr=" MERGEFIELD =first_name \\* MERGEFORMAT ">
17
+ <w:r w:rsidR="004B49F0">
18
+ <w:rPr><w:noProof/></w:rPr>
19
+ <w:t>«=first_name»</w:t>
20
+ </w:r>
21
+ </w:fldSimple>
22
+ <w:r w:rsidR="00BE47B1"><w:t xml:space="preserve">, nice to meet you.</w:t></w:r>
23
+ documentxml
24
+
25
+
26
+ assert_equal "Hello! My Name is Jack , nice to meet you.", text(result)
27
+ assert_xml_equal <<-document, result
28
+ <w:r><w:t xml:space="preserve">Hello! My Name is </w:t></w:r>
29
+ <w:r w:rsidR="004B49F0">
30
+ <w:rPr><w:noProof/></w:rPr>
31
+ <w:t>Jack</w:t>
32
+ </w:r>
33
+ <w:r w:rsidR="00BE47B1"><w:t xml:space="preserve">, nice to meet you.</w:t></w:r>
34
+ document
35
+ end
36
+
37
+ def test_complex_field_replacement
38
+ result = process(<<-documentxml, {"last_name" => "Zane"})
39
+ <w:r><w:t xml:space="preserve">Hello! My Name is </w:t></w:r>
40
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
41
+ <w:rPr>
42
+ <w:b/>
43
+ </w:rPr>
44
+ <w:fldChar w:fldCharType="begin"/>
45
+ </w:r>
46
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
47
+ <w:rPr>
48
+ <w:b/>
49
+ </w:rPr>
50
+ <w:instrText xml:space="preserve"> MERGEFIELD =last_name \\* MERGEFORMAT </w:instrText>
51
+ </w:r>
52
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
53
+ <w:rPr>
54
+ <w:b/>
55
+ </w:rPr>
56
+ <w:fldChar w:fldCharType="separate"/>
57
+ </w:r>
58
+ <w:r w:rsidR="004B49F0">
59
+ <w:rPr>
60
+ <w:b/>
61
+ <w:noProof/>
62
+ </w:rPr>
63
+ <w:t>«=last_name»</w:t>
64
+ </w:r>
65
+ <w:r w:rsidR="00BE47B1" w:rsidRPr="00BE47B1">
66
+ <w:rPr>
67
+ <w:b/>
68
+ </w:rPr>
69
+ <w:fldChar w:fldCharType="end"/>
70
+ </w:r>
71
+ <w:r w:rsidR="00BE47B1"><w:t xml:space="preserve">, nice to meet you.</w:t></w:r>
72
+ documentxml
73
+
74
+ assert_equal "Hello! My Name is Zane , nice to meet you.", text(result)
75
+ assert_xml_equal <<-document, result
76
+ <w:r><w:t xml:space="preserve">Hello! My Name is </w:t></w:r>
77
+ <w:r w:rsidR="004B49F0">
78
+ <w:rPr>
79
+ <w:b/>
80
+ <w:noProof/>
81
+ </w:rPr>
82
+ <w:t>Zane</w:t>
83
+ </w:r>
84
+ <w:r w:rsidR="00BE47B1"><w:t xml:space="preserve">, nice to meet you.</w:t></w:r>
85
+ document
86
+ end
87
+
88
+ def test_complex_field_replacement_with_split_field
89
+ result = process(<<-documentxml, {"first_name" => "Daniel"})
90
+ <w:r>
91
+ <w:t xml:space="preserve">Hello! My Name is </w:t>
92
+ </w:r>
93
+ <w:r w:rsidR="003C4780">
94
+ <w:fldChar w:fldCharType="begin" />
95
+ </w:r>
96
+ <w:r w:rsidR="003C4780">
97
+ <w:instrText xml:space="preserve"> MERGEFIELD </w:instrText>
98
+ </w:r>
99
+ <w:r w:rsidR="003A4504">
100
+ <w:instrText>=</w:instrText>
101
+ </w:r>
102
+ <w:r w:rsidR="003C4780">
103
+ <w:instrText xml:space="preserve">first_name \\* MERGEFORMAT </w:instrText>
104
+ </w:r>
105
+ <w:r w:rsidR="003C4780">
106
+ <w:fldChar w:fldCharType="separate" />
107
+ </w:r>
108
+ <w:r w:rsidR="00441382">
109
+ <w:rPr>
110
+ <w:noProof />
111
+ </w:rPr>
112
+ <w:t>«=person.first_name»</w:t>
113
+ </w:r>
114
+ <w:r w:rsidR="003C4780">
115
+ <w:fldChar w:fldCharType="end" />
116
+ </w:r>
117
+ <w:r w:rsidR="00BE47B1"><w:t xml:space="preserve">, nice to meet you.</w:t></w:r>
118
+ documentxml
119
+
120
+ assert_equal "Hello! My Name is Daniel , nice to meet you.", text(result)
121
+ assert_xml_equal <<-document, result
122
+ <w:r><w:t xml:space="preserve">Hello! My Name is </w:t></w:r>
123
+ <w:r w:rsidR="00441382">
124
+ <w:rPr>
125
+ <w:noProof/>
126
+ </w:rPr>
127
+ <w:t>Daniel</w:t>
128
+ </w:r>
129
+ <w:r w:rsidR="00BE47B1"><w:t xml:space="preserve">, nice to meet you.</w:t></w:r>
130
+ document
131
+ end
132
+
133
+ def test_paragraph_block_replacement
134
+ item = Struct.new(:index, :label, :rating)
135
+ result = process(<<-document, {"technologies" => ["Ruby", "Rails"]})
136
+ <w:p w14:paraId="6CB29D92" w14:textId="164B70F4" w:rsidR="007F5CDE" w:rsidRDefault="007F5CDE" w:rsidP="007F5CDE">
137
+ <w:pPr>
138
+ <w:pStyle w:val="ListParagraph" />
139
+ <w:numPr>
140
+ <w:ilvl w:val="0" />
141
+ <w:numId w:val="1" />
142
+ </w:numPr>
143
+ </w:pPr>
144
+ <w:fldSimple w:instr=" MERGEFIELD technologies:each(technology) \\* MERGEFORMAT ">
145
+ <w:r>
146
+ <w:rPr>
147
+ <w:noProof />
148
+ </w:rPr>
149
+ <w:t>«technologies:each(technology)»</w:t>
150
+ </w:r>
151
+ </w:fldSimple>
152
+ </w:p>
153
+ <w:p w14:paraId="1081E316" w14:textId="3EAB5FDC" w:rsidR="00380EE8" w:rsidRDefault="00380EE8" w:rsidP="007F5CDE">
154
+ <w:pPr>
155
+ <w:pStyle w:val="ListParagraph" />
156
+ <w:numPr>
157
+ <w:ilvl w:val="0" />
158
+ <w:numId w:val="1" />
159
+ </w:numPr>
160
+ </w:pPr>
161
+ <w:r>
162
+ <w:fldChar w:fldCharType="begin" />
163
+ </w:r>
164
+ <w:r>
165
+ <w:instrText xml:space="preserve"> </w:instrText>
166
+ </w:r>
167
+ <w:r w:rsidR="009F01DA">
168
+ <w:instrText>MERGEFIELD =technology</w:instrText>
169
+ </w:r>
170
+ <w:r>
171
+ <w:instrText xml:space="preserve"> \\* MERGEFORMAT </w:instrText>
172
+ </w:r>
173
+ <w:r>
174
+ <w:fldChar w:fldCharType="separate" />
175
+ </w:r>
176
+ <w:r w:rsidR="009F01DA">
177
+ <w:rPr>
178
+ <w:noProof />
179
+ </w:rPr>
180
+ <w:t>«=technology»</w:t>
181
+ </w:r>
182
+ <w:r>
183
+ <w:fldChar w:fldCharType="end" />
184
+ </w:r>
185
+ </w:p>
186
+ <w:p w14:paraId="7F936853" w14:textId="078377AD" w:rsidR="00380EE8" w:rsidRPr="007F5CDE" w:rsidRDefault="00380EE8" w:rsidP="007F5CDE">
187
+ <w:pPr>
188
+ <w:pStyle w:val="ListParagraph" />
189
+ <w:numPr>
190
+ <w:ilvl w:val="0" />
191
+ <w:numId w:val="1" />
192
+ </w:numPr>
193
+ </w:pPr>
194
+ <w:fldSimple w:instr=" MERGEFIELD technologies:endEach \\* MERGEFORMAT ">
195
+ <w:r>
196
+ <w:rPr>
197
+ <w:noProof />
198
+ </w:rPr>
199
+ <w:t>«technologies:endEach»</w:t>
200
+ </w:r>
201
+ </w:fldSimple>
202
+ </w:p>
203
+ document
204
+
205
+ assert_equal "Ruby Rails", text(result)
206
+ assert_xml_equal <<-document, result
207
+ <w:p w14:paraId="1081E316" w14:textId="3EAB5FDC" w:rsidR="00380EE8" w:rsidRDefault="00380EE8" w:rsidP="007F5CDE">
208
+ <w:pPr>
209
+ <w:pStyle w:val="ListParagraph"/>
210
+ <w:numPr>
211
+ <w:ilvl w:val="0"/>
212
+ <w:numId w:val="1"/>
213
+ </w:numPr>
214
+ </w:pPr>
215
+ <w:r w:rsidR="009F01DA">
216
+ <w:rPr>
217
+ <w:noProof/>
218
+ </w:rPr>
219
+ <w:t>Ruby</w:t>
220
+ </w:r>
221
+ </w:p><w:p w14:paraId="1081E316" w14:textId="3EAB5FDC" w:rsidR="00380EE8" w:rsidRDefault="00380EE8" w:rsidP="007F5CDE">
222
+ <w:pPr>
223
+ <w:pStyle w:val="ListParagraph"/>
224
+ <w:numPr>
225
+ <w:ilvl w:val="0"/>
226
+ <w:numId w:val="1"/>
227
+ </w:numPr>
228
+ </w:pPr>
229
+ <w:r w:rsidR="009F01DA">
230
+ <w:rPr>
231
+ <w:noProof/>
232
+ </w:rPr>
233
+ <w:t>Rails</w:t>
234
+ </w:r>
235
+ </w:p>
236
+ document
237
+ end
238
+
239
+ def test_single_row_table_loop
240
+ item = Struct.new(:index, :label, :rating)
241
+ result = process(<<-document, {"items" => [item.new("1.", "Milk", "***"), item.new("2.", "Sugar", "**")]})
242
+ <w:tbl>
243
+ <w:tblPr>
244
+ <w:tblStyle w:val="TableGrid"/>
245
+ <w:tblW w:w="0" w:type="auto"/>
246
+ <w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/>
247
+ </w:tblPr>
248
+ <w:tblGrid>
249
+ <w:gridCol w:w="2202"/>
250
+ <w:gridCol w:w="4285"/>
251
+ <w:gridCol w:w="2029"/>
252
+ </w:tblGrid>
253
+ <w:tr w:rsidR="00757DAD" w14:paraId="229B7A39" w14:textId="77777777" w:rsidTr="006333C3">
254
+ <w:tc>
255
+ <w:tcPr>
256
+ <w:tcW w:w="2202" w:type="dxa"/>
257
+ </w:tcPr>
258
+ <w:p w14:paraId="3D472BF1" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
259
+ <w:r>
260
+ <w:fldChar w:fldCharType="begin"/>
261
+ </w:r>
262
+ <w:r>
263
+ <w:instrText xml:space="preserve"> MERGEFIELD items:each(item) \\* MERGEFORMAT </w:instrText>
264
+ </w:r>
265
+ <w:r>
266
+ <w:fldChar w:fldCharType="separate"/>
267
+ </w:r>
268
+ <w:r>
269
+ <w:rPr>
270
+ <w:noProof/>
271
+ </w:rPr>
272
+ <w:t>«items:each(item)»</w:t>
273
+ </w:r>
274
+ <w:r>
275
+ <w:fldChar w:fldCharType="end"/>
276
+ </w:r>
277
+ </w:p>
278
+ </w:tc>
279
+ <w:tc>
280
+ <w:tcPr>
281
+ <w:tcW w:w="4285" w:type="dxa"/>
282
+ </w:tcPr>
283
+ <w:p w14:paraId="6E6D8DB2" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3"/>
284
+ </w:tc>
285
+ <w:tc>
286
+ <w:tcPr>
287
+ <w:tcW w:w="2029" w:type="dxa"/>
288
+ </w:tcPr>
289
+ <w:p w14:paraId="7BE1DB00" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3"/>
290
+ </w:tc>
291
+ </w:tr>
292
+ <w:tr w:rsidR="00757DAD" w14:paraId="1BD2E50A" w14:textId="77777777" w:rsidTr="006333C3">
293
+ <w:tc>
294
+ <w:tcPr>
295
+ <w:tcW w:w="2202" w:type="dxa"/>
296
+ </w:tcPr>
297
+ <w:p w14:paraId="41ACB3D9" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
298
+ <w:r>
299
+ <w:fldChar w:fldCharType="begin"/>
300
+ </w:r>
301
+ <w:r>
302
+ <w:instrText xml:space="preserve"> MERGEFIELD =item.index \\* MERGEFORMAT </w:instrText>
303
+ </w:r>
304
+ <w:r>
305
+ <w:fldChar w:fldCharType="separate"/>
306
+ </w:r>
307
+ <w:r>
308
+ <w:rPr>
309
+ <w:noProof/>
310
+ </w:rPr>
311
+ <w:t>«=item.index»</w:t>
312
+ </w:r>
313
+ <w:r>
314
+ <w:fldChar w:fldCharType="end"/>
315
+ </w:r>
316
+ </w:p>
317
+ </w:tc>
318
+ <w:tc>
319
+ <w:tcPr>
320
+ <w:tcW w:w="4285" w:type="dxa"/>
321
+ </w:tcPr>
322
+ <w:p w14:paraId="197C6F31" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
323
+ <w:r>
324
+ <w:fldChar w:fldCharType="begin"/>
325
+ </w:r>
326
+ <w:r>
327
+ <w:instrText xml:space="preserve"> MERGEFIELD =item.label \\* MERGEFORMAT </w:instrText>
328
+ </w:r>
329
+ <w:r>
330
+ <w:fldChar w:fldCharType="separate"/>
331
+ </w:r>
332
+ <w:r>
333
+ <w:rPr>
334
+ <w:noProof/>
335
+ </w:rPr>
336
+ <w:t>«=item.label»</w:t>
337
+ </w:r>
338
+ <w:r>
339
+ <w:fldChar w:fldCharType="end"/>
340
+ </w:r>
341
+ </w:p>
342
+ </w:tc>
343
+ <w:tc>
344
+ <w:tcPr>
345
+ <w:tcW w:w="2029" w:type="dxa"/>
346
+ </w:tcPr>
347
+ <w:p w14:paraId="55C258BB" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
348
+ <w:r>
349
+ <w:fldChar w:fldCharType="begin"/>
350
+ </w:r>
351
+ <w:r>
352
+ <w:instrText xml:space="preserve"> MERGEFIELD =item.rating \\* MERGEFORMAT </w:instrText>
353
+ </w:r>
354
+ <w:r>
355
+ <w:fldChar w:fldCharType="separate"/>
356
+ </w:r>
357
+ <w:r>
358
+ <w:rPr>
359
+ <w:noProof/>
360
+ </w:rPr>
361
+ <w:t>«=item.rating»</w:t>
362
+ </w:r>
363
+ <w:r>
364
+ <w:fldChar w:fldCharType="end"/>
365
+ </w:r>
366
+ </w:p>
367
+ </w:tc>
368
+ </w:tr>
369
+ <w:tr w:rsidR="00757DAD" w14:paraId="2D3C09BC" w14:textId="77777777" w:rsidTr="006333C3">
370
+ <w:tc>
371
+ <w:tcPr>
372
+ <w:tcW w:w="2202" w:type="dxa"/>
373
+ </w:tcPr>
374
+ <w:p w14:paraId="04A961B7" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
375
+ <w:r>
376
+ <w:fldChar w:fldCharType="begin"/>
377
+ </w:r>
378
+ <w:r>
379
+ <w:instrText xml:space="preserve"> MERGEFIELD items:endEach \\* MERGEFORMAT </w:instrText>
380
+ </w:r>
381
+ <w:r>
382
+ <w:fldChar w:fldCharType="separate"/>
383
+ </w:r>
384
+ <w:r>
385
+ <w:rPr>
386
+ <w:noProof/>
387
+ </w:rPr>
388
+ <w:t>«items:endEach»</w:t>
389
+ </w:r>
390
+ <w:r>
391
+ <w:fldChar w:fldCharType="end"/>
392
+ </w:r>
393
+ </w:p>
394
+ </w:tc>
395
+ <w:tc>
396
+ <w:tcPr>
397
+ <w:tcW w:w="4285" w:type="dxa"/>
398
+ </w:tcPr>
399
+ <w:p w14:paraId="71165BFB" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3"/>
400
+ </w:tc>
401
+ <w:tc>
402
+ <w:tcPr>
403
+ <w:tcW w:w="2029" w:type="dxa"/>
404
+ </w:tcPr>
405
+ <w:p w14:paraId="01D3965C" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3"/>
406
+ </w:tc>
407
+ </w:tr>
408
+ </w:tbl>
409
+ document
410
+
411
+ assert_xml_equal <<-document, result
412
+ <w:tbl>
413
+ <w:tblPr>
414
+ <w:tblStyle w:val="TableGrid"/>
415
+ <w:tblW w:w="0" w:type="auto"/>
416
+ <w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/>
417
+ </w:tblPr>
418
+ <w:tblGrid>
419
+ <w:gridCol w:w="2202"/>
420
+ <w:gridCol w:w="4285"/>
421
+ <w:gridCol w:w="2029"/>
422
+ </w:tblGrid>
423
+ <w:tr w:rsidR="00757DAD" w14:paraId="1BD2E50A" w14:textId="77777777" w:rsidTr="006333C3">
424
+ <w:tc>
425
+ <w:tcPr>
426
+ <w:tcW w:w="2202" w:type="dxa"/>
427
+ </w:tcPr>
428
+ <w:p w14:paraId="41ACB3D9" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
429
+ <w:r>
430
+ <w:rPr>
431
+ <w:noProof/>
432
+ </w:rPr>
433
+ <w:t>1.</w:t>
434
+ </w:r>
435
+ </w:p>
436
+ </w:tc>
437
+ <w:tc>
438
+ <w:tcPr>
439
+ <w:tcW w:w="4285" w:type="dxa"/>
440
+ </w:tcPr>
441
+ <w:p w14:paraId="197C6F31" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
442
+ <w:r>
443
+ <w:rPr>
444
+ <w:noProof/>
445
+ </w:rPr>
446
+ <w:t>Milk</w:t>
447
+ </w:r>
448
+ </w:p>
449
+ </w:tc>
450
+ <w:tc>
451
+ <w:tcPr>
452
+ <w:tcW w:w="2029" w:type="dxa"/>
453
+ </w:tcPr>
454
+ <w:p w14:paraId="55C258BB" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
455
+ <w:r>
456
+ <w:rPr>
457
+ <w:noProof/>
458
+ </w:rPr>
459
+ <w:t>***</w:t>
460
+ </w:r>
461
+ </w:p>
462
+ </w:tc>
463
+ </w:tr><w:tr w:rsidR="00757DAD" w14:paraId="1BD2E50A" w14:textId="77777777" w:rsidTr="006333C3">
464
+ <w:tc>
465
+ <w:tcPr>
466
+ <w:tcW w:w="2202" w:type="dxa"/>
467
+ </w:tcPr>
468
+ <w:p w14:paraId="41ACB3D9" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
469
+ <w:r>
470
+ <w:rPr>
471
+ <w:noProof/>
472
+ </w:rPr>
473
+ <w:t>2.</w:t>
474
+ </w:r>
475
+ </w:p>
476
+ </w:tc>
477
+ <w:tc>
478
+ <w:tcPr>
479
+ <w:tcW w:w="4285" w:type="dxa"/>
480
+ </w:tcPr>
481
+ <w:p w14:paraId="197C6F31" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
482
+ <w:r>
483
+ <w:rPr>
484
+ <w:noProof/>
485
+ </w:rPr>
486
+ <w:t>Sugar</w:t>
487
+ </w:r>
488
+ </w:p>
489
+ </w:tc>
490
+ <w:tc>
491
+ <w:tcPr>
492
+ <w:tcW w:w="2029" w:type="dxa"/>
493
+ </w:tcPr>
494
+ <w:p w14:paraId="55C258BB" w14:textId="77777777" w:rsidR="00757DAD" w:rsidRDefault="00757DAD" w:rsidP="006333C3">
495
+ <w:r>
496
+ <w:rPr>
497
+ <w:noProof/>
498
+ </w:rPr>
499
+ <w:t>**</w:t>
500
+ </w:r>
501
+ </w:p>
502
+ </w:tc>
503
+ </w:tr>
504
+ </w:tbl>
505
+
506
+ document
507
+ end
508
+
509
+ def test_multi_row_table_loop
510
+ item = Struct.new(:index, :label, :body)
511
+ context = {"foods" => [item.new("1.", "Milk", "Milk is a white liquid."),
512
+ item.new("2.", "Sugar", "Sugar is the generalized name for carbohydrates.")]}
513
+ result = process(<<-document, context)
514
+ <w:tbl>
515
+ <w:tr w:rsidR="00F23752" w14:paraId="3FF89DEC" w14:textId="77777777" w:rsidTr="00213ACD">
516
+ <w:tc>
517
+ <w:tcPr>
518
+ <w:tcW w:w="2235" w:type="dxa" />
519
+ <w:shd w:val="clear" w:color="auto" w:fill="auto" />
520
+ </w:tcPr>
521
+ <w:p w14:paraId="7630A6C6" w14:textId="699D0C71" w:rsidR="00F23752" w:rsidRDefault="00F23752" w:rsidP="003F16E3">
522
+ <w:fldSimple w:instr=" MERGEFIELD foods:each(food) \\* MERGEFORMAT ">
523
+ <w:r w:rsidR="00213ACD">
524
+ <w:rPr>
525
+ <w:noProof />
526
+ </w:rPr>
527
+ <w:t>«foods:each(food)»</w:t>
528
+ </w:r>
529
+ </w:fldSimple>
530
+ </w:p>
531
+ </w:tc>
532
+ <w:tc>
533
+ <w:tcPr>
534
+ <w:tcW w:w="6287" w:type="dxa" />
535
+ <w:shd w:val="clear" w:color="auto" w:fill="auto" />
536
+ </w:tcPr>
537
+ <w:p w14:paraId="437AFC74" w14:textId="77777777" w:rsidR="00F23752" w:rsidRDefault="00F23752" w:rsidP="003F16E3" />
538
+ </w:tc>
539
+ </w:tr>
540
+ <w:tr w:rsidR="00F23752" w14:paraId="320AE02B" w14:textId="77777777" w:rsidTr="00213ACD">
541
+ <w:tc>
542
+ <w:tcPr>
543
+ <w:tcW w:w="2235" w:type="dxa" />
544
+ <w:shd w:val="clear" w:color="auto" w:fill="8DB3E2" w:themeFill="text2" w:themeFillTint="66" />
545
+ </w:tcPr>
546
+ <w:p w14:paraId="3FCF3855" w14:textId="38FA7F3B" w:rsidR="00F23752" w:rsidRDefault="00F23752" w:rsidP="00F23752">
547
+ <w:fldSimple w:instr=" MERGEFIELD =food.index \\* MERGEFORMAT ">
548
+ <w:r w:rsidR="00213ACD">
549
+ <w:rPr>
550
+ <w:noProof />
551
+ </w:rPr>
552
+ <w:t>«=food.index»</w:t>
553
+ </w:r>
554
+ </w:fldSimple>
555
+ </w:p>
556
+ </w:tc>
557
+ <w:tc>
558
+ <w:tcPr>
559
+ <w:tcW w:w="6287" w:type="dxa" />
560
+ <w:shd w:val="clear" w:color="auto" w:fill="8DB3E2" w:themeFill="text2" w:themeFillTint="66" />
561
+ </w:tcPr>
562
+ <w:p w14:paraId="0BB0E74E" w14:textId="4FA0D282" w:rsidR="00F23752" w:rsidRPr="00F576DA" w:rsidRDefault="00F23752" w:rsidP="00F23752">
563
+ <w:r w:rsidRPr="00F576DA">
564
+ <w:fldChar w:fldCharType="begin" />
565
+ </w:r>
566
+ <w:r w:rsidRPr="00F576DA">
567
+ <w:instrText xml:space="preserve"> MERGEFIELD =</w:instrText>
568
+ </w:r>
569
+ <w:r>
570
+ <w:instrText>food</w:instrText>
571
+ </w:r>
572
+ <w:r w:rsidRPr="00F576DA">
573
+ <w:instrText xml:space="preserve">.label \\* MERGEFORMAT </w:instrText>
574
+ </w:r>
575
+ <w:r w:rsidRPr="00F576DA">
576
+ <w:fldChar w:fldCharType="separate" />
577
+ </w:r>
578
+ <w:r w:rsidR="00213ACD">
579
+ <w:rPr>
580
+ <w:rFonts w:ascii="Comic Sans MS" w:hAnsi="Comic Sans MS" />
581
+ <w:noProof />
582
+ </w:rPr>
583
+ <w:t>«=food.label»</w:t>
584
+ </w:r>
585
+ <w:r w:rsidRPr="00F576DA">
586
+ <w:fldChar w:fldCharType="end" />
587
+ </w:r>
588
+ </w:p>
589
+ </w:tc>
590
+ </w:tr>
591
+ <w:tr w:rsidR="00213ACD" w14:paraId="1EA188ED" w14:textId="77777777" w:rsidTr="00213ACD">
592
+ <w:tc>
593
+ <w:tcPr>
594
+ <w:tcW w:w="8522" w:type="dxa" />
595
+ <w:gridSpan w:val="2" />
596
+ <w:shd w:val="clear" w:color="auto" w:fill="auto" />
597
+ </w:tcPr>
598
+ <w:p w14:paraId="3E9FF163" w14:textId="0F37CDFB" w:rsidR="00213ACD" w:rsidRDefault="00213ACD" w:rsidP="003F16E3">
599
+ <w:fldSimple w:instr=" MERGEFIELD =food.body \\* MERGEFORMAT ">
600
+ <w:r>
601
+ <w:rPr>
602
+ <w:noProof />
603
+ </w:rPr>
604
+ <w:t>«=food.body»</w:t>
605
+ </w:r>
606
+ </w:fldSimple>
607
+ </w:p>
608
+ </w:tc>
609
+ </w:tr>
610
+ <w:tr w:rsidR="00213ACD" w14:paraId="34315A41" w14:textId="77777777" w:rsidTr="00213ACD">
611
+ <w:tc>
612
+ <w:tcPr>
613
+ <w:tcW w:w="2235" w:type="dxa" />
614
+ <w:shd w:val="clear" w:color="auto" w:fill="auto" />
615
+ </w:tcPr>
616
+ <w:p w14:paraId="1CA83F76" w14:textId="2622C490" w:rsidR="00213ACD" w:rsidRDefault="00213ACD" w:rsidP="003F16E3">
617
+ <w:r>
618
+ <w:fldChar w:fldCharType="begin" />
619
+ </w:r>
620
+ <w:r>
621
+ <w:instrText xml:space="preserve"> MERGEFIELD foods:endEach \\* MERGEFORMAT </w:instrText>
622
+ </w:r>
623
+ <w:r>
624
+ <w:fldChar w:fldCharType="separate" />
625
+ </w:r>
626
+ <w:r>
627
+ <w:rPr>
628
+ <w:noProof />
629
+ </w:rPr>
630
+ <w:t>«foods:endEach»</w:t>
631
+ </w:r>
632
+ <w:r>
633
+ <w:fldChar w:fldCharType="end" />
634
+ </w:r>
635
+ </w:p>
636
+ </w:tc>
637
+ <w:tc>
638
+ <w:tcPr>
639
+ <w:tcW w:w="6287" w:type="dxa" />
640
+ <w:shd w:val="clear" w:color="auto" w:fill="auto" />
641
+ </w:tcPr>
642
+ <w:p w14:paraId="7D976602" w14:textId="77777777" w:rsidR="00213ACD" w:rsidRDefault="00213ACD" w:rsidP="003F16E3" />
643
+ </w:tc>
644
+ </w:tr>
645
+ </w:tbl>
646
+ document
647
+
648
+ assert_equal "1. Milk Milk is a white liquid. 2. Sugar Sugar is the generalized name for carbohydrates.", text(result)
649
+ end
650
+
651
+ def test_conditional
652
+ document = <<-documentxml
653
+ <w:r><w:t xml:space="preserve">Anthony</w:t></w:r>
654
+ <w:p>
655
+ <w:fldSimple w:instr=" MERGEFIELD middle_name:if \\* MERGEFORMAT ">
656
+ <w:r>
657
+ <w:rPr>
658
+ <w:noProof/>
659
+ </w:rPr>
660
+ <w:t>«middle_name:if»</w:t>
661
+ </w:r>
662
+ </w:fldSimple>
663
+ </w:p>
664
+ <w:p>
665
+ <w:fldSimple w:instr=" MERGEFIELD =middle_name \\* MERGEFORMAT ">
666
+ <w:r>
667
+ <w:rPr>
668
+ <w:noProof/>
669
+ </w:rPr>
670
+ <w:t>«=middle_name»</w:t>
671
+ </w:r>
672
+ </w:fldSimple>
673
+ </w:p>
674
+ <w:p>
675
+ <w:fldSimple w:instr=" MERGEFIELD middle_name:endIf \\* MERGEFORMAT ">
676
+ <w:r>
677
+ <w:rPr>
678
+ <w:noProof/>
679
+ </w:rPr>
680
+ <w:t>«middle_name:endIf»</w:t>
681
+ </w:r>
682
+ </w:fldSimple>
683
+ </w:p>
684
+ <w:r><w:t xml:space="preserve">Hall</w:t></w:r>
685
+ documentxml
686
+ result = process(document, {"middle_name" => "Michael"})
687
+ assert_equal "Anthony Michael Hall", text(result)
688
+
689
+ result = process(document, {"middle_name" => nil})
690
+ assert_equal "Anthony Hall", text(result)
691
+ end
692
+
693
+ private
694
+ def process(document, context)
695
+ @processor.process(wrap(document), context).to_xml
696
+ end
697
+ end