docx 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16df363293a0fcb4945e4ab7aa968f7138f2d0e38d25bcf689a5c5142279e520
4
- data.tar.gz: '089d8ef9c78a7ae13d980d3a78ce729b05fe6dff1fefe8bbdfe4488a6b7b0f91'
3
+ metadata.gz: d639e88e5950b94157fc4f81b14f0f8bf355abb4fdedd24b7f40dc00eafb72d7
4
+ data.tar.gz: e64400d25d919b4cd51f4b876d7f35c3433841523055866dff2c300f7c15aca7
5
5
  SHA512:
6
- metadata.gz: 8a23b154632578c3a702d3e16b6bbc3d763f062860ea9cf8db6c6814e89c1e7d99073fa28b7f90276c883d5c1f47be9d688d6f05177c06dbfc3bc452ccadc371
7
- data.tar.gz: a8a735936caad5c75f56cace55e980df01bb25af795d5b8d040b735b14510898b220da3b8f40c52b74c68dc371c9a2d25bc2791d44a54a74dde9db727d3432ae
6
+ metadata.gz: d2ad2931111374349dd03ca04bef655a9eaa797b84d688198ebeac84cdf93af147d4f8fd5a561c4e506d7f823c9b22884c698a315d7e2bb505357c7c6da9316a
7
+ data.tar.gz: 25cb0bc5f3b67b4af1874b902fbdfe3061205cee0c8b288187924b80cb50a6bd5a7076ea497be702a07241841da146055b3be0dbffeb90c20922f851ad31588c
data/README.md CHANGED
@@ -154,6 +154,15 @@ doc.paragraphs.each do |p|
154
154
  end
155
155
  end
156
156
 
157
+ # Substitute a placeholder even when Word has split it across several runs
158
+ # (e.g. "{{first_name}}" stored as "{{fi", "rst_na", "me}}"). Paragraph#substitute
159
+ # matches across run boundaries, where the per-run TextRun#substitute above cannot.
160
+ # Accepts a String or a Regexp (capture-group backreferences work in the replacement).
161
+ doc.paragraphs.each do |p|
162
+ p.substitute('{{first_name}}', 'Jane')
163
+ p.substitute(/\{\{(\w+)\}\}/, 'value of \1')
164
+ end
165
+
157
166
  # Substitute text with access to captures, note block arg is a MatchData, a bit
158
167
  # different than String.gsub. https://ruby-doc.org/3.3.7/MatchData.html
159
168
  doc.paragraphs.each do |p|
@@ -65,6 +65,59 @@ module Docx
65
65
  text_runs.each { |tr| yield(tr) }
66
66
  end
67
67
 
68
+ # Substitute text within the paragraph, even when a match spans multiple
69
+ # text runs (e.g. a "{{placeholder}}" that Word split across several runs,
70
+ # such as "{{fi", "rst_na", "me}}"). The per-run TextRun#substitute cannot
71
+ # match those, but this can, because it joins the runs first.
72
+ #
73
+ # The matched region is collapsed into the first run it touches, so that
74
+ # run's formatting is kept while the other spanned runs are emptied; runs
75
+ # outside the match are left untouched.
76
+ #
77
+ # +pattern+ may be a String or a Regexp; +replacement+ follows String#sub
78
+ # semantics, so capture-group backreferences (e.g. '\1') work with a Regexp.
79
+ #
80
+ # # given a paragraph reading "Hello {{first_name}}!"
81
+ # paragraph.substitute('{{first_name}}', 'Jane') # => "Hello Jane!"
82
+ # paragraph.substitute(/\{\{(\w+)\}\}/, 'value of \1')
83
+ #
84
+ # See https://github.com/ruby-docx/docx/issues/147
85
+ def substitute(pattern, replacement)
86
+ search_from = 0
87
+ loop do
88
+ runs = text_runs
89
+ break if runs.empty?
90
+
91
+ offsets = []
92
+ cursor = 0
93
+ runs.each do |run|
94
+ offsets << cursor
95
+ cursor += run.text.length
96
+ end
97
+ full_text = runs.map(&:text).join
98
+
99
+ match = full_text.match(pattern, search_from)
100
+ break unless match
101
+ break if match.end(0) == match.begin(0) # ignore empty matches
102
+
103
+ match_start = match.begin(0)
104
+ match_end = match.end(0) # exclusive
105
+ first = offsets.rindex { |offset| offset <= match_start }
106
+ last = offsets.rindex { |offset| offset < match_end }
107
+
108
+ combined = runs[first..last].map(&:text).join
109
+ local_start = match_start - offsets[first]
110
+ local_end = match_end - offsets[first]
111
+ replaced = combined[local_start...local_end].sub(pattern, replacement)
112
+ runs[first].text = combined[0...local_start] + replaced + combined[local_end..-1]
113
+ ((first + 1)..last).each { |index| runs[index].text = '' }
114
+
115
+ # advance past the inserted replacement so it is not re-matched
116
+ search_from = match_start + replaced.length
117
+ end
118
+ self
119
+ end
120
+
68
121
  def aligned_left?
