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 +4 -4
- data/README.md +9 -0
- data/lib/docx/containers/paragraph.rb +54 -1
- data/lib/docx/containers/table.rb +5 -3
- data/lib/docx/containers/table_cell.rb +4 -2
- data/lib/docx/containers/table_column.rb +4 -2
- data/lib/docx/containers/table_row.rb +4 -2
- data/lib/docx/containers/text_run.rb +1 -0
- data/lib/docx/document.rb +19 -4
- data/lib/docx/elements/bookmark.rb +76 -12
- data/lib/docx/elements/element.rb +2 -1
- data/lib/docx/helpers.rb +1 -1
- data/lib/docx/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d639e88e5950b94157fc4f81b14f0f8bf355abb4fdedd24b7f40dc00eafb72d7
|
|
4
|
+
data.tar.gz: e64400d25d919b4cd51f4b876d7f35c3433841523055866dff2c300f7c15aca7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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']
|
|
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
|
|
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
|
-
@
|
|
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
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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(
|
|
7
|
+
return super() if full
|
|
8
8
|
|
|
9
9
|
variable_values =
|
|
10
10
|
instance_variables.map do |var|
|
data/lib/docx/version.rb
CHANGED