Skip to content

Commit 56bec8b

Browse files
authored
Merge pull request #181 from ruby-docx/fix-147-cross-run-substitution
Add Paragraph#substitute for placeholders split across runs (#147)
2 parents a74e2cf + 69afc79 commit 56bec8b

4 files changed

Lines changed: 95 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ doc.paragraphs.each do |p|
154154
end
155155
end
156156

157+
# Substitute a placeholder even when Word has split it across several runs
158+
# (e.g. "{{first_name}}" stored as "{{fi", "rst_na", "me}}"). Paragraph#substitute
159+
# matches across run boundaries, where the per-run TextRun#substitute above cannot.
160+
# Accepts a String or a Regexp (capture-group backreferences work in the replacement).
161+
doc.paragraphs.each do |p|
162+
p.substitute('{{first_name}}', 'Jane')
163+
p.substitute(/\{\{(\w+)\}\}/, 'value of \1')
164+
end
165+
157166
# Substitute text with access to captures, note block arg is a MatchData, a bit
158167
# different than String.gsub. https://ruby-doc.org/3.3.7/MatchData.html
159168
doc.paragraphs.each do |p|

lib/docx/containers/paragraph.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,59 @@ def each_text_run
6565
text_runs.each { |tr| yield(tr) }
6666
end
6767

68+
# Substitute text within the paragraph, even when a match spans multiple
69+
# text runs (e.g. a "{{placeholder}}" that Word split across several runs,
70+
# such as "{{fi", "rst_na", "me}}"). The per-run TextRun#substitute cannot
71+
# match those, but this can, because it joins the runs first.
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.
76+
#
77+
# +pattern+ may be a String or a Regexp; +replacement+ follows String#sub
78+
# semantics, so capture-group backreferences (e.g. '\1') work with a Regexp.
79+
#
80+
# # given a paragraph reading "Hello {{first_name}}!"
81+
# paragraph.substitute('{{first_name}}', 'Jane') # => "Hello Jane!"
82+
# paragraph.substitute(/\{\{(\w+)\}\}/, 'value of \1')
83+
#
84+
# See https://github.com/ruby-docx/docx/issues/147
85+
def substitute(pattern, replacement)
86+
search_from = 0
87+
loop do
88+
runs = text_runs
89+
break if runs.empty?
90+
91+
offsets = []
92+
cursor = 0
93+
runs.each do |run|
94+
offsets << cursor
95+
cursor += run.text.length
96+
end
97+
full_text = runs.map(&:text).join
98+
99+
match = full_text.match(pattern, search_from)
100+
break unless match
101+
break if match.end(0) == match.begin(0) # ignore empty matches
102+
103+
match_start = match.begin(0)
104+
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 = '' }
114+
115+
# advance past the inserted replacement so it is not re-matched
116+
search_from = match_start + replaced.length
117+
end
118+
self
119+
end
120+
68121
def aligned_left?
69122
['left', nil].include?(alignment)
70123
end

spec/docx/document_spec.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,39 @@
317317
end
318318
end
319319

320+
# Regression test for #147: a placeholder split across multiple text runs
321+
# (as Word often produces) must be replaceable.
322+
describe 'substituting a placeholder split across runs' do
323+
before do
324+
@doc = Docx::Document.open(@fixtures_path + '/split_placeholder.docx')
325+
@paragraph = @doc.paragraphs.find { |p| p.text.include?('{{') }
326+
end
327+
328+
it 'has the placeholder split across several runs' do
329+
expect(@paragraph.text).to eq('Hello {{first_name}}!')
330+
expect(@paragraph.text_runs.map(&:text)).to eq(['Hello ', '{{fi', 'rst_na', 'me}}', '!'])
331+
end
332+
333+
it 'replaces the placeholder via Paragraph#substitute, preserving surrounding text' do
334+
@paragraph.substitute('{{first_name}}', 'World')
335+
expect(@paragraph.text).to eq('Hello World!')
336+
end
337+
338+
it 'supports regex patterns and capture groups' do
339+
@paragraph.substitute(/\{\{(\w+)\}\}/, 'name=\1')
340+
expect(@paragraph.text).to eq('Hello name=first_name!')
341+
end
342+
343+
it 'persists the substitution after save' do
344+
@paragraph.substitute('{{first_name}}', 'World')
345+
new_path = @fixtures_path + '/split_placeholder_saved.docx'
346+
@doc.save(new_path)
347+
reopened = Docx::Document.open(new_path)
348+
expect(reopened.paragraphs.find { |p| p.text.include?('Hello') }.text).to eq('Hello World!')
349+
File.delete(new_path) if File.exist?(new_path)
350+
end
351+
end
352+
320353
describe 'read formatting' do
321354
before do
322355
@doc = Docx::Document.open(@fixtures_path + '/formatting.docx')
13.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)