From b982713c0813d2db2c6db26fa293449c2598c403 Mon Sep 17 00:00:00 2001 From: Tatsuya Sato Date: Sun, 18 Oct 2026 02:02:33 +0900 Subject: [PATCH] Make TextRun#text= work on runs with several w:t (#197) TextRun#text= only wrote when the run had exactly one w:t (or none), so on runs written by LibreOffice/textutil ("texttext") and on hyperlinks made of several runs it silently did nothing. That broke Paragraph#text= on such single-run paragraphs and bookmark insertion. text= now replaces the text from the first through the last w:t: the content goes into the first w:t, and the other w:t and the line breaks and tabs between them are removed. Breaks outside that range, such as a trailing page break, and the run properties are kept. A run with a single w:t behaves as before. Bookmark#insert_text_before/after no longer go through text=, which would now drop the adjacent run's breaks. They write into the w:t right next to the bookmark instead (the end of the preceding run or the start of the following one), adding a new w:t when the run ends or starts with a break or tab. This also fixes text being inserted on the wrong side of such a break. Move remove_separators_between into Container so Paragraph and TextRun share it. Co-Authored-By: Claude Opus 5.5 --- lib/docx/containers/container.rb | 13 ++++++++ lib/docx/containers/paragraph.rb | 11 +------ lib/docx/containers/text_run.rb | 31 +++++++++++++++---- lib/docx/elements/bookmark.rb | 38 ++++++++++++++++++++--- spec/docx/containers/paragraph_spec.rb | 9 ++++++ spec/docx/containers/text_run_spec.rb | 39 ++++++++++++++++++++++++ spec/docx/elements/bookmark_spec.rb | 42 ++++++++++++++++++++++++++ 7 files changed, 163 insertions(+), 20 deletions(-) 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)