From 9f384a2d0a142a375b3fed226d71c7d5c6c43f59 Mon Sep 17 00:00:00 2001 From: Tatsuya Sato Date: Sun, 31 May 2026 20:40:19 +0900 Subject: [PATCH] Scan bookmarks in headers and footers Document#bookmarks previously only scanned the document body, so bookmarks placed in headers/footers were invisible and couldn't be edited. This extends the scan to headers and footers (keyed by name, like the body). Combined with header/footer write-back (#175), the existing Bookmark insertion API now works for headers and footers and persists on save. Based on the original header-bookmark idea by @bramj in #22 (extended here to cover footers as well). Co-authored-by: bramj Co-Authored-By: Claude Opus 4.8 --- lib/docx/document.rb | 3 +++ spec/docx/document_spec.rb | 36 +++++++++++++++++++++++++ spec/fixtures/multi_doc_bookmarks.docx | Bin 0 -> 6147 bytes 3 files changed, 39 insertions(+) create mode 100644 spec/fixtures/multi_doc_bookmarks.docx diff --git a/lib/docx/document.rb b/lib/docx/document.rb index 551eee0..bd2d659 100755 --- a/lib/docx/document.rb +++ b/lib/docx/document.rb @@ -70,6 +70,9 @@ def paragraphs def bookmarks bkmrks_hsh = {} bkmrks_ary = @doc.xpath('//w:bookmarkStart').map { |b_node| parse_bookmark_from b_node } + # also scan headers and footers so their bookmarks can be read and edited + bkmrks_ary += headers.values.flat_map { |h| h.xpath('//w:bookmarkStart').map { |b_node| parse_bookmark_from b_node } } + bkmrks_ary += footers.values.flat_map { |f| f.xpath('//w:bookmarkStart').map { |b_node| parse_bookmark_from b_node } } # auto-generated by office 2010 bkmrks_ary.reject! { |b| b.name == '_GoBack' } bkmrks_ary.each { |b| bkmrks_hsh[b.name] = b } diff --git a/spec/docx/document_spec.rb b/spec/docx/document_spec.rb index d942bc4..d6144ef 100755 --- a/spec/docx/document_spec.rb +++ b/spec/docx/document_spec.rb @@ -105,6 +105,42 @@ end end + describe 'bookmarks in headers and footers' do + before do + @doc = Docx::Document.open(@fixtures_path + '/multi_doc_bookmarks.docx') + @new_doc_path = @fixtures_path + '/multi_doc_bookmarks_saved.docx' + end + + after do + File.delete(@new_doc_path) if File.exist?(@new_doc_path) + end + + it 'includes bookmarks found in headers and footers' do + expect(@doc.bookmarks['header_bookmark']).to_not be_nil + expect(@doc.bookmarks['footer_bookmark']).to_not be_nil + end + + it 'allows inserting text at a header bookmark' do + @doc.bookmarks['header_bookmark'].insert_text_after('Inserted ') + expect(@doc.headers['header1'].text).to eq 'Inserted Hello from the header.' + end + + it 'allows inserting text at a footer bookmark' do + @doc.bookmarks['footer_bookmark'].insert_text_after('Inserted ') + expect(@doc.footers['footer1'].text).to eq 'Inserted Hello from the footer.' + end + + it 'persists header and footer bookmark edits after save' do + @doc.bookmarks['header_bookmark'].insert_text_after('Inserted ') + @doc.bookmarks['footer_bookmark'].insert_text_after('Inserted ') + @doc.save(@new_doc_path) + + reopened = Docx::Document.open(@new_doc_path) + expect(reopened.headers['header1'].text).to eq 'Inserted Hello from the header.' + expect(reopened.footers['footer1'].text).to eq 'Inserted Hello from the footer.' + end + end + describe 'read tables' do before do @doc = Docx::Document.open(@fixtures_path + '/tables.docx') diff --git a/spec/fixtures/multi_doc_bookmarks.docx b/spec/fixtures/multi_doc_bookmarks.docx new file mode 100644 index 0000000000000000000000000000000000000000..fa1e25a187dd11a0f2d3a3b69dac3001e9e4e8a9 GIT binary patch literal 6147 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