Skip to content

Commit e083d0b

Browse files
authored
Merge pull request #184 from ruby-docx/fix-111-bookmark-outside-paragraph
Insert bookmark text into a paragraph for block-level bookmarks (#111)
2 parents 0899367 + 43e2b7b commit e083d0b

3 files changed

Lines changed: 118 additions & 12 deletions

File tree

lib/docx/elements/bookmark.rb

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,92 @@ def insert_multiple_lines(text_array)
5151

5252
# Get text run immediately prior to bookmark node
5353
def get_run_before
54-
# at_xpath returns the first match found and preceding-sibling returns siblings in the
55-
# order they appear in the document not the order as they appear when moving out from
56-
# the starting node
57-
if not (r_nodes = @node.xpath("./preceding-sibling::w:r")).empty?
58-
r_node = r_nodes.last
59-
Containers::TextRun.new(r_node)
60-
else
54+
if enclosing_paragraph
55+
# at_xpath returns the first match found and preceding-sibling returns siblings in the
56+
# order they appear in the document not the order as they appear when moving out from
57+
# the starting node
58+
unless (r_nodes = @node.xpath("./preceding-sibling::w:r")).empty?
59+
return Containers::TextRun.new(r_nodes.last)
60+
end
61+
6162
new_r = Containers::TextRun.create_with(self)
6263
new_r.insert_before(self)
63-
new_r
64+
return new_r
6465
end
66+
67+
# Block-level bookmark (e.g. Google Docs places bookmarkStart/End directly
68+
# under w:body). Add a run to the preceding paragraph, or to a new
69+
# paragraph before the bookmark, so the run lives inside a w:p.
70+
run_at_paragraph_end(@node.xpath("./preceding-sibling::w:p").last) ||
71+
run_in_new_paragraph { |paragraph| @node.add_previous_sibling(paragraph) }
6572
end
6673

6774
# Get text run immediately after bookmark node
6875
def get_run_after
69-
if (r_node = @node.at_xpath("./following-sibling::w:r"))
70-
Containers::TextRun.new(r_node)
71-
else
76+
if enclosing_paragraph
77+
if (r_node = @node.at_xpath("./following-sibling::w:r"))
78+
return Containers::TextRun.new(r_node)
79+
end
80+
7281
new_r = Containers::TextRun.create_with(self)
7382
new_r.insert_after(self)
74-
new_r
83+
return new_r
84+
end
85+
86+
# Block-level bookmark: add a run to the following paragraph, or to a new
87+
# paragraph after the bookmark.
88+
run_at_paragraph_start(@node.at_xpath("./following-sibling::w:p")) ||
89+
run_in_new_paragraph { |paragraph| @node.add_next_sibling(paragraph) }
90+
end
91+
92+
# Override Element#parent_paragraph so insert_multiple_lines also works for
93+
# block-level bookmarks. For those we fill a fresh paragraph inserted at the
94+
# bookmark, rather than an adjacent existing paragraph, so we neither crash
95+
# (no enclosing paragraph) nor overwrite unrelated content.
96+
def parent_paragraph
97+
return Containers::Paragraph.new(enclosing_paragraph) if enclosing_paragraph
98+
99+
paragraph = Nokogiri::XML::Node.new("w:p", @node.document)
100+
@node.add_next_sibling(paragraph)
101+
Containers::Paragraph.new(paragraph)
102+
end
103+
104+
private
105+
106+
# The w:p the bookmark sits inside, or nil when it is block-level.
107+
def enclosing_paragraph
108+
@node.at_xpath("./parent::w:p")
109+
end
110+
111+
# A new run inserted at the start of paragraph_node (after w:pPr if present).
112+
# Returns nil when paragraph_node is nil.
113+
def run_at_paragraph_start(paragraph_node)
114+
return nil unless paragraph_node
115+
116+
new_r = Containers::TextRun.create_with(self)
117+
if (props = paragraph_node.at_xpath("w:pPr"))
118+
props.add_next_sibling(new_r.node)
119+
else
120+
paragraph_node.prepend_child(new_r.node)
75121
end
122+
new_r
123+
end
124+
125+
# A new run appended to the end of paragraph_node. nil when it is nil.
126+
def run_at_paragraph_end(paragraph_node)
127+
return nil unless paragraph_node
128+
129+
Containers::TextRun.create_within(Containers::Paragraph.new(paragraph_node))
130+
end
131+
132+
# Create a run wrapped in a fresh w:p; the block positions that paragraph
133+
# relative to the bookmark.
134+
def run_in_new_paragraph
135+
new_r = Containers::TextRun.create_with(self)
136+
paragraph = Nokogiri::XML::Node.new("w:p", @node.document)
137+
paragraph.add_child(new_r.node)
138+
yield paragraph
139+
new_r
76140
end
77141
end
78142
end

spec/docx/document_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,48 @@
153153
end
154154
end
155155

156+
# Regression test for #111: some generators (e.g. Google Docs) place
157+
# w:bookmarkStart/End directly under w:body, outside any w:p. Inserting text at
158+
# such a bookmark must land inside a paragraph (otherwise the run is orphaned
159+
# and silently lost).
160+
describe 'bookmarks placed outside a paragraph (e.g. Google Docs)' do
161+
before do
162+
@doc = Docx::Document.open(@fixtures_path + '/bookmark_outside_paragraph.docx')
163+
@new_path = @fixtures_path + '/bookmark_outside_paragraph_saved.docx'
164+
end
165+
166+
after do
167+
File.delete(@new_path) if File.exist?(@new_path)
168+
end
169+
170+
it 'finds the block-level bookmark' do
171+
expect(@doc.bookmarks['bookmark_1']).to_not be_nil
172+
expect(@doc.paragraphs.map(&:text)).to eq(['bookmark_1'])
173+
end
174+
175+
it 'inserts text after the bookmark into the following paragraph' do
176+
@doc.bookmarks['bookmark_1'].insert_text_after('INSERTED ')
177+
expect(@doc.paragraphs.map(&:text)).to eq(['INSERTED bookmark_1'])
178+
end
179+
180+
it 'inserts text before the bookmark as a new leading paragraph' do
181+
@doc.bookmarks['bookmark_1'].insert_text_before('BEFORE')
182+
expect(@doc.paragraphs.map(&:text)).to eq(['BEFORE', 'bookmark_1'])
183+
end
184+
185+
it 'inserts multiple lines at the bookmark without destroying the following paragraph' do
186+
@doc.bookmarks['bookmark_1'].insert_multiple_lines(['line1', 'line2', 'line3'])
187+
expect(@doc.paragraphs.map(&:text)).to eq(['line1', 'line2', 'line3', 'bookmark_1'])
188+
end
189+
190+
it 'persists inserted text after save' do
191+
@doc.bookmarks['bookmark_1'].insert_text_after('INSERTED ')
192+
@doc.save(@new_path)
193+
reopened = Docx::Document.open(@new_path)
194+
expect(reopened.paragraphs.map(&:text)).to eq(['INSERTED bookmark_1'])
195+
end
196+
end
197+
156198
describe 'read tables' do
157199
before do
158200
@doc = Docx::Document.open(@fixtures_path + '/tables.docx')
13.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)