diff --git a/lib/docx/containers/container.rb b/lib/docx/containers/container.rb
index 510bb9b..c164e38 100644
--- a/lib/docx/containers/container.rb
+++ b/lib/docx/containers/container.rb
@@ -18,6 +18,19 @@ def blank!
def remove!
@node.remove
end
+
+ private
+
+ # Remove the line breaks and tabs (nodes matched by +separator_xpath+)
+ # lying between the w:t nodes +first_t+ and +last_t+, which are matched
+ # by +text_xpath+.
+ def remove_separators_between(first_t, last_t, text_xpath, separator_xpath)
+ 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
end
end
end
diff --git a/lib/docx/containers/paragraph.rb b/lib/docx/containers/paragraph.rb
index 24849ac..739c771 100755
--- a/lib/docx/containers/paragraph.rb
+++ b/lib/docx/containers/paragraph.rb
@@ -118,7 +118,7 @@ def substitute(pattern, replacement)
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)
+ remove_separators_between(texts[first].node, texts[last].node, TEXT_XPATH, SEPARATOR_XPATH)
end
# advance past the inserted replacement so it is not re-matched
@@ -181,15 +181,6 @@ 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
- # 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/lib/docx/containers/text_run.rb b/lib/docx/containers/text_run.rb
index 1e3c58a..0597bfa 100755
--- a/lib/docx/containers/text_run.rb
+++ b/lib/docx/containers/text_run.rb
@@ -14,6 +14,12 @@ class TextRun
strike: false
}
+ # The w:t of the run, or of the runs of a hyperlink, in document order.
+ TEXT_XPATH = 'w:t|w:r/w:t'
+ # Line breaks and tabs that sit between those w:t.
+ SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:#{tag}|w:r/w:#{tag}" }.join('|')
+ private_constant :TEXT_XPATH, :SEPARATOR_XPATH
+
def self.tag
'r'
end
@@ -23,8 +29,7 @@ def self.tag
def initialize(node, document_properties = {})
@node = node
- @text_nodes = @node.xpath('w:t').map {|t_node| Elements::Text.new(t_node) }
- @text_nodes = @node.xpath('w:t|w:r/w:t').map {|t_node| Elements::Text.new(t_node) }
+ @text_nodes = @node.xpath(TEXT_XPATH).map {|t_node| Elements::Text.new(t_node) }
@properties_tag = 'rPr'
@text = parse_text || ''
@@ -33,14 +38,28 @@ def initialize(node, document_properties = {})
@font_size = @document_properties[:font_size]
end
- # Set text of text run
+ # Set text of text run.
+ #
+ # The text from the first through the last w:t is replaced: the content
+ # goes into the first w:t, and the other w:t and the line breaks and
+ # tabs between them are removed (#text never includes breaks or tabs,
+ # so a plain string cannot say where they belong). Anything before the
+ # first or after the last w:t, such as a trailing page break, is kept.
+ # To replace part of a paragraph while keeping its line breaks in place,
+ # use Paragraph#substitute.
def text=(content)
- if @text_nodes.size == 1
- @text_nodes.first.content = content
- elsif @text_nodes.empty?
+ if @text_nodes.empty?
new_t = Elements::Text.create_within(self)
new_t.content = content
@text_nodes << new_t
+ else
+ first, *rest = @text_nodes
+ first.content = content
+ unless rest.empty?
+ remove_separators_between(first.node, rest.last.node, TEXT_XPATH, SEPARATOR_XPATH)
+ rest.each { |text_node| text_node.node.remove }
+ @text_nodes = [first]
+ end
end
reset_text
end
diff --git a/lib/docx/elements/bookmark.rb b/lib/docx/elements/bookmark.rb
index 01f9171..6ac3ccb 100644
--- a/lib/docx/elements/bookmark.rb
+++ b/lib/docx/elements/bookmark.rb
@@ -17,14 +17,12 @@ def initialize(node)
# Insert text before bookmarkStart node
def insert_text_before(text)
- text_run = get_run_before
- text_run.text = "#{text_run.text}#{text}"
+ append_to_run(get_run_before, text)
end
# Insert text after bookmarkStart node
def insert_text_after(text)
- text_run = get_run_after
- text_run.text = "#{text}#{text_run.text}"
+ prepend_to_run(get_run_after, text)
end
# insert multiple lines starting with paragraph containing bookmark node.
@@ -103,6 +101,38 @@ def parent_paragraph
private
+ # A run's text, line breaks and tabs, in document order.
+ RUN_CONTENT_XPATH = 'w:t|w:br|w:tab|w:cr'
+ private_constant :RUN_CONTENT_XPATH
+
+ # Add text at the very end of the run, which sits just before the
+ # bookmark: to its last w:t, or to a new w:t when the run ends with a
+ # break or tab. The rest of the run is left as it is (#197).
+ def append_to_run(run, text)
+ last = run.node.xpath(RUN_CONTENT_XPATH).last
+ if last && run.node.xpath('w:t').include?(last)
+ Text.new(last).content = last.content + text
+ else
+ Text.create_within(run).content = text
+ end
+ end
+
+ # Add text at the very start of the run, which sits just after the
+ # bookmark: to its first w:t, or to a new w:t when the run starts with
+ # a break or tab. The rest of the run is left as it is (#197).
+ def prepend_to_run(run, text)
+ first = run.node.xpath(RUN_CONTENT_XPATH).first
+ if first && run.node.xpath('w:t').include?(first)
+ Text.new(first).content = text + first.content
+ elsif first
+ new_t = Text.create_with(run)
+ new_t.node = first.add_previous_sibling(new_t.node)
+ new_t.content = text
+ else
+ Text.create_within(run).content = text
+ end
+ end
+
# The w:p the bookmark sits inside, or nil when it is block-level.
def enclosing_paragraph
@node.at_xpath("./parent::w:p")
diff --git a/spec/docx/containers/paragraph_spec.rb b/spec/docx/containers/paragraph_spec.rb
index e5db7df..15d49bc 100644
--- a/spec/docx/containers/paragraph_spec.rb
+++ b/spec/docx/containers/paragraph_spec.rb
@@ -56,6 +56,15 @@ def paragraph(xml, document_properties = {}, document = nil)
expect(p.text_runs.map(&:text)).to eq(['new'])
end
+
+ # Regression test for #197
+ it 'replaces the text of a single run holding several w:t' do
+ p = paragraph('Name: JaneNext line')
+
+ p.text = 'new'
+
+ expect(p.text).to eq('new')
+ end
end
describe '#text_runs' do
diff --git a/spec/docx/containers/text_run_spec.rb b/spec/docx/containers/text_run_spec.rb
index 8092c9d..b157398 100644
--- a/spec/docx/containers/text_run_spec.rb
+++ b/spec/docx/containers/text_run_spec.rb
@@ -69,6 +69,45 @@ def run(xml, document_properties = {})
expect(text_run.text).to eq('second')
expect(node.xpath('w:t').map(&:text)).to eq(['second'])
end
+
+ # Regression tests for #197
+ context 'when the run has several w:t' do
+ # The run content in document order, breaks and tabs included.
+ def layout(node)
+ node.xpath('.//w:t|.//w:br|.//w:tab|.//w:cr').map { |n| n.name == 't' ? n.text : "<#{n.name}>" }
+ end
+
+ it 'replaces the text from the first through the last w:t, dropping the breaks and tabs between them' do
+ node = w_node('abc')
+ text_run = described_class.new(node)
+
+ text_run.text = 'new'
+
+ expect(text_run.text).to eq('new')
+ expect(layout(node)).to eq(['new'])
+ expect(text_run).to be_bolded
+ end
+
+ it 'keeps breaks outside the text, such as a trailing page break' do
+ node = w_node('ab')
+ text_run = described_class.new(node)
+
+ text_run.text = 'new'
+
+ expect(layout(node)).to eq(['
', 'new', '
'])
+ expect(node.at_xpath('w:br[@w:type="page"]')).not_to be_nil
+ end
+
+ it 'replaces the text of a hyperlink made of several runs' do
+ node = w_node('site link')
+ text_run = described_class.new(node)
+
+ text_run.text = 'new'
+
+ expect(text_run.text).to eq('new')
+ expect(layout(node)).to eq(['new'])
+ end
+ end
end
describe '#substitute' do
diff --git a/spec/docx/elements/bookmark_spec.rb b/spec/docx/elements/bookmark_spec.rb
index 568312f..30bf71b 100644
--- a/spec/docx/elements/bookmark_spec.rb
+++ b/spec/docx/elements/bookmark_spec.rb
@@ -61,6 +61,48 @@ def paragraph_texts(body)
end
end
+ # Regression tests for #197: the run next to the bookmark may hold several
+ # w:t around line breaks or tabs (as LibreOffice writes). Inserting must
+ # write next to the bookmark and leave the rest of that run alone.
+ context 'when the adjacent run holds several w:t' do
+ def layout(body)
+ body.xpath('.//w:t|.//w:br|.//w:tab').map { |n| n.name == 't' ? n.text : "<#{n.name}>" }
+ end
+
+ it 'inserts after the bookmark at the start of the following run' do
+ body = w_body('ab')
+
+ bookmark_in(body).insert_text_after('X')
+
+ expect(layout(body)).to eq(['Xa', '
', 'b'])
+ end
+
+ it 'inserts before the bookmark at the end of the preceding run' do
+ body = w_body('ab')
+
+ bookmark_in(body).insert_text_before('X')
+
+ expect(layout(body)).to eq(['a', '', 'bX'])
+ end
+
+ it 'inserts after a break that ends the preceding run' do
+ body = w_body('a')
+
+ bookmark_in(body).insert_text_before('X')
+
+ expect(layout(body)).to eq(['a', '
', 'X'])
+ end
+
+ it 'inserts before a break that starts the following run' do
+ body = w_body('a')
+
+ bookmark_in(body).insert_text_after('X')
+
+ expect(layout(body)).to eq(['X', '
', 'a'])
+ expect(body.at_xpath('.//w:r').element_children.first.name).to eq('rPr')
+ end
+ end
+
describe '#insert_multiple_lines' do
it 'replaces the paragraph text and adds a paragraph per extra line' do
body = w_body(<<~XML)