Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/docx/containers/container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 1 addition & 10 deletions lib/docx/containers/paragraph.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('<w:pStyle/>').first
end
Expand Down
31 changes: 25 additions & 6 deletions lib/docx/containers/text_run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 || ''
Expand All @@ -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
Expand Down
38 changes: 34 additions & 4 deletions lib/docx/elements/bookmark.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions spec/docx/containers/paragraph_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('<w:p><w:r><w:t>Name: Jane</w:t><w:br/><w:t>Next line</w:t></w:r></w:p>')

p.text = 'new'

expect(p.text).to eq('new')
end
end

describe '#text_runs' do
Expand Down
39 changes: 39 additions & 0 deletions spec/docx/containers/text_run_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('<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>')
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('<w:r><w:br/><w:t>a</w:t><w:cr/><w:t>b</w:t><w:br w:type="page"/></w:r>')
text_run = described_class.new(node)

text_run.text = 'new'

expect(layout(node)).to eq(['<br>', 'new', '<br>'])
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('<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>')
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
Expand Down
42 changes: 42 additions & 0 deletions spec/docx/elements/bookmark_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('<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>')

bookmark_in(body).insert_text_after('X')

expect(layout(body)).to eq(['Xa', '<br>', 'b'])
end

it 'inserts before the bookmark at the end of the preceding run' do
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>')

bookmark_in(body).insert_text_before('X')

expect(layout(body)).to eq(['a', '<tab>', 'bX'])
end

it 'inserts after a break that ends the preceding run' do
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>')

bookmark_in(body).insert_text_before('X')

expect(layout(body)).to eq(['a', '<br>', 'X'])
end

it 'inserts before a break that starts the following run' do
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>')

bookmark_in(body).insert_text_after('X')

expect(layout(body)).to eq(['X', '<br>', '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)
Expand Down
Loading