sablon 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +36 -5
  4. data/lib/sablon.rb +0 -3
  5. data/lib/sablon/configuration/html_tag.rb +1 -1
  6. data/lib/sablon/content.rb +56 -0
  7. data/lib/sablon/context.rb +2 -0
  8. data/lib/sablon/document_object_model/content_types.rb +35 -0
  9. data/lib/sablon/document_object_model/file_handler.rb +26 -0
  10. data/lib/sablon/document_object_model/model.rb +94 -0
  11. data/lib/sablon/document_object_model/numbering.rb +94 -0
  12. data/lib/sablon/document_object_model/relationships.rb +111 -0
  13. data/lib/sablon/environment.rb +13 -16
  14. data/lib/sablon/html/ast.rb +14 -13
  15. data/lib/sablon/html/ast_builder.rb +18 -5
  16. data/lib/sablon/html/node_properties.rb +3 -3
  17. data/lib/sablon/operations.rb +59 -0
  18. data/lib/sablon/processor/document.rb +48 -11
  19. data/lib/sablon/processor/section_properties.rb +11 -4
  20. data/lib/sablon/template.rb +88 -47
  21. data/lib/sablon/version.rb +1 -1
  22. data/misc/image-example.png +0 -0
  23. data/test/configuration_test.rb +22 -22
  24. data/test/content_test.rb +50 -0
  25. data/test/context_test.rb +37 -1
  26. data/test/environment_test.rb +4 -1
  27. data/test/executable_test.rb +0 -2
  28. data/test/fixtures/cv_sample.docx +0 -0
  29. data/test/fixtures/html_sample.docx +0 -0
  30. data/test/fixtures/images/c3po.jpg +0 -0
  31. data/test/fixtures/images/clone.jpg +0 -0
  32. data/test/fixtures/images/darth_vader.jpg +0 -0
  33. data/test/fixtures/images/r2d2.jpg +0 -0
  34. data/test/fixtures/images_sample.docx +0 -0
  35. data/test/fixtures/images_template.docx +0 -0
  36. data/test/fixtures/loops_sample.docx +0 -0
  37. data/test/fixtures/loops_template.docx +0 -0
  38. data/test/fixtures/recipe_sample.docx +0 -0
  39. data/test/fixtures/xml/image.xml +91 -0
  40. data/test/fixtures/xml/loop_with_unique_ids.xml +152 -0
  41. data/test/fixtures/xml/mock_document/word/document.xml +12 -0
  42. data/test/html/ast_test.rb +10 -5
  43. data/test/html/converter_style_test.rb +9 -9
  44. data/test/html/converter_test.rb +66 -81
  45. data/test/html/node_properties_test.rb +2 -2
  46. data/test/html_test.rb +2 -6
  47. data/test/processor/document_test.rb +80 -3
  48. data/test/processor/section_properties_test.rb +68 -0
  49. data/test/sablon_test.rb +77 -5
  50. data/test/test_helper.rb +109 -9
  51. metadata +33 -9
  52. data/lib/sablon/numbering.rb +0 -23
  53. data/lib/sablon/processor/numbering.rb +0 -47
  54. data/lib/sablon/relationship.rb +0 -47
  55. data/lib/sablon/test/assertions.rb +0 -22
  56. data/test/section_properties_test.rb +0 -41
@@ -1,12 +1,18 @@
1
1
  module Sablon
2
2
  module Processor
3
3
  class SectionProperties
4
- def self.from_document(document_xml)
5
- new document_xml.at_xpath(".//w:sectPr")
4
+ def self.process(xml_node, env)
5
+ processor = new(xml_node)
6
+ processor.write_properties(env.section_properties)
6
7
  end
7
8
 
8
- def initialize(properties_node)
9
- @properties_node = properties_node
9
+ def initialize(xml_node)
10
+ @properties_node = xml_node.at_xpath(".//w:sectPr")
11
+ end
12
+
13
+ def write_properties(properties = {})
14
+ return unless properties["start_page_number"]
15
+ self.start_page_number = properties["start_page_number"]
10
16
  end
11
17
 
12
18
  def start_page_number
@@ -18,6 +24,7 @@ module Sablon
18
24
  end
19
25
 
20
26
  private
