Skip to content

Commit c45ba97

Browse files
authored
Merge pull request #200 from ruby-docx/fix-197-text-run-multiple-t
Make TextRun#text= work on runs with several w:t
2 parents d1339a2 + b982713 commit c45ba97

7 files changed

Lines changed: 163 additions & 20 deletions

File tree

‎lib/docx/containers/container.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ def blank!
1818
def remove!
1919
@node.remove
2020
end
21+
22+
private
23+
24+
# Remove the line breaks and tabs (nodes matched by +separator_xpath+)
25+
# lying between the w:t nodes +first_t+ and +last_t+, which are matched
26+
# by +text_xpath+.
27+
def remove_separators_between(first_t, last_t, text_xpath, separator_xpath)
28+
separators = @node.xpath(separator_xpath).to_a
29+
nodes = @node.xpath("#{text_xpath}|#{separator_xpath}").to_a
30+
from = nodes.index(first_t)
31+
to = nodes.index(last_t)
32+
nodes[(from + 1)...to].each { |node| node.remove if separators.include?(node) }
33+
end
2134
end
2235
end
2336
end

‎lib/docx/containers/paragraph.rb‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def substitute(pattern, replacement)
118118
texts[first].content = head + replaced
119119
texts[(first + 1)...last].each { |text| text.content = '' }
120120
texts[last].content = tail
121-
remove_separators_between(texts[first].node, texts[last].node)
121+
remove_separators_between(texts[first].node, texts[last].node, TEXT_XPATH, SEPARATOR_XPATH)
122122
end
123123

124124
# advance past the inserted replacement so it is not re-matched
@@ -181,15 +181,6 @@ def style=(identifier)
181181
SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:r/w:#{tag}|w:hyperlink/w:r/w:#{tag}" }.join('|')
182182
private_constant :TEXT_XPATH, :SEPARATOR_XPATH
183183

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-
193184
def style_property
194185
properties&.at_xpath('w:pStyle') || properties&.add_child('<w:pStyle/>').first
195186
end

‎lib/docx/containers/text_run.rb‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ class TextRun
1414
strike: false
1515
}
1616

17+
# The w:t of the run, or of the runs of a hyperlink, in document order.
18+
TEXT_XPATH = 'w:t|w:r/w:t'
19+
# Line breaks and tabs that sit between those w:t.
20+
SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:#{tag}|w:r/w:#{tag}" }.join('|')
21+
private_constant :TEXT_XPATH, :SEPARATOR_XPATH
22+
1723
def self.tag
1824
'r'
1925
end
@@ -23,8 +29,7 @@ def self.tag
2329

2430
def initialize(node, document_properties = {})
2531
@node = node
26-
@text_nodes = @node.xpath('w:t').map {|t_node| Elements::Text.new(t_node) }
27-
@text_nodes = @node.xpath('w:t|w:r/w:t').map {|t_node| Elements::Text.new(t_node) }
32+
@text_nodes = @node.xpath(TEXT_XPATH).map {|t_node| Elements::Text.new(t_node) }
2833

2934
@properties_tag = 'rPr'
3035
@text = parse_text || ''
@@ -33,14 +38,28 @@ def initialize(node, document_properties = {})
3338
@font_size = @document_properties[:font_size]
3439
end
3540

36-
# Set text of text run
41+
# Set text of text run.
42+
#
43+
# The text from the first through the last w:t is replaced: the content
44+
# goes into the first w:t, and the other w:t and the line breaks and
45+
# tabs between them are removed (#text never includes breaks or tabs,
46+
# so a plain string cannot say where they belong). Anything before the
47+
# first or after the last w:t, such as a trailing page break, is kept.
48+
# To replace part of a paragraph while keeping its line breaks in place,
49+
# use Paragraph#substitute.
3750
def text=(content)
38-
if @text_nodes.size == 1
39-
@text_nodes.first.content = content
40-
elsif @text_nodes.empty?
51+
if @text_nodes.empty?
4152
new_t = Elements::Text.create_within(self)
4253
new_t.content = content
4354
@text_nodes << new_t
55+
else
56+
first, *rest = @text_nodes
57+
first.content = content
58+
unless rest.empty?
59+
remove_separators_between(first.node, rest.last.node, TEXT_XPATH, SEPARATOR_XPATH)
60+
rest.each { |text_node| text_node.node.remove }
61+
@text_nodes = [first]
62+
end
4463
end
4564
reset_text
4665
end

