docx 0.13.0 → 0.13.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 236ded16324395b50579ba21fdc84891251ffc75b9bd1f5c65c4d125c6689c2f
4
- data.tar.gz: d994c54de22f1829928d4fe60087d8f4667905d4e13e88e9b607a9ae3ac34077
3
+ metadata.gz: db1fd167dc6ddfecc81518f27cd78fc203d74738ffe52b2877251b2ed291c297
4
+ data.tar.gz: 7c2f230a44f18ceffffcbbfa55d212ebdd2a58651823aa78d303cc64493aa461
5
5
  SHA512:
6
- metadata.gz: a5a969c0011ac31aba461a485474015b230a76d2c449275f90a5267f4ee9b38ba698090561fea0cade728502be7db6e306c8d9a29cc662400122ed138a209e18
7
- data.tar.gz: f0e12812fd5d488162a4d263a41d095e31ab0500824ae99cc58f4fafe82ef4468b44fa68bcf0db86b3ecd305cd263602334dfbea81fa14622d7303060e461779
6
+ metadata.gz: 6e1af62546207b59b6b2bcd193c83aaafc11258dcb50d7c52bcd2251b77552704b39d5667a709cdc9fdd005c37e00e3530acd9fb88df565ba10db361d6032d98
7
+ data.tar.gz: 7d753f33adc0795e12cea763d32e01d4cf76777d1cf7f5c8d815991efccfd0ede2b513a7a99c5558824e972e17ffd86460f661d87f7003dd916cca2cdbc06498
@@ -70,9 +70,13 @@ module Docx
70
70
  # such as "{{fi", "rst_na", "me}}"). The per-run TextRun#substitute cannot
71
71
  # match those, but this can, because it joins the runs first.
72
72
  #
73
- # The matched region is collapsed into the first run it touches, so that
74
- # run's formatting is kept while the other spanned runs are emptied; runs
75
- # outside the match are left untouched.
73
+ # The substitution works on the paragraph's w:t nodes, so it also handles
74
+ # runs holding several w:t (LibreOffice writes "text<w:br/>text" in one
75
+ # run) and placeholders that start inside a hyperlink. The replacement
76
+ # goes into the w:t holding the first matched character, so that run's
77
+ # formatting is used; the rest of the match is cut from the following
78
+ # w:t, and line breaks and tabs inside the match are dropped. Text,
79
+ # breaks and tabs outside the match stay where they are.
76
80
  #
77
81
  # +pattern+ may be a String or a Regexp; +replacement+ follows String#sub
78
82
  # semantics, so capture-group backreferences (e.g. '\1') work with a Regexp.
@@ -81,20 +85,19 @@ module Docx
81
85
  # paragraph.substitute('{{first_name}}', 'Jane') # => "Hello Jane!"
82
86
  # paragraph.substitute(/\{\{(\w+)\}\}/, 'value of \1')
83
87
  #
84
- # See https://github.com/ruby-docx/docx/issues/147
88
+ # See https://github.com/ruby-docx/docx/issues/147 and #187
85
89
  def substitute(pattern, replacement)
86
90
  search_from = 0
87
91
  loop do
88
- runs = text_runs
89
- break if runs.empty?
92
+ texts = @node.xpath(TEXT_XPATH).map { |t_node| Elements::Text.new(t_node) }
90
93
 
91
94
  offsets = []
92
95
  cursor = 0
93
- runs.each do |run|
96
+ texts.each do |text|
94
97
  offsets << cursor
95
- cursor += run.text.length
98
+ cursor += text.content.length
96
99
  end
97
- full_text = runs.map(&:text).join
100
+ full_text = texts.map(&:content).join
98
101
 
99
102
  match = full_text.match(pattern, search_from)
100
103
  break unless match
@@ -102,15 +105,21 @@ module Docx
102
105
 
103
106
  match_start = match.begin(0)
104
107
  match_end = match.end(0) # exclusive