27
+
21
28
  def find_or_add_pg_num_type
22
29
  pg_num_type || begin
23
30
  node = Nokogiri::XML::Node.new "w:pgNumType", @properties_node.document
@@ -1,5 +1,38 @@
1
+ require 'sablon/document_object_model/model'
2
+ require 'sablon/processor/document'
3
+ require 'sablon/processor/section_properties'
4
+
1
5
  module Sablon
6
+ # Creates a template from an MS Word doc that can be easily manipulated
2
7
  class Template
8
+ attr_reader :document
9
+
10
+ class << self
11
+ # Adds a new processor to the processors hash. The +pattern+ is used
12
+ # to select which files the processor should handle. Multiple processors
13
+ # can be added for the same pattern.
14
+ def register_processor(pattern, klass, replace_all: false)
15
+ processors[pattern] = [] if replace_all
16
+ #
17
+ if processors[pattern].empty?
18
+ processors[pattern] = [klass]
19
+ else
20
+ processors[pattern] << klass
21
+ end
22
+ end
23
+
24
+ # Returns the processor classes with a pattern matching the
25
+ # entry name. If none match nil is returned.
26
+ def get_processors(entry_name)
27
+ key = processors.keys.detect { |pattern| entry_name =~ pattern }
28
+ processors[key]
29
+ end
30
+
31
+ def processors
32
+ @processors ||= Hash.new([])
33
+ end
34
+ end
35
+
3
36
  def initialize(path)
4
37
  @path = path
5
38
  end
@@ -19,63 +52,71 @@ module Sablon
19
52
  private
20
53
 
21
54
  def render(context, properties = {})
22
- created_dirs = []
23
- relations_file_content = nil
55
+ # initialize environment
56
+ @document = Sablon::DOM::Model.new(Zip::File.open(@path))
24
57
  env = Sablon::Environment.new(self, context)
25
- Zip.sort_entries = true # required to process document.xml before numbering.xml
58
+ env.section_properties = properties
59
+ #
60
+ # process files
61
+ process(env)
62
+ #
26
63
  Zip::OutputStream.write_buffer(StringIO.new) do |out|
27
- Zip::File.open(@path).each do |entry|
28
- entry_name = entry.name
29
- created_dirs = create_dirs_in_zipfile(created_dirs, entry_name, out)
30
- out.put_next_entry(entry_name)
31
- content = entry.get_input_stream.read
32
- if entry_name == 'word/document.xml'
33
- out.write(process(Processor::Document, content, env, properties))
34
- elsif entry_name =~ /word\/header\d*\.xml/ || entry_name =~ /word\/footer\d*\.xml/
35
- out.write(process(Processor::Document, content, env))
36
- elsif entry_name == 'word/numbering.xml'
37
- out.write(process(Processor::Numbering, content, env))
38
- elsif entry_name == 'word/_rels/document.xml.rels'
39
- relations_file_content = content
40
- else
41
- out.write(content)
42
- end
43
- end
44
- if relations_file_content
45
- env.relationship.add_found_relationships(relations_file_content, out)
46
- end
64
+ generate_output_file(out, @document.zip_contents)
47
65
  end
48
66
  end
49
67
 
50
- # creates directories of the unzipped docx file in the newly created docx file e.g. in case of
51
- # word/_rels/document.xml.rels it creates word/ and _rels directories to apply recursive zipping.
52
- # This is a hack to fix the issue of getting a corrupted file when any referencing between the
53
- # xml files happen like in the case of implementing hyperlinks
54
- #
55
- def create_dirs_in_zipfile(previous_created_dirs, entry_name, output_stream)
56
- created_dirs = previous_created_dirs
57
- entry_name_tokens = entry_name.split('/')
58
- entry_name_tokens.pop()
59
- if entry_name_tokens.length > 1
60
- prev_dir = ''
61
- entry_name_tokens.each do |dir_name|
62
- prev_dir += dir_name + '/'
63
- unless created_dirs.include? prev_dir
64
- output_stream.put_next_entry(prev_dir)
65
- created_dirs << prev_dir
66
- end
67
- end
68
+ # Processes all of te entries searching for ones that match the pattern.
69
+ # The hash is converted into an array first to avoid any possible
70
+ # modification during iteration errors (i.e. creation of a new rels file).
71
+ def process(env)
72
+ @document.zip_contents.to_a.each do |(entry_name, content)|
73
+ @document.current_entry = entry_name
74
+ processors = Template.get_processors(entry_name)
75
+ processors.each { |processor| processor.process(content, env) }
68
76
  end
