diff --git a/lib/docx/containers/paragraph.rb b/lib/docx/containers/paragraph.rb
index 820d6c3..24849ac 100755
--- a/lib/docx/containers/paragraph.rb
+++ b/lib/docx/containers/paragraph.rb
@@ -70,9 +70,13 @@ def each_text_run
# such as "{{fi", "rst_na", "me}}"). The per-run TextRun#substitute cannot
# match those, but this can, because it joins the runs first.
#
- # The matched region is collapsed into the first run it touches, so that
- # run's formatting is kept while the other spanned runs are emptied; runs
- # outside the match are left untouched.
+ # The substitution works on the paragraph's w:t nodes, so it also handles
+ # runs holding several w:t (LibreOffice writes "texttext" in one
+ # run) and placeholders that start inside a hyperlink. The replacement
+ # goes into the w:t holding the first matched character, so that run's
+ # formatting is used; the rest of the match is cut from the following
+ # w:t, and line breaks and tabs inside the match are dropped. Text,
+ # breaks and tabs outside the match stay where they are.
#
# +pattern+ may be a String or a Regexp; +replacement+ follows String#sub
# semantics, so capture-group backreferences (e.g. '\1') work with a Regexp.
@@ -81,20 +85,19 @@ def each_text_run
# paragraph.substitute('{{first_name}}', 'Jane') # => "Hello Jane!"
# paragraph.substitute(/\{\{(\w+)\}\}/, 'value of \1')
#
- # See https://github.com/ruby-docx/docx/issues/147
+ # See https://github.com/ruby-docx/docx/issues/147 and #187
def substitute(pattern, replacement)
search_from = 0
loop do
- runs = text_runs
- break if runs.empty?
+ texts = @node.xpath(TEXT_XPATH).map { |t_node| Elements::Text.new(t_node) }
offsets = []
cursor = 0
- runs.each do |run|
+ texts.each do |text|
offsets << cursor
- cursor += run.text.length
+ cursor += text.content.length
end
- full_text = runs.map(&:text).join
+ full_text = texts.map(&:content).join
match = full_text.match(pattern, search_from)
break unless match
@@ -102,15 +105,21 @@ def substitute(pattern, replacement)
match_start = match.begin(0)
match_end = match.end(0) # exclusive
- first = offsets.rindex { |offset| offset <= match_start }
- last = offsets.rindex { |offset| offset < match_end }
-
- combined = runs[first..last].map(&:text).join
- local_start = match_start - offsets[first]
- local_end = match_end - offsets[first]
- replaced = combined[local_start...local_end].sub(pattern, replacement)
- runs[first].text = combined[0...local_start] + replaced + combined[local_end..-1]
- ((first + 1)..last).each { |index| runs[index].text = '' }
+ # 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 }
+
+ 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)
+ end
# advance past the inserted replacement so it is not re-matched
search_from = match_start + replaced.length
@@ -165,6 +174,22 @@ def style=(identifier)
private
+ # The w:t read by #text (those of the runs and hyperlink runs), in
+ # document order.
+ TEXT_XPATH = 'w:r/w:t|w:hyperlink/w:r/w:t'
+ # Line breaks and tabs that sit between those w:t.
+ 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
+
+ # Remove the line breaks and tabs lying between two w:t of a match.
+ def remove_separators_between(first_t, last_t)
+ separators = @node.xpath(SEPARATOR_XPATH).to_a
+ nodes = @node.xpath("#{TEXT_XPATH}|#{SEPARATOR_XPATH}").to_a
+ from = nodes.index(first_t)
+ to = nodes.index(last_t)
+ nodes[(from + 1)...to].each { |node| node.remove if separators.include?(node) }
+ 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 5a0a37b..e5db7df 100644
--- a/spec/docx/containers/paragraph_spec.rb
+++ b/spec/docx/containers/paragraph_spec.rb
@@ -140,6 +140,84 @@ def paragraph(xml, document_properties = {}, document = nil)
it 'returns self' do
expect(split.substitute('{{first_name}}', 'World')).to equal(split)
end
+
+ it 'keeps the text after the match in its own run and formatting' do
+ p = paragraph('{{name}} after')
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(p.text_runs.map(&:text)).to eq(['Jane', ' after'])
+ expect(p.text_runs[1]).to be_italicized
+ end
+
+ # Regression tests for #187: files saved by LibreOffice or textutil keep
+ # several w:t in one run (around w:br / w:tab), and a hyperlink's runs are
+ # read as one TextRun. The substitution must work on the w:t nodes.
+ context 'with several w:t in a run or a hyperlink (#187)' do
+ # The paragraph content in document order, breaks and tabs included.
+ def layout(p)
+ p.node.xpath('.//w:t|.//w:br|.//w:tab').map { |n| n.name == 't' ? n.text : "<#{n.name}>" }
+ end
+
+ it 'replaces a placeholder sitting before a line break in the same run' do
+ p = paragraph('Name: {{name}}Next line')
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(layout(p)).to eq(['Name: Jane', '
', 'Next line'])
+ end
+
+ it 'replaces a split placeholder whose last run also holds a line break' do
+ p = paragraph(<<~XML)
+ Name: {{name}}Next line
+ XML
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(p.text).to eq('Name: JaneNext line')
+ expect(layout(p)).to eq(['Name: Jane', '', '
', 'Next line'])
+ end
+
+ it 'drops a tab that lies inside the placeholder' do
+ p = paragraph(<<~XML)
+ A {{name}} B
+ XML
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(p.text).to eq('A Jane B')
+ expect(layout(p)).not_to include('')
+ end
+
+ it 'keeps breaks and tabs outside the placeholder' do
+ p = paragraph('a{{name}}b')
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(layout(p)).to eq(['a', '', 'Jane', '', '
', 'b'])
+ end
+
+ it 'replaces a placeholder that starts inside a hyperlink' do
+ p = paragraph(<<~XML)
+ See site {{name}} end
+ XML
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(p.text).to eq('See site Jane end')
+ end
+
+ it 'keeps the space after the placeholder through a save' do
+ p = paragraph('{{name}} end')
+
+ p.substitute('{{name}}', 'Jane')
+
+ expect(p.text).to eq('Jane end')
+ p.node.xpath('.//w:t').select { |t| t.text.match?(/\A\s|\s\z/) }.each do |t|
+ expect(t.to_xml).to include('xml:space="preserve"')
+ end
+ end
+ end
end
describe 'alignment' do