105
- first = offsets.rindex { |offset| offset <= match_start }
106
- last = offsets.rindex { |offset| offset < match_end }
107
-
108
- combined = runs[first..last].map(&:text).join
109
- local_start = match_start - offsets[first]
110
- local_end = match_end - offsets[first]
111
- replaced = combined[local_start...local_end].sub(pattern, replacement)
112
- runs[first].text = combined[0...local_start] + replaced + combined[local_end..-1]
113
- ((first + 1)..last).each { |index| runs[index].text = '' }
108
+ # the w:t holding the first and the last matched character
109
+ first = texts.each_index.find { |i| offsets[i] + texts[i].content.length > match_start }
110
+ last = texts.each_index.find { |i| offsets[i] + texts[i].content.length >= match_end }
111
+
112
+ replaced = match[0].sub(pattern, replacement)
113
+ head = texts[first].content[0...(match_start - offsets[first])]
114
+ tail = texts[last].content[(match_end - offsets[last])..]
115
+ if first == last
116
+ texts[first].content = head + replaced + tail
117
+ else
118
+ texts[first].content = head + replaced
119
+ texts[(first + 1)...last].each { |text| text.content = '' }
120
+ texts[last].content = tail
121
+ remove_separators_between(texts[first].node, texts[last].node)
122
+ end
114
123
 
115
124
  # advance past the inserted replacement so it is not re-matched
116
125
  search_from = match_start + replaced.length
@@ -165,6 +174,22 @@ module Docx
165
174
 
166
175
  private
167
176
 
177
+ # The w:t read by #text (those of the runs and hyperlink runs), in
178
+ # document order.
179
+ TEXT_XPATH = 'w:r/w:t|w:hyperlink/w:r/w:t'
180
+ # Line breaks and tabs that sit between those w:t.
181
+ SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:r/w:#{tag}|w:hyperlink/w:r/w:#{tag}" }.join('|')
182
+ private_constant :TEXT_XPATH, :SEPARATOR_XPATH
183
+
184
+ # Remove the line breaks and tabs lying between two w:t of a match.
185
+ def remove_separators_between(first_t, last_t)
186
+ separators = @node.xpath(SEPARATOR_XPATH).to_a
187
+ nodes = @node.xpath("#{TEXT_XPATH}|#{SEPARATOR_XPATH}").to_a
188
+ from = nodes.index(first_t)
189
+ to = nodes.index(last_t)
190
+ nodes[(from + 1)...to].each { |node| node.remove if separators.include?(node) }
191
+ end
192
+
168
193
  def style_property
169
194
  properties&.at_xpath('w:pStyle') || properties&.add_child('<w:pStyle/>').first
170
195
  end
@@ -40,6 +40,7 @@ module Docx
40
40
  elsif @text_nodes.empty?
41
41
  new_t = Elements::Text.create_within(self)
42
42
  new_t.content = content
43
+ @text_nodes << new_t
43
44
  end
44
45
  reset_text
45
46
  end
data/lib/docx/document.rb CHANGED
@@ -113,6 +113,9 @@ module Docx
113
113
  end
114
114
 
115
115
  def hyperlink_relationships
116
+ # @rels is nil when the document has no relationships part (#192)
117
+ return Nokogiri::XML::NodeSet.new(Nokogiri::XML::Document.new) unless @rels
118
+
116
119
  @rels.xpath("//xmlns:Relationship[contains(@Type,'hyperlink')]")
117
120
  end
118
121
 
@@ -51,28 +51,92 @@ module Docx
51
51
 
52
52
  # Get text run immediately prior to bookmark node
53
53
  def get_run_before
54
- # at_xpath returns the first match found and preceding-sibling returns siblings in the
55
- # order they appear in the document not the order as they appear when moving out from
56
- # the starting node
57
- if not (r_nodes = @node.xpath("./preceding-sibling::w:r")).empty?
58
- r_node = r_nodes.last
59
- Containers::TextRun.new(r_node)
60
- else
54
+ if enclosing_paragraph
55
+ # at_xpath returns the first match found and preceding-sibling returns siblings in the
56
+ # order they appear in the document not the order as they appear when moving out from
57
+ # the starting node
58
+ unless (r_nodes = @node.xpath("./preceding-sibling::w:r")).empty?
59
+ return Containers::TextRun.new(r_nodes.last)
60
+ end
61
+
61
62
  new_r = Containers::TextRun.create_with(self)
62
63
  new_r.insert_before(self)
63
- new_r
64
+ return new_r
64
65
  end
66
+
67
+ # Block-level bookmark (e.g. Google Docs places bookmarkStart/End directly
68
+ # under w:body). Add a run to the preceding paragraph, or to a new
69
+ # paragraph before the bookmark, so the run lives inside a w:p.
70
+ run_at_paragraph_end(@node.xpath("./preceding-sibling::w:p").last) ||
71
+ run_in_new_paragraph { |paragraph| @node.add_previous_sibling(paragraph) }
65
72
  end
66
73
 
67
74
  # Get text run immediately after bookmark node
