docx 0.13.1 → 0.13.3

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: d639e88e5950b94157fc4f81b14f0f8bf355abb4fdedd24b7f40dc00eafb72d7
4
- data.tar.gz: e64400d25d919b4cd51f4b876d7f35c3433841523055866dff2c300f7c15aca7
3
+ metadata.gz: a6559deefb9d0bfae3c8101274040ebf8968827360a3f871d2f1d23440e6bbcb
4
+ data.tar.gz: 4ad8c35b486f9fb9aeac833f78f9425dbac5364f4c1d0d16e168685434695210
5
5
  SHA512:
6
- metadata.gz: d2ad2931111374349dd03ca04bef655a9eaa797b84d688198ebeac84cdf93af147d4f8fd5a561c4e506d7f823c9b22884c698a315d7e2bb505357c7c6da9316a
7
- data.tar.gz: 25cb0bc5f3b67b4af1874b902fbdfe3061205cee0c8b288187924b80cb50a6bd5a7076ea497be702a07241841da146055b3be0dbffeb90c20922f851ad31588c
6
+ metadata.gz: bb062084c4bd71c5ca72fca4f7eae5fe17869eca2533c5d668a4cd8c2963544fff8737b836676ca22073c19ec6fca7f9a78bf0ad5789f7172d149ad75d87a455
7
+ data.tar.gz: bea34b169aa1c320cab0d9bd8de4a6a02e91e50a327d60192d9e9e5f53e8d9c977a7b713d0cd5be4d3ba05573f0850481cc69c707b96edf29609a4be8e08cd14
@@ -18,6 +18,19 @@ module Docx
18
18
  def remove!
19
19
  @node.remove
20
20
  end
21
+
22
+ private
23
+
24
+ # Remove the line breaks and tabs (nodes matched by +separator_xpath+)
25
+ # lying between the w:t nodes +first_t+ and +last_t+, which are matched
26
+ # by +text_xpath+.
27
+ def remove_separators_between(first_t, last_t, text_xpath, separator_xpath)
28
+ separators = @node.xpath(separator_xpath).to_a
29
+ nodes = @node.xpath("#{text_xpath}|#{separator_xpath}").to_a
30
+ from = nodes.index(first_t)
31
+ to = nodes.index(last_t)
32
+ nodes[(from + 1)...to].each { |node| node.remove if separators.include?(node) }
33
+ end
21
34
  end
22
35
  end
23
36
  end
@@ -70,9 +70,13 @@ module Docx
70
70
  # such as "{{fi", "rst_na", "me}}"). The per-run TextRun#substitute cannot
71
71
  # match those, but this can, because it joins the runs first.
72
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.
73
+ # The substitution works on the paragraph's w:t nodes, so it also handles
74
+ # runs holding several w:t (LibreOffice writes "text<w:br/>text" in one
75
+ # run) and placeholders that start inside a hyperlink. The replacement
76
+ # goes into the w:t holding the first matched character, so that run's
77
+ # formatting is used; the rest of the match is cut from the following
78
+ # w:t, and line breaks and tabs inside the match are dropped. Text,
79
+ # breaks and tabs outside the match stay where they are.
76
80
  #
77
81
  # +pattern+ may be a String or a Regexp; +replacement+ follows String#sub
78
82
  # semantics, so capture-group backreferences (e.g. '\1') work with a Regexp.
@@ -81,39 +85,15 @@ module Docx
81
85
  # paragraph.substitute('{{first_name}}', 'Jane') # => "Hello Jane!"
82
86
  # paragraph.substitute(/\{\{(\w+)\}\}/, 'value of \1')
83
87
  #
84
- # See https://github.com/ruby-docx/docx/issues/147
88
+ # See https://github.com/ruby-docx/docx/issues/147 and #187
85
89
  def substitute(pattern, replacement)
86
90
  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 = '' }
91
+ while (match = find_match(pattern, search_from))
92
+ replaced = match[0].sub(pattern, replacement)
93
+ replace_text_range(match.begin(0)...match.end(0), replaced)
114
94
 
115
95
  # advance past the inserted replacement so it is not re-matched