69
- created_dirs
70
77
  end
71
78
 
72
- # process the sablon xml template with the given +context+.
73
- #
74
79
  # IMPORTANT: Open Office does not ignore whitespace around tags.
75
80
  # We need to render the xml without indent and whitespace.
76
- def process(processor, content, *args)
77
- document = Nokogiri::XML(content)
78
- processor.process(document, *args).to_xml(indent: 0, save_with: 0)
81
+ def generate_output_file(zip_out, contents)
82
+ # output entries to zip file
83
+ created_dirs = []
84
+ contents.each do |entry_name, content|
85
+ create_dirs_in_zipfile(created_dirs, File.dirname(entry_name), zip_out)
86
+ zip_out.put_next_entry(entry_name)
87
+ #
88
+ # convert Nokogiri XML to string
89
+ if content.instance_of? Nokogiri::XML::Document
90
+ content = content.to_xml(indent: 0, save_with: 0)
91
+ end
92
+ #
93
+ zip_out.write(content)
94
+ end
95
+ end
96
+
97
+ # creates directories of the unzipped docx file in the newly created
98
+ # docx file e.g. in case of word/_rels/document.xml.rels it creates
99
+ # word/ and _rels directories to apply recursive zipping. This is a
100
+ # hack to fix the issue of getting a corrupted file when any referencing
101
+ # between the xml files happen like in the case of implementing hyperlinks.
102
+ # The created_dirs array is augmented in place using '<<'
103
+ def create_dirs_in_zipfile(created_dirs, entry_path, output_stream)
104
+ entry_path_tokens = entry_path.split('/')
105
+ return created_dirs unless entry_path_tokens.length > 1
106
+ #
107
+ prev_dir = ''
108
+ entry_path_tokens.each do |dir_name|
109
+ prev_dir += dir_name + '/'
110
+ next if created_dirs.include? prev_dir
111
+ #
112
+ output_stream.put_next_entry(prev_dir)
113
+ created_dirs << prev_dir
114
+ end
79
115
  end
80
116
  end
117
+
118
+ # Register the standard processors
119
+ Template.register_processor(%r{word/document.xml}, Sablon::Processor::Document)
120
+ Template.register_processor(%r{word/document.xml}, Sablon::Processor::SectionProperties)
121
+ Template.register_processor(%r{word/(?:header|footer)\d*\.xml}, Sablon::Processor::Document)
81
122
  end
@@ -1,3 +1,3 @@
1
1
  module Sablon
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
Binary file
@@ -16,24 +16,24 @@ class ConfigurationTest < Sablon::TestCase
16
16
  }
17
17
  # test initialization without type
18
18
  tag = @config.register_html_tag(:test_tag, **options)
19
- assert_equal @config.permitted_html_tags[:test_tag], tag
20
- assert_equal tag.name, :test_tag
21
- assert_equal tag.type, :inline
22
- assert_equal tag.ast_class, Sablon::HTMLConverter::Paragraph
23
- assert_equal tag.attributes, dummy: 'value'
24
- assert_equal tag.properties, pstyle: 'ListBullet'
25
- assert_equal tag.allowed_children, %i[_inline ol ul li]
19
+ assert_equal tag, @config.permitted_html_tags[:test_tag]
20
+ assert_equal :test_tag, tag.name
21
+ assert_equal :inline, tag.type
22
+ assert_equal Sablon::HTMLConverter::Paragraph, tag.ast_class
23
+ assert_equal({ dummy: 'value' }, tag.attributes)
24
+ assert_equal({ 'pstyle' => 'ListBullet' }, tag.properties)
25
+ assert_equal %i[_inline ol ul li], tag.allowed_children
26
26
 
27
27
  # test initialization with type
28
28
  tag = @config.register_html_tag('test_tag2', :block, **options)