68
75
  def get_run_after
69
- if (r_node = @node.at_xpath("./following-sibling::w:r"))
70
- Containers::TextRun.new(r_node)
71
- else
76
+ if enclosing_paragraph
77
+ if (r_node = @node.at_xpath("./following-sibling::w:r"))
78
+ return Containers::TextRun.new(r_node)
79
+ end
80
+
72
81
  new_r = Containers::TextRun.create_with(self)
73
82
  new_r.insert_after(self)
74
- new_r
83
+ return new_r
84
+ end
85
+
86
+ # Block-level bookmark: add a run to the following paragraph, or to a new
87
+ # paragraph after the bookmark.
88
+ run_at_paragraph_start(@node.at_xpath("./following-sibling::w:p")) ||
89
+ run_in_new_paragraph { |paragraph| @node.add_next_sibling(paragraph) }
90
+ end
91
+
92
+ # Override Element#parent_paragraph so insert_multiple_lines also works for
93
+ # block-level bookmarks. For those we fill a fresh paragraph inserted at the
94
+ # bookmark, rather than an adjacent existing paragraph, so we neither crash
95
+ # (no enclosing paragraph) nor overwrite unrelated content.
96
+ def parent_paragraph
97
+ return Containers::Paragraph.new(enclosing_paragraph) if enclosing_paragraph
98
+
99
+ paragraph = Nokogiri::XML::Node.new("w:p", @node.document)
100
+ @node.add_next_sibling(paragraph)
101
+ Containers::Paragraph.new(paragraph)
102
+ end
103
+
104
+ private
105
+
106
+ # The w:p the bookmark sits inside, or nil when it is block-level.
107
+ def enclosing_paragraph
108
+ @node.at_xpath("./parent::w:p")
109
+ end
110
+
111
+ # A new run inserted at the start of paragraph_node (after w:pPr if present).
112
+ # Returns nil when paragraph_node is nil.
113
+ def run_at_paragraph_start(paragraph_node)
114
+ return nil unless paragraph_node
115
+
116
+ new_r = Containers::TextRun.create_with(self)
117
+ if (props = paragraph_node.at_xpath("w:pPr"))
118
+ props.add_next_sibling(new_r.node)
119
+ else
120
+ paragraph_node.prepend_child(new_r.node)
75
121
  end
122
+ new_r
123
+ end
124
+
125
+ # A new run appended to the end of paragraph_node. nil when it is nil.
126
+ def run_at_paragraph_end(paragraph_node)
127
+ return nil unless paragraph_node
128
+
129
+ Containers::TextRun.create_within(Containers::Paragraph.new(paragraph_node))
130
+ end
131
+
132
+ # Create a run wrapped in a fresh w:p; the block positions that paragraph
133
+ # relative to the bookmark.
134
+ def run_in_new_paragraph
135
+ new_r = Containers::TextRun.create_with(self)
136
+ paragraph = Nokogiri::XML::Node.new("w:p", @node.document)
137
+ paragraph.add_child(new_r.node)
138
+ yield paragraph
139
+ new_r
76
140
  end
77
141
  end
78
142
  end
@@ -42,7 +42,8 @@ module Docx
42
42
 
43
43
  # Insert node as first child (after properties)
44
44
  def prepend_to(element)
45
- @node = element.node.properties.add_next_sibling(@node)
45
+ properties = element.properties if element.respond_to?(:properties)
46
+ @node = properties ? properties.add_next_sibling(@node) : element.node.prepend_child(@node)
46
47
  self
47
48
  end
48
49
 
@@ -13,6 +13,8 @@ module Docx
13
13
 
14
14
  def content=(args)
15
15
  @node.content = args
16
+ # Without xml:space="preserve", Word drops leading and trailing spaces.
17
+ @node['xml:space'] = 'preserve' if args.to_s.match?(/\A\s|\s\z/)
16
18
  end
17
19
 
18
20
  def initialize(node)
data/lib/docx/helpers.rb CHANGED
@@ -4,7 +4,7 @@ module Docx
4
4
  # than the default inspect method. But you can still get the default inspect method by passing
5
5
  # true as the first argument.
6
6
  def inspect(full = false)
7
- return(super) if full
7
+ return super() if full
8
8
 
9
9
  variable_values =
10
10
  instance_variables.map do |var|
data/lib/docx/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docx #:nodoc:
4
- VERSION = '0.13.0'
4
+ VERSION = '0.13.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.13.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Hunt