diff --git a/lib/docx/containers/paragraph.rb b/lib/docx/containers/paragraph.rb
index 739c771..6e6ca3c 100755
--- a/lib/docx/containers/paragraph.rb
+++ b/lib/docx/containers/paragraph.rb
@@ -88,41 +88,12 @@ def each_text_run
# See https://github.com/ruby-docx/docx/issues/147 and #187
def substitute(pattern, replacement)
search_from = 0
- loop do
- texts = @node.xpath(TEXT_XPATH).map { |t_node| Elements::Text.new(t_node) }
-
- offsets = []
- cursor = 0
- texts.each do |text|
- offsets << cursor
- cursor += text.content.length
- end
- full_text = texts.map(&:content).join
-
- match = full_text.match(pattern, search_from)
- break unless match
- break if match.end(0) == match.begin(0) # ignore empty matches
-
- match_start = match.begin(0)
- match_end = match.end(0) # exclusive
- # the w:t holding the first and the last matched character
- first = texts.each_index.find { |i| offsets[i] + texts[i].content.length > match_start }
- last = texts.each_index.find { |i| offsets[i] + texts[i].content.length >= match_end }
-
+ while (match = find_match(pattern, search_from))
replaced = match[0].sub(pattern, replacement)
- head = texts[first].content[0...(match_start - offsets[first])]
- tail = texts[last].content[(match_end - offsets[last])..]
- if first == last
- texts[first].content = head + replaced + tail
- else
- texts[first].content = head + replaced
- texts[(first + 1)...last].each { |text| text.content = '' }
- texts[last].content = tail
- remove_separators_between(texts[first].node, texts[last].node, TEXT_XPATH, SEPARATOR_XPATH)
- end
+ replace_text_range(match.begin(0)...match.end(0), replaced)
# advance past the inserted replacement so it is not re-matched
- search_from = match_start + replaced.length
+ search_from = match.begin(0) + replaced.length
end
self
end
@@ -181,6 +152,43 @@ def style=(identifier)
SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:r/w:#{tag}|w:hyperlink/w:r/w:#{tag}" }.join('|')
private_constant :TEXT_XPATH, :SEPARATOR_XPATH
+ # The paragraph's w:t, in document order; their contents joined make up #text.
+ def text_nodes
+ @node.xpath(TEXT_XPATH).map { |t_node| Elements::Text.new(t_node) }
+ end
+
+ # The first match of +pattern+ in the paragraph text at or after +from+,
+ # or nil when there is none. An empty match also gives nil, so that
+ # #substitute stops rather than loop on it.
+ def find_match(pattern, from)
+ match = text_nodes.map(&:content).join.match(pattern, from)
+ match unless match.nil? || match[0].empty?
+ end
+
+ # Replace the characters in +range+ of the paragraph text with +string+.
+ # The string goes into the w:t holding the range's first character; the
+ # rest of the range is cut from the following w:t, and line breaks and
+ # tabs inside it are removed.
+ def replace_text_range(range, string)
+ texts = text_nodes
+ # boundaries[i]...boundaries[i + 1] is where texts[i] sits in the text
+ boundaries = texts.each_with_object([0]) { |text, acc| acc << acc.last + text.content.length }
+ first = texts.each_index.find { |i| boundaries[i + 1] > range.begin }
+ last = texts.each_index.find { |i| boundaries[i + 1] >= range.end }
+
+ head = texts[first].content[0...(range.begin - boundaries[first])]
+ tail = texts[last].content[(range.end - boundaries[last])..]
+ if first == last
+ texts[first].content = head + string + tail
+ return
+ end
+
+ texts[first].content = head + string
+ texts[(first + 1)...last].each { |text| text.content = '' }
+ texts[last].content = tail
+ remove_separators_between(texts[first].node, texts[last].node, TEXT_XPATH, SEPARATOR_XPATH)
+ end
+
def style_property
properties&.at_xpath('w:pStyle') || properties&.add_child('').first
end
diff --git a/spec/docx/containers/paragraph_spec.rb b/spec/docx/containers/paragraph_spec.rb
index 15d49bc..deb2897 100644
--- a/spec/docx/containers/paragraph_spec.rb
+++ b/spec/docx/containers/paragraph_spec.rb
@@ -2,6 +2,7 @@
require 'spec_helper'
require 'docx'
+require 'timeout'
describe Docx::Elements::Containers::Paragraph do
def paragraph(xml, document_properties = {}, document = nil)
@@ -142,6 +143,19 @@ def paragraph(xml, document_properties = {}, document = nil)
expect(p.text).to eq('{{a}}{{a}}')
end
+ # Without the guard against empty matches this would loop forever, so it
+ # runs under a timeout to fail instead of hanging.
+ it 'ignores a pattern that only matches the empty string' do
+ p = paragraph('abc')
+
+ Timeout.timeout(5) do
+ p.substitute(/x*/, '-')
+ p.substitute(/x*/, '')
+ end
+
+ expect(p.text).to eq('abc')
+ end
+
it 'leaves the paragraph untouched when nothing matches' do
expect(split.substitute('{{missing}}', 'x').text).to eq('Hello {{first_name}}!')
end