116
- search_from = match_start + replaced.length
96
+ search_from = match.begin(0) + replaced.length
117
97
  end
118
98
  self
119
99
  end
@@ -165,6 +145,50 @@ module Docx
165
145
 
166
146
  private
167
147
 
148
+ # The w:t read by #text (those of the runs and hyperlink runs), in
149
+ # document order.
150
+ TEXT_XPATH = 'w:r/w:t|w:hyperlink/w:r/w:t'
151
+ # Line breaks and tabs that sit between those w:t.
152
+ SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:r/w:#{tag}|w:hyperlink/w:r/w:#{tag}" }.join('|')
153
+ private_constant :TEXT_XPATH, :SEPARATOR_XPATH
154
+
155
+ # The paragraph's w:t, in document order; their contents joined make up #text.
156
+ def text_nodes
157
+ @node.xpath(TEXT_XPATH).map { |t_node| Elements::Text.new(t_node) }
158
+ end
159
+
160
+ # The first match of +pattern+ in the paragraph text at or after +from+,
161
+ # or nil when there is none. An empty match also gives nil, so that
162
+ # #substitute stops rather than loop on it.
163
+ def find_match(pattern, from)
164
+ match = text_nodes.map(&:content).join.match(pattern, from)
165
+ match unless match.nil? || match[0].empty?
166
+ end
167
+
168
+ # Replace the characters in +range+ of the paragraph text with +string+.
169
+ # The string goes into the w:t holding the range's first character; the
170
+ # rest of the range is cut from the following w:t, and line breaks and
171
+ # tabs inside it are removed.
172
+ def replace_text_range(range, string)
173
+ texts = text_nodes
174
+ # boundaries[i]...boundaries[i + 1] is where texts[i] sits in the text
175
+ boundaries = texts.each_with_object([0]) { |text, acc| acc << acc.last + text.content.length }
176
+ first = texts.each_index.find { |i| boundaries[i + 1] > range.begin }
177
+ last = texts.each_index.find { |i| boundaries[i + 1] >= range.end }
178
+
179
+ head = texts[first].content[0...(range.begin - boundaries[first])]
180
+ tail = texts[last].content[(range.end - boundaries[last])..]
181
+ if first == last
182
+ texts[first].content = head + string + tail
183
+ return
184
+ end
185
+
186
+ texts[first].content = head + string
187
+ texts[(first + 1)...last].each { |text| text.content = '' }
188
+ texts[last].content = tail
189
+ remove_separators_between(texts[first].node, texts[last].node, TEXT_XPATH, SEPARATOR_XPATH)
190
+ end
191
+
168
192
  def style_property
169
193
  properties&.at_xpath('w:pStyle') || properties&.add_child('<w:pStyle/>').first
170
194
  end
@@ -14,6 +14,12 @@ module Docx
14
14
  strike: false
15
15
  }
16
16
 
17
+ # The w:t of the run, or of the runs of a hyperlink, in document order.
18
+ TEXT_XPATH = 'w:t|w:r/w:t'
19
+ # Line breaks and tabs that sit between those w:t.
20
+ SEPARATOR_XPATH = %w[br tab cr].map { |tag| "w:#{tag}|w:r/w:#{tag}" }.join('|')
21
+ private_constant :TEXT_XPATH, :SEPARATOR_XPATH
22
+
17
23
  def self.tag
18
24
  'r'
19
25
  end
@@ -23,8 +29,7 @@ module Docx
23
29
 
24
30
  def initialize(node, document_properties = {})
25
31
  @node = node
26
- @text_nodes = @node.xpath('w:t').map {|t_node| Elements::Text.new(t_node) }
27
- @text_nodes = @node.xpath('w:t|w:r/w:t').map {|t_node| Elements::Text.new(t_node) }
32
+ @text_nodes = @node.xpath(TEXT_XPATH).map {|t_node| Elements::Text.new(t_node) }
28
33
 
29
34
  @properties_tag = 'rPr'
30
35
  @text = parse_text || ''
@@ -33,14 +38,28 @@ module Docx
33
38
  @font_size = @document_properties[:font_size]
34
39
  end
35
40
 