29
- assert_equal @config.permitted_html_tags[:test_tag2], tag
30
- assert_equal tag.name, :test_tag2
31
- assert_equal tag.type, :block
29
+ assert_equal tag, @config.permitted_html_tags[:test_tag2]
30
+ assert_equal :test_tag2, tag.name
31
+ assert_equal :block, tag.type
32
32
  end
33
33
 
34
34
  def test_remove_tag
35
35
  tag = @config.register_html_tag(:test)
36
- assert_equal @config.remove_html_tag(:test), tag
36
+ assert_equal tag, @config.remove_html_tag(:test)
37
37
  assert_nil @config.permitted_html_tags[:test]
38
38
  end
39
39
 
@@ -77,9 +77,9 @@ class ConfigurationHTMLTagTest < Sablon::TestCase
77
77
  def test_html_tag_full_init
78
78
  args = ['a', 'inline', ast_class: Sablon::HTMLConverter::Run]
79
79
  tag = Sablon::Configuration::HTMLTag.new(*args)
80
- assert_equal tag.name, :a
81
- assert_equal tag.type, :inline
82
- assert_equal tag.ast_class, Sablon::HTMLConverter::Run
80
+ assert_equal :a, tag.name
81
+ assert_equal :inline, tag.type
82
+ assert_equal Sablon::HTMLConverter::Run, tag.ast_class
83
83
  #
84
84
  options = {
85
85
  ast_class: :run,
@@ -89,12 +89,12 @@ class ConfigurationHTMLTagTest < Sablon::TestCase
89
89
  }
90
90
  tag = Sablon::Configuration::HTMLTag.new('a', 'inline', **options)
91
91
  #
92
- assert_equal tag.name, :a
93
- assert_equal tag.type, :inline
94
- assert_equal tag.ast_class, Sablon::HTMLConverter::Run
95
- assert_equal tag.attributes, dummy: 'value1'
96
- assert_equal tag.properties, dummy2: 'value2'
97
- assert_equal tag.allowed_children, [:text]
92
+ assert_equal :a, tag.name
93
+ assert_equal :inline, tag.type
94
+ assert_equal Sablon::HTMLConverter::Run, tag.ast_class
95
+ assert_equal({ dummy: 'value1' }, tag.attributes)
96
+ assert_equal({ 'dummy2' => 'value2' }, tag.properties)
97
+ assert_equal [:text], tag.allowed_children
98
98
  end
99
99
 
100
100
  def test_html_tag_init_block_without_class
@@ -113,10 +113,10 @@ class ConfigurationHTMLTagTest < Sablon::TestCase
113
113
  # test default allowances
114
114
  assert div.allowed_child?(text) # all inline elements allowed
115
115
  assert div.allowed_child?(olist) # tag name is included even though it is bock leve
116
- assert_equal div.allowed_child?(div), false # other block elms are not allowed
116
+ assert_equal false, div.allowed_child?(div) # other block elms are not allowed
117
117
 
118
118
  # test olist with allowances for all blocks but no inline
119
119
  assert olist.allowed_child?(div) # all block elements allowed
120
- assert_equal olist.allowed_child?(text), false # no inline elements
120
+ assert_equal false, olist.allowed_child?(text) # no inline elements
121
121
  end
122
122
  end
@@ -238,3 +238,53 @@ class ContentWordMLTest < Sablon::TestCase
238
238
  assert_xml_equal output, @document
239
239
  end
240
240
  end