69
122
  ['left', nil].include?(alignment)
70
123
  end
@@ -87,7 +140,7 @@ module Docx
87
140
 
88
141
  def font_color
89
142
  color_tag = @node.xpath('w:r//w:rPr//w:color').first
90
- color_tag ? color_tag.attributes['val'].value : nil
143
+ color_tag ? color_tag.attributes['val']&.value : nil
91
144
  end
92
145
 
93
146
  def style
@@ -13,14 +13,16 @@ module Docx
13
13
  'tbl'
14
14
  end
15
15
 
16
- def initialize(node)
16
+ def initialize(node, document_properties = {}, doc = nil)
17
17
  @node = node
18
18
  @properties_tag = 'tblGrid'
19
+ @document_properties = document_properties
20
+ @document = doc
19
21
  end
20
22
 
21
23
  # Array of row
22
24
  def rows
23
- @node.xpath('w:tr').map {|r_node| Containers::TableRow.new(r_node) }
25
+ @node.xpath('w:tr').map {|r_node| Containers::TableRow.new(r_node, @document_properties, @document) }
24
26
  end
25
27
 
26
28
  def row_count
@@ -31,7 +33,7 @@ module Docx
31
33
  def columns
32
34
  columns_containers = []
33
35
  (0..(column_count-1)).each do |i|
34
- columns_containers[i] = Containers::TableColumn.new @node.xpath("w:tr//w:tc[#{i+1}]")
36
+ columns_containers[i] = Containers::TableColumn.new(@node.xpath("w:tr//w:tc[#{i+1}]"), @document_properties, @document)
35
37
  end
36
38
  columns_containers
37
39
  end
@@ -12,9 +12,11 @@ module Docx
12
12
  'tc'
13
13
  end
14
14
 
15
- def initialize(node)
15
+ def initialize(node, document_properties = {}, doc = nil)
16
16
  @node = node
17
17
  @properties_tag = 'tcPr'
18
+ @document_properties = document_properties
19
+ @document = doc
18
20
  end
19
21
 
20
22
  # Return text of paragraph's cell
@@ -24,7 +26,7 @@ module Docx
24
26
 
25
27
  # Array of paragraphs contained within cell
26
28
  def paragraphs
27
- @node.xpath('w:p').map {|p_node| Containers::Paragraph.new(p_node) }
29
+ @node.xpath('w:p').map {|p_node| Containers::Paragraph.new(p_node, @document_properties, @document) }
28
30
  end
29
31
 
30
32
  # Iterate over each text run within a paragraph's cell
@@ -12,10 +12,12 @@ module Docx
12
12
  'w:gridCol'
13
13
  end
14
14
 
15
- def initialize(cell_nodes)
15
+ def initialize(cell_nodes, document_properties = {}, doc = nil)
16
16
  @node = ''
17
17
  @properties_tag = ''
18
- @cells = cell_nodes.map { |c_node| Containers::TableCell.new(c_node) }
18
+ @document_properties = document_properties
19
+ @document = doc
20
+ @cells = cell_nodes.map { |c_node| Containers::TableCell.new(c_node, @document_properties, @document) }
19
21
  end
20
22
 
21
23
  # Array of cells contained within row
@@ -12,14 +12,16 @@ module Docx
12
12
  'tr'
13
13
  end
14
14
 
15
- def initialize(node)
15
+ def initialize(node, document_properties = {}, doc = nil)
16
16
  @node = node
17
17
  @properties_tag = ''
18
+ @document_properties = document_properties
19
+ @document = doc
18
20
  end
19
21
 
20
22
  # Array of cells contained within row
21
23
  def cells
22
- @node.xpath('w:tc').map {|c_node| Containers::TableCell.new(c_node) }
24
+ @node.xpath('w:tc').map {|c_node| Containers::TableCell.new(c_node, @document_properties, @document) }
23
25
  end
24
26
 
25
27
  end
@@ -40,6 +40,7 @@ module Docx
40
40
  elsif @text_nodes.empty?
41
41
  new_t = Elements::Text.create_within(self)
42
42
  new_t.content = content
43
+ @text_nodes << new_t
43
44
  end
44
45
  reset_text
45
46
  end
data/lib/docx/document.rb CHANGED
@@ -27,6 +27,9 @@ module Docx
27
27
  def initialize(path_or_io, options = {})
28
28
  @replace = {}
29
29
 
