@@ -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
0 commit comments