‎lib/docx/elements/bookmark.rb‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,12 @@ def initialize(node)
1717

1818
# Insert text before bookmarkStart node
1919
def insert_text_before(text)
20-
text_run = get_run_before
21-
text_run.text = "#{text_run.text}#{text}"
20+
append_to_run(get_run_before, text)
2221
end
2322

2423
# Insert text after bookmarkStart node
2524
def insert_text_after(text)
26-
text_run = get_run_after
27-
text_run.text = "#{text}#{text_run.text}"
25+
prepend_to_run(get_run_after, text)
2826
end
2927

3028
# insert multiple lines starting with paragraph containing bookmark node.
@@ -103,6 +101,38 @@ def parent_paragraph
103101

104102
private
105103

104+
# A run's text, line breaks and tabs, in document order.
105+
RUN_CONTENT_XPATH = 'w:t|w:br|w:tab|w:cr'
106+
private_constant :RUN_CONTENT_XPATH
107+
108+
# Add text at the very end of the run, which sits just before the
109+
# bookmark: to its last w:t, or to a new w:t when the run ends with a
110+
# break or tab. The rest of the run is left as it is (#197).
111+
def append_to_run(run, text)
112+
last = run.node.xpath(RUN_CONTENT_XPATH).last
113+
if last && run.node.xpath('w:t').include?(last)
114+
Text.new(last).content = last.content + text
115+
else
116+
Text.create_within(run).content = text
117+
end
118+
end
119+
120+
# Add text at the very start of the run, which sits just after the
121+
# bookmark: to its first w:t, or to a new w:t when the run starts with
122+
# a break or tab. The rest of the run is left as it is (#197).
123+
def prepend_to_run(run, text)
124+
first = run.node.xpath(RUN_CONTENT_XPATH).first
125+
if first && run.node.xpath('w:t').include?(first)
126+
Text.new(first).content = text + first.content
127+
elsif first
128+
new_t = Text.create_with(run)
129+
new_t.node = first.add_previous_sibling(new_t.node)
130+
new_t.content = text
131+
else
132+
Text.create_within(run).content = text
133+
end
134+
end
135+
106136
# The w:p the bookmark sits inside, or nil when it is block-level.
107137
def enclosing_paragraph
108138
@node.at_xpath("./parent::w:p")

‎spec/docx/containers/paragraph_spec.rb‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ def paragraph(xml, document_properties = {}, document = nil)
5656

5757
expect(p.text_runs.map(&:text)).to eq(['new'])
5858
end
59+
60+
# Regression test for #197
61+
it 'replaces the text of a single run holding several w:t' do
62+
p = paragraph('<w:p><w:r><w:t>Name: Jane</w:t><w:br/><w:t>Next line</w:t></w:r></w:p>')
63+
64+
p.text = 'new'
65+
66+
expect(p.text).to eq('new')
67+
end
5968
end
6069

6170
describe '#text_runs' do

