sablon 0.1.1 → 0.2.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.
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>