241
+
242
+ class ContentImageTest < Sablon::TestCase
243
+ def setup
244
+ base_path = Pathname.new(File.expand_path('../', __FILE__))
245
+ fixture_dir = base_path.join('fixtures')
246
+ @image_path = fixture_dir.join('images', 'r2d2.jpg')
247
+ @expected = Sablon::Content::Image.new(@image_path.to_s)
248
+ end
249
+
250
+ def test_inspect
251
+ assert_equal '#<Image r2d2.jpg:{}>', @expected.inspect
252
+ #
253
+ # set some rid's and retest
254
+ @expected.rid_by_file['word/test.xml'] = 'rId1'
255
+ assert_equal '#<Image r2d2.jpg:{"word/test.xml"=>"rId1"}>', @expected.inspect
256
+ end
257
+
258
+ def test_wraps_image_from_string_path
259
+ #
260
+ tested = Sablon.content(:image, @image_path.to_s)
261
+ assert_equal @expected, tested
262
+ end
263
+
264
+ def test_wraps_image_from_readable_object_that_can_be_basenamed
265
+ tested = Sablon.content(:image, open(@image_path.to_s, 'rb'))
266
+ assert_equal @expected, tested
267
+ end
268
+
269
+ def test_wraps_image_from_readable_object_with_filename_supplied
270
+ data = StringIO.new(IO.binread(@image_path.to_s))
271
+ tested = Sablon.content(:image, data, filename: File.basename(@image_path))
272
+ assert_equal @expected, tested
273
+ end
274
+
275
+ def test_wraps_readable_object_that_responds_to_filename
276
+ readable = Struct.new(:data, :filename) { alias read data }
277
+ #
278
+ readable = readable.new(IO.binread(@image_path.to_s), File.basename(@image_path))
279
+ tested = Sablon.content(:image, readable)
280
+ assert_equal @expected, tested
281
+ end
282
+
283
+ def test_raises_error_when_no_filename
284
+ data = StringIO.new(IO.binread(@image_path.to_s))
285
+ #
286
+ assert_raises ArgumentError do
287
+ Sablon.content(:image, data)
288
+ end
289
+ end
290
+ end
@@ -1,7 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  require "test_helper"
3
3
 
4
- class EnvironmentTest < Sablon::TestCase
4
+ class ContextTest < Sablon::TestCase
5
5
  def test_converts_symbol_keys_to_string_keys
6
6
  context = Sablon::Context.transform_hash(a: 1, b: { c: 2, "d" => 3 })
7
7
  assert_equal({ "a" => 1, "b" => { "c" => 2, "d" => 3 } }, context)
@@ -25,4 +25,40 @@ class EnvironmentTest < Sablon::TestCase
25
25
  "otherkey" => nil,
26
26
  "normalkey" => nil}, context)
27
27
  end
28
+
29
+ def test_recognizes_image_keys
30
+ base_path = Pathname.new(File.expand_path("../", __FILE__))
31
+ img_path = "#{base_path}/fixtures/images/c3po.jpg"
32
+ context = {
33
+ test: 'result',
34
+ 'image:image' => img_path
35
+ }
36
+ #
37
+ context = Sablon::Context.transform_hash(context)
38
+ assert_equal({ "test" => "result",
39
+ "image" => Sablon.content(:image, img_path) },
40
+ context)
41
+ end
42
+
43
+ def test_converts_hashes_nested_in_arrays
44
+ input_context = {
45
+ test: 'result',
46
+ items: [
47
+ { name: 'Key1', value: 'Value1' },
48
+ { 'name' => 'Key2', 'html:value' => '<b>Test</b>' }
49
+ ],
50
+ 'word_ml:runs' => '<w:r><w:t>Text</w:t><w:r>'
51
+ }
52
+ expected_context = {
53
+ 'test' => 'result',
54
+ 'items' => [
55
+ { 'name' => 'Key1', 'value' => 'Value1' },
56
+ { 'name' => 'Key2', 'value' => Sablon.content(:html, '<b>Test</b>') }
57
+ ],
58
+ 'runs' => Sablon.content(:word_ml, '<w:r><w:t>Text</w:t><w:r>')
59
+ }
60
+ #
61
+ context = Sablon::Context.transform_hash(input_context)
62
+ assert_equal expected_context, context
63
+ end
28
64
  end
@@ -13,14 +13,17 @@ class EnvironmentTest < Sablon::TestCase
13
13
  def test_alter_context
14
14
  # set initial context
15
15
  env = Sablon::Environment.new(nil, a: 1, b: { c: 2, "d" => 3 })
16
+
16
17
  # alter context to change a single key and set a new one
17
18
  env2 = env.alter_context(a: "a", e: "new-key")
18
19
  assert_equal({ "a" => "a", "b" => { "c" => 2, "d" => 3 }, "e" => "new-key" }, env2.context)
20
+
19
21
  # check that the old context was not modified
20
22
  assert_equal({"a" => 1, "b" => { "c" => 2, "d" => 3 }}, env.context)
23
+
21
24
  # check that numbering and template are the same references