‎spec/docx/containers/text_run_spec.rb‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,45 @@ def run(xml, document_properties = {})
6969
expect(text_run.text).to eq('second')
7070
expect(node.xpath('w:t').map(&:text)).to eq(['second'])
7171
end
72+
73+
# Regression tests for #197
74+
context 'when the run has several w:t' do
75+
# The run content in document order, breaks and tabs included.
76+
def layout(node)
77+
node.xpath('.//w:t|.//w:br|.//w:tab|.//w:cr').map { |n| n.name == 't' ? n.text : "<#{n.name}>" }
78+
end
79+
80+
it 'replaces the text from the first through the last w:t, dropping the breaks and tabs between them' do
81+
node = w_node('<w:r><w:rPr><w:b/></w:rPr><w:t>a</w:t><w:br/><w:t>b</w:t><w:tab/><w:t>c</w:t></w:r>')
82+
text_run = described_class.new(node)
83+
84+
text_run.text = 'new'
85+
86+
expect(text_run.text).to eq('new')
87+
expect(layout(node)).to eq(['new'])
88+
expect(text_run).to be_bolded
89+
end
90+
91+
it 'keeps breaks outside the text, such as a trailing page break' do
92+
node = w_node('<w:r><w:br/><w:t>a</w:t><w:cr/><w:t>b</w:t><w:br w:type="page"/></w:r>')
93+
text_run = described_class.new(node)
94+
95+
text_run.text = 'new'
96+
97+
expect(layout(node)).to eq(['<br>', 'new', '<br>'])
98+
expect(node.at_xpath('w:br[@w:type="page"]')).not_to be_nil
99+
end
100+
101+
it 'replaces the text of a hyperlink made of several runs' do
102+
node = w_node('<w:hyperlink r:id="rId1"><w:r><w:t>site </w:t></w:r><w:r><w:t>link</w:t></w:r></w:hyperlink>')
103+
text_run = described_class.new(node)
104+
105+
text_run.text = 'new'
106+
107+
expect(text_run.text).to eq('new')
108+
expect(layout(node)).to eq(['new'])
109+
end
110+
end
72111
end
73112

74113
describe '#substitute' do

‎spec/docx/elements/bookmark_spec.rb‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,48 @@ def paragraph_texts(body)
6161
end
6262
end
6363

64+
# Regression tests for #197: the run next to the bookmark may hold several
65+
# w:t around line breaks or tabs (as LibreOffice writes). Inserting must
66+
# write next to the bookmark and leave the rest of that run alone.
67+
context 'when the adjacent run holds several w:t' do
68+
def layout(body)
69+
body.xpath('.//w:t|.//w:br|.//w:tab').map { |n| n.name == 't' ? n.text : "<#{n.name}>" }
70+
end
71+
72+
it 'inserts after the bookmark at the start of the following run' do
73+
body = w_body('<w:p><w:bookmarkStart w:id="0" w:name="b"/><w:r><w:t>a</w:t><w:br/><w:t>b</w:t></w:r></w:p>')
74+
75+
bookmark_in(body).insert_text_after('X')
76+
77+
expect(layout(body)).to eq(['Xa', '<br>', 'b'])
78+
end
79+
80+
it 'inserts before the bookmark at the end of the preceding run' do
81+
body = w_body('<w:p><w:r><w:t>a</w:t><w:tab/><w:t>b</w:t></w:r><w:bookmarkStart w:id="0" w:name="b"/></w:p>')
82+
83+
bookmark_in(body).insert_text_before('X')
84+
85+
expect(layout(body)).to eq(['a', '<tab>', 'bX'])
86+
end
87+
88+
it 'inserts after a break that ends the preceding run' do
89+
body = w_body('<w:p><w:r><w:t>a</w:t><w:br/></w:r><w:bookmarkStart w:id="0" w:name="b"/></w:p>')
90+
91+
bookmark_in(body).insert_text_before('X')
92+
93+
expect(layout(body)).to eq(['a', '<br>', 'X'])
94+
end
95+
96+
it 'inserts before a break that starts the following run' do
97+
body = w_body('<w:p><w:bookmarkStart w:id="0" w:name="b"/><w:r><w:rPr><w:b/></w:rPr><w:br/><w:t>a</w:t></w:r></w:p>')
98+
99+
bookmark_in(body).insert_text_after('X')
100+
101+
expect(layout(body)).to eq(['X', '<br>', 'a'])
102+
expect(body.at_xpath('.//w:r').element_children.first.name).to eq('rPr')
103+
end
104+
end
105+
64106
describe '#insert_multiple_lines' do
65107
it 'replaces the paragraph text and adds a paragraph per extra line' do
66108
body = w_body(<<~XML)

0 commit comments

Comments
 (0)