30
+ # accept path-like objects (e.g. Pathname, File) by using their path (#101)
31
+ path_or_io = path_or_io.to_path if path_or_io.respond_to?(:to_path)
32
+
30
33
  # if path-or_io is string && does not contain a null byte
31
34
  if (path_or_io.instance_of?(String) && !/\u0000/.match?(path_or_io))
32
35
  @zip = Zip::File.open(path_or_io)
@@ -40,6 +43,7 @@ module Docx
40
43
  @document_xml = document.get_input_stream.read
41
44
  @doc = Nokogiri::XML(@document_xml)
42
45
  load_styles
46
+ load_rels
43
47
  load_headers
44
48
  load_footers
45
49
  yield(self) if block_given?
@@ -100,11 +104,18 @@ module Docx
100
104
  # Hyperlink targets are extracted from the document.xml.rels file
101
105
  def hyperlinks
102
106
  hyperlink_relationships.each_with_object({}) do |rel, hash|
103
- hash[rel.attributes['Id'].value] = rel.attributes['Target'].value
107
+ id = rel.attributes['Id']
108
+ target = rel.attributes['Target']
109
+ next unless id && target
110
+
111
+ hash[id.value] = target.value
104
112
  end
105
113
  end
106
114
 
107
115
  def hyperlink_relationships
116
+ # @rels is nil when the document has no relationships part (#192)
117
+ return Nokogiri::XML::NodeSet.new(Nokogiri::XML::Document.new) unless @rels
118
+
108
119
  @rels.xpath("//xmlns:Relationship[contains(@Type,'hyperlink')]")
109
120
  end
110
121
 
@@ -180,7 +191,7 @@ module Docx
180
191
  end
181
192
 
182
193
  def default_paragraph_style
183
- @styles.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']/w:name/@w:val").value
194
+ @styles&.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']/w:name/@w:val")&.value
184
195
  end
185
196
 
186
197
  def style_name_of(style_id)
@@ -226,18 +237,22 @@ module Docx
226
237
  def load_styles
227
238
  @styles_xml = @zip.read('word/styles.xml')
228
239
  @styles = Nokogiri::XML(@styles_xml)
229
- load_rels
230
240
  rescue Errno::ENOENT => e
231
241
  warn e.message
232
242
  nil
233
243
  end
234
244
 
245
+ # Loaded independently of styles so that a document without word/styles.xml
246
+ # still initializes @rels (see #158).
235
247
  def load_rels
236
248
  rels_entry = @zip.glob('word/_rels/document*.xml.rels').first
237
249
  raise Errno::ENOENT unless rels_entry
238
250
 
239
251
  @rels_xml = rels_entry.get_input_stream.read
240
252
  @rels = Nokogiri::XML(@rels_xml)
253
+ rescue Errno::ENOENT => e
254
+ warn e.message
255
+ nil
241
256
  end
242
257
 
243
258
  #--
@@ -267,7 +282,7 @@ module Docx
267
282
  end
268
283
 
269
284
  def parse_table_from(t_node)
270
- Elements::Containers::Table.new(t_node)
285
+ Elements::Containers::Table.new(t_node, document_properties, self)
271
286
  end
272
287
  end
273
288
  end
@@ -51,28 +51,92 @@ module Docx
51
51
 
52
52
  # Get text run immediately prior to bookmark node
53
53
  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
+
61
62
  new_r = Containers::TextRun.create_with(self)
62
63
  new_r.insert_before(self)
63
- new_r
64
+ return new_r
64
65
  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) }
65
72
  end
66
73
 
67
74
  # Get text run immediately after bookmark node
68
75
  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
+
72
81
  new_r = Containers::TextRun.create_with(self)
73
82
  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)
75
121
  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
76
140
  end
77
141
  end
78
142
  end
@@ -42,7 +42,8 @@ module Docx
42
42
 
43
43
  # Insert node as first child (after properties)
44
44
  def prepend_to(element)
45
- @node = element.node.properties.add_next_sibling(@node)
45
+ properties = element.properties if element.respond_to?(:properties)
46
+ @node = properties ? properties.add_next_sibling(@node) : element.node.prepend_child(@node)
46
47
  self
47
48
  end
48
49
 
data/lib/docx/helpers.rb CHANGED
@@ -4,7 +4,7 @@ module Docx
4
4
  # than the default inspect method. But you can still get the default inspect method by passing
5
5
  # true as the first argument.
6
6
  def inspect(full = false)
7
- return(super) if full
7
+ return super() if full
8
8
 
9
9
  variable_values =
10
10
  instance_variables.map do |var|
data/lib/docx/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docx #:nodoc:
4
- VERSION = '0.12.0'
4
+ VERSION = '0.13.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Hunt