22
25
  assert env.template.equal?(env2.template), "#{env.template} != #{env2.template}"
23
- assert env.numbering.equal?(env2.numbering), "#{env.numbering} != #{env2.numbering}"
26
+
24
27
  # check that a new context reference was created
25
28
  assert !env.context.equal?(env2.context), "#{env.context} == #{env2.context}"
26
29
  end
@@ -1,8 +1,6 @@
1
1
  require "test_helper"
2
2
 
3
3
  class ExecutableTest < Sablon::TestCase
4
- include Sablon::Test::Assertions
5
-
6
4
  def setup
7
5
  super
8
6
  @base_path = Pathname.new(File.expand_path("../", __FILE__))
Binary file
@@ -0,0 +1,91 @@
1
+ <w:p>
2
+ <w:r>
3
+ <w:fldChar w:fldCharType="begin"/>
4
+ </w:r>
5
+ <w:r>
6
+ <w:instrText xml:space="preserve"> MERGEFIELD @item.image:start \* MERGEFORMAT </w:instrText>
7
+ </w:r>
8
+ <w:r>
9
+ <w:fldChar w:fldCharType="separate"/>
10
+ </w:r>
11
+ <w:r>
12
+ <w:rPr>
13
+ <w:noProof/>
14
+ </w:rPr>
15
+ <w:t>«@item.image:start»</w:t>
16
+ </w:r>
17
+ <w:r>
18
+ <w:fldChar w:fldCharType="end"/>
19
+ </w:r>
20
+ </w:p>
21
+ <w:p >
22
+ <w:r>
23
+ <w:rPr>
24
+ <w:noProof/>
25
+ </w:rPr>
26
+ <w:drawing>
27
+ <wp:inline distT="0" distB="0" distL="0" distR="0">
28
+ <wp:extent cx="1875155" cy="1249045"/>
29
+ <wp:effectExtent l="0" t="0" r="0" b="0"/>
30
+ <wp:docPr id="2" name="Picture 2"/>
31
+ <wp:cNvGraphicFramePr>
32
+ <a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>
33
+ </wp:cNvGraphicFramePr>
34
+ <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
35
+ <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
36
+ <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
37
+ <pic:nvPicPr>
38
+ <pic:cNvPr id="2" name="placeholder.png"/>
39
+ <pic:cNvPicPr/>
40
+ </pic:nvPicPr>
41
+ <pic:blipFill>
42
+ <a:blip r:embed="rId4">
43
+ <a:extLst>
44
+ <a:ext uri="{28A0092B-C50C-407E-A947-70E740481C1C}">
45
+ <a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/>
46
+ </a:ext>
47
+ </a:extLst>
48
+ </a:blip>
49
+ <a:stretch>
50
+ <a:fillRect/>
51
+ </a:stretch>
52
+ </pic:blipFill>
53
+ <pic:spPr>
54
+ <a:xfrm>
55
+ <a:off x="0" y="0"/>
56
+ <a:ext cx="1875155" cy="1249045"/>
57
+ </a:xfrm>
58
+ <a:prstGeom prst="rect">
59
+ <a:avLst/>
60
+ </a:prstGeom>
61
+ </pic:spPr>
62
+ </pic:pic>
63
+ </a:graphicData>
64
+ </a:graphic>
65
+ </wp:inline>
66
+ </w:drawing>
67
+ </w:r>
68
+ </w:p>
69
+ <w:p>
70
+ <w:r>
71
+ <w:fldChar w:fldCharType="begin"/>
72
+ </w:r>
73
+ <w:r>
74
+ <w:instrText xml:space="preserve"> MERGEFIELD @item.image:end \* MERGEFORMAT </w:instrText>
75
+ </w:r>
76
+ <w:r>
77
+ <w:fldChar w:fldCharType="separate"/>
78
+ </w:r>
79
+ <w:r>
80
+ <w:rPr>
81
+ <w:noProof/>
82
+ </w:rPr>
83
+ <w:t>«@item.image:end»</w:t>
84
+ </w:r>
85
+ <w:r>
86
+ <w:rPr>
87
+ <w:noProof/>
88
+ </w:rPr>
89
+ <w:fldChar w:fldCharType="end"/>
90
+ </w:r>
91
+ </w:p>