36
- # Set text of text run
41
+ # Set text of text run.
42
+ #
43
+ # The text from the first through the last w:t is replaced: the content
44
+ # goes into the first w:t, and the other w:t and the line breaks and
45
+ # tabs between them are removed (#text never includes breaks or tabs,
46
+ # so a plain string cannot say where they belong). Anything before the
47
+ # first or after the last w:t, such as a trailing page break, is kept.
48
+ # To replace part of a paragraph while keeping its line breaks in place,
49
+ # use Paragraph#substitute.
37
50
  def text=(content)
38
- if @text_nodes.size == 1
39
- @text_nodes.first.content = content
40
- elsif @text_nodes.empty?
51
+ if @text_nodes.empty?
41
52
  new_t = Elements::Text.create_within(self)
42
53
  new_t.content = content
43
54
  @text_nodes << new_t
55
+ else
56
+ first, *rest = @text_nodes
57
+ first.content = content
58
+ unless rest.empty?
59
+ remove_separators_between(first.node, rest.last.node, TEXT_XPATH, SEPARATOR_XPATH)
60
+ rest.each { |text_node| text_node.node.remove }
61
+ @text_nodes = [first]
62
+ end
44
63
  end
45
64
  reset_text
46
65
  end
@@ -17,14 +17,12 @@ module Docx
17
17
 
18
18
  # Insert text before bookmarkStart node
19
19
  def insert_text_before(text)
20
- text_run = get_run_before
21
- text_run.text = "#{text_run.text}#{text}"
20
+ append_to_run(get_run_before, text)
22
21
  end
23
22
 
24
23
  # Insert text after bookmarkStart node
25
24
  def insert_text_after(text)
26
- text_run = get_run_after
27
- text_run.text = "#{text}#{text_run.text}"
25
+ prepend_to_run(get_run_after, text)
28
26
  end
29
27
 
30
28
  # insert multiple lines starting with paragraph containing bookmark node.
@@ -103,6 +101,38 @@ module Docx
103
101
 
104
102
  private
105
103
 
104
+ # A run's text, line breaks and tabs, in document order.
105
+ RUN_CONTENT_XPATH = 'w:t|w:br|w:tab|w:cr'
106
+ private_constant :RUN_CONTENT_XPATH
107
+
108
+ # Add text at the very end of the run, which sits just before the
109
+ # bookmark: to its last w:t, or to a new w:t when the run ends with a
110
+ # break or tab. The rest of the run is left as it is (#197).
111
+ def append_to_run(run, text)
112
+ last = run.node.xpath(RUN_CONTENT_XPATH).last
113
+ if last && run.node.xpath('w:t').include?(last)
114
+ Text.new(last).content = last.content + text
115
+ else
116
+ Text.create_within(run).content = text
117
+ end
118
+ end
119
+
120
+ # Add text at the very start of the run, which sits just after the
121
+ # bookmark: to its first w:t, or to a new w:t when the run starts with
122
+ # a break or tab. The rest of the run is left as it is (#197).
123
+ def prepend_to_run(run, text)
124
+ first = run.node.xpath(RUN_CONTENT_XPATH).first
125
+ if first && run.node.xpath('w:t').include?(first)
126
+ Text.new(first).content = text + first.content
127
+ elsif first
128
+ new_t = Text.create_with(run)
129
+ new_t.node = first.add_previous_sibling(new_t.node)
130
+ new_t.content = text
131
+ else
132
+ Text.create_within(run).content = text
133
+ end
134
+ end
135
+
106
136
  # The w:p the bookmark sits inside, or nil when it is block-level.
107
137
  def enclosing_paragraph
108
138
  @node.at_xpath("./parent::w:p")
@@ -13,6 +13,8 @@ module Docx
13
13
 
14
14
  def content=(args)
15
15
  @node.content = args
16
+ # Without xml:space="preserve", Word drops leading and trailing spaces.
17
+ @node['xml:space'] = 'preserve' if args.to_s.match?(/\A\s|\s\z/)
16
18
  end
17
19
 
18
20
  def initialize(node)
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.13.1'
4
+ VERSION = '0.13.3'
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.13.1
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Hunt