rbpptx 0.1.0 → 0.2.0
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/CHANGELOG.md +48 -0
- data/README.md +95 -9
- data/lib/rbpptx/package.rb +274 -0
- data/lib/rbpptx/presentation.rb +201 -75
- data/lib/rbpptx/shape.rb +31 -75
- data/lib/rbpptx/shape_container.rb +161 -0
- data/lib/rbpptx/slide.rb +131 -38
- data/lib/rbpptx/table.rb +106 -0
- data/lib/rbpptx/text_body.rb +92 -0
- data/lib/rbpptx/version.rb +1 -1
- data/lib/rbpptx.rb +4 -0
- data/sig/rbpptx.rbs +148 -10
- metadata +13 -3
data/lib/rbpptx/presentation.rb
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
module Rbpptx
|
|
2
2
|
# A PowerPoint presentation loaded from a +.pptx+ file.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# The package is opened, the presentation part is located via
|
|
5
|
+
# +_rels/.rels+, the slide id list is read in document order, and slides
|
|
6
|
+
# are resolved through the presentation's own rels. Each {Slide} parses
|
|
7
|
+
# its XML lazily.
|
|
8
|
+
#
|
|
9
|
+
# Slide-level structure edits ({#move_slide}, {#delete_slide}) rewrite
|
|
10
|
+
# only the bookkeeping parts that must change — +ppt/presentation.xml+,
|
|
11
|
+
# its rels, and +[Content_Types].xml+ — and leave every other part as-is.
|
|
9
12
|
class Presentation
|
|
10
13
|
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main".freeze
|
|
11
14
|
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
|
|
12
15
|
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships".freeze
|
|
16
|
+
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main".freeze
|
|
13
17
|
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships".freeze
|
|
14
18
|
OFFICE_DOC_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument".freeze
|
|
15
19
|
SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide".freeze
|
|
20
|
+
NOTES_SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide".freeze
|
|
21
|
+
NOTES_MASTER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster".freeze
|
|
22
|
+
NOTES_SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml".freeze
|
|
16
23
|
|
|
17
24
|
LEGACY_PPT_SIGNATURE = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b.freeze
|
|
18
25
|
ZIP_SIGNATURE = "PK\x03\x04".b.freeze
|
|
@@ -31,7 +38,7 @@ module Rbpptx
|
|
|
31
38
|
@closed = false
|
|
32
39
|
validate_signature!
|
|
33
40
|
begin
|
|
34
|
-
@
|
|
41
|
+
@package = Package.new(@path)
|
|
35
42
|
rescue Zip::Error => e
|
|
36
43
|
raise UnsupportedFormatError, "#{@path}: not a valid .pptx (#{e.message})"
|
|
37
44
|
end
|
|
@@ -44,12 +51,104 @@ module Rbpptx
|
|
|
44
51
|
@slides ||= build_slides
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
# @param index [Integer] zero-based; negative counts from the end
|
|
55
|
+
# @return [Slide]
|
|
56
|
+
# @raise [SlideNotFoundError] if +index+ is out of range
|
|
57
|
+
def slide(index)
|
|
58
|
+
slides.fetch(index)
|
|
59
|
+
rescue IndexError
|
|
60
|
+
raise SlideNotFoundError, "#{@path}: no slide at index #{index} (#{slides.size} slides)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Moves a slide to a new position in the presentation order. Only
|
|
64
|
+
# +<p:sldIdLst>+ in +ppt/presentation.xml+ is rewritten.
|
|
65
|
+
#
|
|
66
|
+
# @param slide_or_index [Slide, Integer] the slide to move
|
|
67
|
+
# @param new_index [Integer] zero-based destination; negative counts from
|
|
68
|
+
# the end (+-1+ moves to the last position)
|
|
69
|
+
# @return [Array<Slide>] slides in the new order
|
|
70
|
+
# @raise [SlideNotFoundError] if either argument is out of range
|
|
71
|
+
def move_slide(slide_or_index, new_index)
|
|
72
|
+
target = resolve_slide(slide_or_index)
|
|
73
|
+
size = slides.size
|
|
74
|
+
to = new_index.negative? ? new_index + size : new_index
|
|
75
|
+
raise SlideNotFoundError, "#{@path}: destination index #{new_index} out of range (#{size} slides)" unless (0...size).cover?(to)
|
|
76
|
+
return slides if slides.index(target) == to
|
|
77
|
+
|
|
78
|
+
others = slides - [target]
|
|
79
|
+
node = @sld_id_nodes.fetch(target)
|
|
80
|
+
node.unlink
|
|
81
|
+
if to.zero?
|
|
82
|
+
@sld_id_nodes.fetch(others.first).add_previous_sibling(node)
|
|
83
|
+
else
|
|
84
|
+
@sld_id_nodes.fetch(others[to - 1]).add_next_sibling(node)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@slides = others.insert(to, target)
|
|
88
|
+
@package.touch(@presentation_part)
|
|
89
|
+
@slides
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Removes a slide from the presentation, together with its notes page.
|
|
93
|
+
#
|
|
94
|
+
# The slide's +<p:sldId>+, its relationship from the presentation, its
|
|
95
|
+
# entries in custom shows and sections, its content-type override, and
|
|
96
|
+
# the slide/notes parts themselves are dropped. Shared parts (layouts,
|
|
97
|
+
# media) stay in the package even if no other slide uses them.
|
|
98
|
+
#
|
|
99
|
+
# Other parts may still point at the slide — typically "jump to slide"
|
|
100
|
+
# hyperlinks on an agenda slide. By default deletion is then refused,
|
|
101
|
+
# because removing the slide would leave a dangling reference that
|
|
102
|
+
# PowerPoint reports as a corrupt file. Pass +unlink: true+ to remove
|
|
103
|
+
# those hyperlinks (+<a:hlinkClick>+ / +<a:hlinkHover>+; the link text
|
|
104
|
+
# stays) and their relationships first. References that are not
|
|
105
|
+
# hyperlinks always cause a refusal.
|
|
106
|
+
#
|
|
107
|
+
# @param slide_or_index [Slide, Integer]
|
|
108
|
+
# @param unlink [Boolean] remove hyperlinks on other parts that target
|
|
109
|
+
# this slide
|
|
110
|
+
# @return [Slide] the removed slide (detached; do not use it further)
|
|
111
|
+
# @raise [SlideNotFoundError] if the index is out of range
|
|
112
|
+
# @raise [Rbpptx::Error] if another part references the slide and the
|
|
113
|
+
# reference cannot be removed
|
|
114
|
+
def delete_slide(slide_or_index, unlink: false)
|
|
115
|
+
target = resolve_slide(slide_or_index)
|
|
116
|
+
part = target.part_name
|
|
117
|
+
notes_part = @package.related(part, NOTES_SLIDE_REL_TYPE).first
|
|
118
|
+
|
|
119
|
+
referrers = @package.referrers_of(part) - [@presentation_part, part, notes_part]
|
|
120
|
+
unless referrers.empty?
|
|
121
|
+
unless unlink
|
|
122
|
+
raise Error, "#{@path}: cannot delete #{part}; it is still referenced by #{referrers.join(', ')} " \
|
|
123
|
+
"(pass unlink: true to remove hyperlinks pointing at it)"
|
|
124
|
+
end
|
|
125
|
+
unlink_references!(referrers, part)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
node = @sld_id_nodes.delete(target)
|
|
129
|
+
sld_id = node["id"]
|
|
130
|
+
rid = node.attribute_with_ns("id", R_NS)&.value
|
|
131
|
+
node.remove
|
|
132
|
+
|
|
133
|
+
pres_doc = @package.xml(@presentation_part)
|
|
134
|
+
pres_doc.xpath("//p14:sldId[@id=$id]", { "p14" => P14_NS }, { "id" => sld_id }).each(&:remove)
|
|
135
|
+
pres_doc.xpath("//p:custShow/p:sldLst/p:sld[@r:id=$rid]", { "p" => P_NS, "r" => R_NS }, { "rid" => rid }).each(&:remove)
|
|
136
|
+
@package.touch(@presentation_part)
|
|
137
|
+
|
|
138
|
+
@package.remove_relationship(@presentation_part, rid)
|
|
139
|
+
@package.delete(part)
|
|
140
|
+
@package.delete(notes_part) if notes_part
|
|
141
|
+
|
|
142
|
+
@slides.delete(target)
|
|
143
|
+
target
|
|
144
|
+
end
|
|
145
|
+
|
|
47
146
|
# Writes the presentation out to +path+, preserving every part that has
|
|
48
|
-
# not been mutated byte-for-byte.
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
147
|
+
# not been mutated byte-for-byte. Edited parts are re-serialized from
|
|
148
|
+
# their in-memory Nokogiri documents; all other entries (themes, masters,
|
|
149
|
+
# layouts, media, fonts) are streamed straight from the source ZIP
|
|
150
|
+
# without re-parsing, so unknown PowerPoint features round-trip
|
|
151
|
+
# unchanged.
|
|
53
152
|
#
|
|
54
153
|
# +path+ may equal the original load path; the new file is written to a
|
|
55
154
|
# temp file in the same directory and atomically renamed into place. On
|
|
@@ -59,31 +158,7 @@ module Rbpptx
|
|
|
59
158
|
# @return [String] the path that was written
|
|
60
159
|
def save(path)
|
|
61
160
|
ensure_open!
|
|
62
|
-
out_path = path.to_s
|
|
63
|
-
overrides = (@slides || []).each_with_object({}) do |slide, h|
|
|
64
|
-
h[slide.part_name] = slide.to_xml if slide.dirty?
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
tmp_path = "#{out_path}.rbpptx-tmp.#{Process.pid}.#{rand(1 << 32).to_s(16)}"
|
|
68
|
-
begin
|
|
69
|
-
Zip::OutputStream.open(tmp_path) do |out|
|
|
70
|
-
@zip.each do |entry|
|
|
71
|
-
next if entry.directory?
|
|
72
|
-
|
|
73
|
-
if (override_xml = overrides[entry.name])
|
|
74
|
-
out.put_next_entry(entry.name)
|
|
75
|
-
out.write(override_xml)
|
|
76
|
-
else
|
|
77
|
-
out.copy_raw_entry(entry)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
File.rename(tmp_path, out_path)
|
|
82
|
-
rescue StandardError
|
|
83
|
-
File.unlink(tmp_path) if File.exist?(tmp_path)
|
|
84
|
-
raise
|
|
85
|
-
end
|
|
86
|
-
|
|
161
|
+
out_path = @package.save(path.to_s)
|
|
87
162
|
(@slides || []).each(&:clear_dirty!)
|
|
88
163
|
out_path
|
|
89
164
|
end
|
|
@@ -94,8 +169,8 @@ module Rbpptx
|
|
|
94
169
|
def close
|
|
95
170
|
return false if @closed
|
|
96
171
|
|
|
97
|
-
@
|
|
98
|
-
@
|
|
172
|
+
@package&.close
|
|
173
|
+
@package = nil
|
|
99
174
|
@closed = true
|
|
100
175
|
true
|
|
101
176
|
end
|
|
@@ -105,6 +180,33 @@ module Rbpptx
|
|
|
105
180
|
@closed
|
|
106
181
|
end
|
|
107
182
|
|
|
183
|
+
# Creates an empty notes page for +slide+ from the notes master.
|
|
184
|
+
#
|
|
185
|
+
# @api private
|
|
186
|
+
# @return [NotesSlide]
|
|
187
|
+
# @raise [Rbpptx::Error] if the presentation has no notes master
|
|
188
|
+
def create_notes_slide_for(slide)
|
|
189
|
+
ensure_open!
|
|
190
|
+
slides # ensures @presentation_part is resolved
|
|
191
|
+
master = @package.related(@presentation_part, NOTES_MASTER_REL_TYPE).first
|
|
192
|
+
unless master
|
|
193
|
+
raise Error, "#{@path}: presentation has no notes master; cannot create a notes slide " \
|
|
194
|
+
"(add speaker notes to any slide in PowerPoint once to create one)"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
n = 1
|
|
198
|
+
n += 1 while @package.part?("ppt/notesSlides/notesSlide#{n}.xml")
|
|
199
|
+
part = "ppt/notesSlides/notesSlide#{n}.xml"
|
|
200
|
+
|
|
201
|
+
@package.add(part, Nokogiri::XML(new_notes_slide_xml))
|
|
202
|
+
@package.add_relationship(part, NOTES_MASTER_REL_TYPE, master)
|
|
203
|
+
@package.add_relationship(part, SLIDE_REL_TYPE, slide.part_name)
|
|
204
|
+
@package.add_relationship(slide.part_name, NOTES_SLIDE_REL_TYPE, part)
|
|
205
|
+
@package.add_content_type_override(part, NOTES_SLIDE_CONTENT_TYPE)
|
|
206
|
+
|
|
207
|
+
NotesSlide.new(package: @package, part_name: part)
|
|
208
|
+
end
|
|
209
|
+
|
|
108
210
|
private
|
|
109
211
|
|
|
110
212
|
def ensure_open!
|
|
@@ -126,23 +228,70 @@ module Rbpptx
|
|
|
126
228
|
end
|
|
127
229
|
end
|
|
128
230
|
|
|
231
|
+
def resolve_slide(slide_or_index)
|
|
232
|
+
case slide_or_index
|
|
233
|
+
when Integer then slide(slide_or_index)
|
|
234
|
+
when Slide
|
|
235
|
+
slides.find { |s| s.equal?(slide_or_index) } or
|
|
236
|
+
raise SlideNotFoundError, "#{@path}: slide #{slide_or_index.part_name} is not part of this presentation"
|
|
237
|
+
else
|
|
238
|
+
raise TypeError, "expected Rbpptx::Slide or Integer, got #{slide_or_index.class}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
HYPERLINK_ELEMENTS = %w[hlinkClick hlinkHover].freeze
|
|
243
|
+
private_constant :HYPERLINK_ELEMENTS
|
|
244
|
+
|
|
245
|
+
# Removes hyperlinks in +referrers+ that target +part+. Everything is
|
|
246
|
+
# validated before anything is mutated, so a refusal leaves the package
|
|
247
|
+
# untouched.
|
|
248
|
+
def unlink_references!(referrers, part)
|
|
249
|
+
plan = referrers.map do |source|
|
|
250
|
+
doc = @package.xml(source)
|
|
251
|
+
rels = @package.relationships(source).select { |r| r.part == part }
|
|
252
|
+
uses = rels.to_h do |rel|
|
|
253
|
+
attrs = doc.xpath("//@*[namespace-uri()=$ns and .=$rid]", nil, { "ns" => R_NS, "rid" => rel.id })
|
|
254
|
+
foreign = attrs.reject { |a| a.parent.namespace&.href == A_NS && HYPERLINK_ELEMENTS.include?(a.parent.name) }
|
|
255
|
+
unless foreign.empty?
|
|
256
|
+
raise Error, "#{@path}: cannot delete #{part}; #{source} references it from " \
|
|
257
|
+
"<#{foreign.first.parent.name}>, which is not a hyperlink"
|
|
258
|
+
end
|
|
259
|
+
[rel.id, attrs.map(&:parent)]
|
|
260
|
+
end
|
|
261
|
+
[source, uses]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
plan.each do |source, uses|
|
|
265
|
+
uses.each do |rid, elements|
|
|
266
|
+
elements.each(&:remove)
|
|
267
|
+
@package.remove_relationship(source, rid)
|
|
268
|
+
end
|
|
269
|
+
owner = slides.find { |s| s.part_name == source }
|
|
270
|
+
owner ? owner.mark_dirty! : @package.touch(source)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
129
274
|
def build_slides
|
|
130
|
-
|
|
131
|
-
pres_rels =
|
|
132
|
-
pres_dir = File.dirname(pres_part)
|
|
275
|
+
@presentation_part = locate_presentation_part
|
|
276
|
+
pres_rels = @package.relationships(@presentation_part).to_h { |r| [r.id, r] }
|
|
133
277
|
|
|
134
|
-
doc =
|
|
135
|
-
|
|
278
|
+
doc = @package.xml(@presentation_part)
|
|
279
|
+
raise PresentationFormatError, "#{@path}: #{@presentation_part} has no root element" unless doc.root
|
|
136
280
|
|
|
137
|
-
|
|
281
|
+
@sld_id_nodes = {}.compare_by_identity
|
|
282
|
+
doc.xpath("/p:presentation/p:sldIdLst/p:sldId", "p" => P_NS).map do |sld_id|
|
|
138
283
|
rid = sld_id.attribute_with_ns("id", R_NS)&.value
|
|
139
|
-
|
|
140
|
-
|
|
284
|
+
rel = pres_rels[rid]
|
|
285
|
+
raise PresentationFormatError, "#{@path}: rId #{rid.inspect} not found in presentation rels" unless rel&.part
|
|
286
|
+
|
|
287
|
+
slide = Slide.new(package: @package, part_name: rel.part, presentation: self)
|
|
288
|
+
@sld_id_nodes[slide] = sld_id
|
|
289
|
+
slide
|
|
141
290
|
end
|
|
142
291
|
end
|
|
143
292
|
|
|
144
293
|
def locate_presentation_part
|
|
145
|
-
doc =
|
|
294
|
+
doc = @package.xml("_rels/.rels")
|
|
146
295
|
rel = doc.at_xpath(
|
|
147
296
|
"/pkg:Relationships/pkg:Relationship[@Type=$type]",
|
|
148
297
|
{ "pkg" => PKG_REL_NS },
|
|
@@ -154,34 +303,11 @@ module Rbpptx
|
|
|
154
303
|
target.sub(%r{\A/}, "")
|
|
155
304
|
end
|
|
156
305
|
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
doc.xpath("/pkg:Relationships/pkg:Relationship", "pkg" => PKG_REL_NS).each_with_object({}) do |r, h|
|
|
163
|
-
h[r["Id"]] = r["Target"]
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def parse_xml(part_name)
|
|
168
|
-
entry = @zip.find_entry(part_name) or raise PresentationFormatError, "#{@path}: missing part #{part_name}"
|
|
169
|
-
doc = Nokogiri::XML(entry.get_input_stream.read)
|
|
170
|
-
raise PresentationFormatError, "#{@path}: #{part_name}: #{doc.errors.first}" unless doc.errors.empty?
|
|
171
|
-
|
|
172
|
-
doc
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def rels_path_for(part_name)
|
|
176
|
-
dir = File.dirname(part_name)
|
|
177
|
-
base = File.basename(part_name)
|
|
178
|
-
dir == "." ? "_rels/#{base}.rels" : "#{dir}/_rels/#{base}.rels"
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def resolve_relative(base_dir, target)
|
|
182
|
-
return target.sub(%r{\A/}, "") if target.start_with?("/")
|
|
183
|
-
|
|
184
|
-
File.expand_path(target, "/#{base_dir}").sub(%r{\A/}, "")
|
|
306
|
+
def new_notes_slide_xml
|
|
307
|
+
<<~XML
|
|
308
|
+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
309
|
+
<p:notes xmlns:a="#{A_NS}" xmlns:r="#{R_NS}" xmlns:p="#{P_NS}"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>
|
|
310
|
+
XML
|
|
185
311
|
end
|
|
186
312
|
end
|
|
187
313
|
end
|
data/lib/rbpptx/shape.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
require "cgi"
|
|
2
|
-
|
|
3
1
|
module Rbpptx
|
|
4
|
-
# A single shape (+<p:sp>+) on a slide.
|
|
2
|
+
# A single shape (+<p:sp>+) on a slide or notes slide.
|
|
5
3
|
#
|
|
6
|
-
# Two layers of text access are exposed:
|
|
4
|
+
# Two layers of text access are exposed (see {TextBody}):
|
|
7
5
|
#
|
|
8
6
|
# * {#text} / {#text=} — bulldozer-level: read the entire concatenated
|
|
9
7
|
# text body, or replace it wholesale. The setter cleans the text body's
|
|
@@ -15,17 +13,19 @@ module Rbpptx
|
|
|
15
13
|
# breaks, paragraph properties, and any unknown OOXML extensions on the
|
|
16
14
|
# shape are preserved.
|
|
17
15
|
#
|
|
18
|
-
# In addition, {#placeholder_type}
|
|
19
|
-
# +<p:nvSpPr>/<p:nvPr>/<p:ph
|
|
20
|
-
# semantic role (e.g. +:ctrTitle+, +:subTitle+, +:body+) instead
|
|
21
|
-
# index.
|
|
16
|
+
# In addition, {#placeholder_type} and {#placeholder_idx} read the shape's
|
|
17
|
+
# placeholder role from +<p:nvSpPr>/<p:nvPr>/<p:ph>+ so callers can target
|
|
18
|
+
# shapes by semantic role (e.g. +:ctrTitle+, +:subTitle+, +:body+) instead
|
|
19
|
+
# of by index. See also {Slide#placeholders} and {Slide#title}.
|
|
22
20
|
class Shape
|
|
21
|
+
include TextBody
|
|
22
|
+
|
|
23
23
|
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main".freeze
|
|
24
24
|
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
|
|
25
25
|
|
|
26
26
|
# @param node [Nokogiri::XML::Element] the +<p:sp>+ element
|
|
27
|
-
# @param slide [Slide, nil] back-reference used to mark the
|
|
28
|
-
# when this shape is mutated
|
|
27
|
+
# @param slide [Slide, NotesSlide, nil] back-reference used to mark the
|
|
28
|
+
# owning part dirty when this shape is mutated
|
|
29
29
|
def initialize(node:, slide: nil)
|
|
30
30
|
@node = node
|
|
31
31
|
@slide = slide
|
|
@@ -37,91 +37,47 @@ module Rbpptx
|
|
|
37
37
|
@node.at_xpath("./p:nvSpPr/p:cNvPr/@name", "p" => P_NS)&.value
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# @return [Boolean] whether the shape is a placeholder
|
|
41
|
+
def placeholder?
|
|
42
|
+
!placeholder_node.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
# @return [Symbol, nil] the placeholder type as a Symbol (e.g.
|
|
41
46
|
# +:ctrTitle+, +:subTitle+, +:title+, +:body+, +:obj+, +:ftr+,
|
|
42
47
|
# +:dt+, +:sldNum+, +:pic+), or +nil+ if the shape is not a
|
|
43
48
|
# placeholder. Returns +:body+ when the shape has a +<p:ph>+ element
|
|
44
49
|
# with no +type+ attribute, matching the OOXML default.
|
|
45
50
|
def placeholder_type
|
|
46
|
-
ph =
|
|
51
|
+
ph = placeholder_node
|
|
47
52
|
return nil unless ph
|
|
48
53
|
|
|
49
54
|
type = ph["type"]
|
|
50
55
|
type ? type.to_sym : :body
|
|
51
56
|
end
|
|
52
57
|
|
|
53
|
-
# @return [
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
# @return [Integer, nil] the placeholder index (+<p:ph idx="...">+) that
|
|
59
|
+
# links the shape to its layout placeholder, or +nil+ if the shape is
|
|
60
|
+
# not a placeholder. Returns +0+ when +idx+ is omitted, matching the
|
|
61
|
+
# OOXML default.
|
|
62
|
+
def placeholder_idx
|
|
63
|
+
ph = placeholder_node
|
|
64
|
+
return nil unless ph
|
|
59
65
|
|
|
60
|
-
|
|
66
|
+
ph["idx"] ? Integer(ph["idx"], 10) : 0
|
|
61
67
|
end
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
# across paragraphs in document order
|
|
65
|
-
def runs
|
|
66
|
-
paragraphs.flat_map(&:runs)
|
|
67
|
-
end
|
|
69
|
+
private
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# rendered as +"\n"+.
|
|
72
|
-
#
|
|
73
|
-
# @return [String]
|
|
74
|
-
def text
|
|
75
|
-
paragraphs.map(&:text).join("\n")
|
|
71
|
+
def placeholder_node
|
|
72
|
+
@node.at_xpath("./p:nvSpPr/p:nvPr/p:ph", "p" => P_NS)
|
|
76
73
|
end
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# +<a:rPr>+ (font, size, color, language) are cloned onto each new run
|
|
81
|
-
# so template formatting carries through.
|
|
82
|
-
#
|
|
83
|
-
# This is the bulldozer-level setter; for edits that need to preserve
|
|
84
|
-
# individual run formatting (e.g. replacing a placeholder inside a
|
|
85
|
-
# multi-run paragraph), use {#runs} or {#paragraphs} and {Run#text=}
|
|
86
|
-
# instead.
|
|
87
|
-
#
|
|
88
|
-
# Shapes without a +<p:txBody>+ (e.g. picture or group shapes) cannot
|
|
89
|
-
# accept text and will raise.
|
|
90
|
-
#
|
|
91
|
-
# @param new_text [String]
|
|
92
|
-
# @return [String] the value that was set
|
|
93
|
-
# @raise [Rbpptx::Error] if the shape has no text body
|
|
94
|
-
def text=(new_text)
|
|
95
|
-
txbody = @node.at_xpath("./p:txBody", "p" => P_NS)
|
|
96
|
-
raise Error, "shape has no <p:txBody>; cannot set text" unless txbody
|
|
97
|
-
|
|
98
|
-
template_rpr = txbody.at_xpath(".//a:r/a:rPr", "a" => A_NS)
|
|
99
|
-
txbody.xpath("./a:p", "a" => A_NS).each(&:remove)
|
|
100
|
-
|
|
101
|
-
lines = new_text.to_s.empty? ? [""] : new_text.to_s.split("\n", -1)
|
|
102
|
-
txbody.add_child(build_paragraphs_xml(lines))
|
|
103
|
-
|
|
104
|
-
if template_rpr
|
|
105
|
-
txbody.xpath("./a:p/a:r", "a" => A_NS).each do |run|
|
|
106
|
-
first_child = run.children.first
|
|
107
|
-
first_child ? first_child.add_previous_sibling(template_rpr.dup) : run.add_child(template_rpr.dup)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
@slide&.mark_dirty!
|
|
112
|
-
new_text
|
|
75
|
+
def text_body_node
|
|
76
|
+
@node.at_xpath("./p:txBody", "p" => P_NS)
|
|
113
77
|
end
|
|
114
78
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def build_paragraphs_xml(lines)
|
|
118
|
-
lines.map do |line|
|
|
119
|
-
if line.empty?
|
|
120
|
-
"<a:p/>"
|
|
121
|
-
else
|
|
122
|
-
"<a:p><a:r><a:t>#{CGI.escapeHTML(line)}</a:t></a:r></a:p>"
|
|
123
|
-
end
|
|
124
|
-
end.join
|
|
79
|
+
def text_body_label
|
|
80
|
+
"shape has no <p:txBody>"
|
|
125
81
|
end
|
|
126
82
|
end
|
|
127
83
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module Rbpptx
|
|
2
|
+
# Shape-tree navigation shared by {Slide}, {NotesSlide} and {GroupShape}.
|
|
3
|
+
#
|
|
4
|
+
# Including classes provide a private +shape_tree_node+ returning the
|
|
5
|
+
# element whose children are the shapes (+<p:spTree>+ or +<p:grpSp>+),
|
|
6
|
+
# and a private +owner_part+ returning the part to mark dirty on edits.
|
|
7
|
+
#
|
|
8
|
+
# Only the element kinds rbpptx understands are surfaced: +<p:sp>+,
|
|
9
|
+
# +<p:grpSp>+, and +<p:graphicFrame>+ holding an +<a:tbl>+. Pictures,
|
|
10
|
+
# connectors, charts, SmartArt and +<mc:AlternateContent>+ wrappers are
|
|
11
|
+
# skipped and left untouched.
|
|
12
|
+
module ShapeContainer
|
|
13
|
+
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main".freeze
|
|
14
|
+
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
|
|
15
|
+
|
|
16
|
+
# @return [Array<Shape>] direct +<p:sp>+ children, in document order
|
|
17
|
+
def shapes
|
|
18
|
+
direct_children("sp").map { |n| Shape.new(node: n, slide: owner_part) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Array<GroupShape>] direct +<p:grpSp>+ children, in document
|
|
22
|
+
# order
|
|
23
|
+
def groups
|
|
24
|
+
direct_children("grpSp").map { |n| GroupShape.new(node: n, slide: owner_part) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<Table>] direct +<p:graphicFrame>+ children that hold a
|
|
28
|
+
# table, in document order
|
|
29
|
+
def tables
|
|
30
|
+
direct_children("graphicFrame").filter_map { |n| build_table(n) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<Shape>] every +<p:sp>+ in the tree, descending into
|
|
34
|
+
# groups (nested to any depth), in document order
|
|
35
|
+
def all_shapes
|
|
36
|
+
walk_tree(shape_tree_node, [], [])[0]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<Table>] every table in the tree, descending into groups,
|
|
40
|
+
# in document order
|
|
41
|
+
def all_tables
|
|
42
|
+
walk_tree(shape_tree_node, [], [])[1]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def direct_children(local_name)
|
|
48
|
+
node = shape_tree_node
|
|
49
|
+
return [] unless node
|
|
50
|
+
|
|
51
|
+
node.xpath("./p:#{local_name}", "p" => P_NS)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_table(frame)
|
|
55
|
+
return nil unless frame.at_xpath("./a:graphic/a:graphicData/a:tbl", "a" => A_NS)
|
|
56
|
+
|
|
57
|
+
Table.new(node: frame, slide: owner_part)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def walk_tree(node, shapes, tables)
|
|
61
|
+
return [shapes, tables] unless node
|
|
62
|
+
|
|
63
|
+
node.element_children.each do |child|
|
|
64
|
+
next unless child.namespace&.href == P_NS
|
|
65
|
+
|
|
66
|
+
case child.name
|
|
67
|
+
when "sp" then shapes << Shape.new(node: child, slide: owner_part)
|
|
68
|
+
when "grpSp" then walk_tree(child, shapes, tables)
|
|
69
|
+
when "graphicFrame"
|
|
70
|
+
table = build_table(child)
|
|
71
|
+
tables << table if table
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
[shapes, tables]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A group shape (+<p:grpSp>+). Groups nest; use {#shapes} for direct
|
|
79
|
+
# children or {#all_shapes} to flatten the whole subtree.
|
|
80
|
+
class GroupShape
|
|
81
|
+
include ShapeContainer
|
|
82
|
+
|
|
83
|
+
# @param node [Nokogiri::XML::Element] the +<p:grpSp>+ element
|
|
84
|
+
# @param slide [Slide, NotesSlide, nil] owning part
|
|
85
|
+
def initialize(node:, slide: nil)
|
|
86
|
+
@node = node
|
|
87
|
+
@slide = slide
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [String, nil] value of +<p:nvGrpSpPr>/<p:cNvPr name="...">+
|
|
91
|
+
def name
|
|
92
|
+
@node.at_xpath("./p:nvGrpSpPr/p:cNvPr/@name", "p" => P_NS)&.value
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String] text of every shape in the group (see {#all_shapes}),
|
|
96
|
+
# one shape per line, empty shapes skipped. Read-only convenience.
|
|
97
|
+
def text
|
|
98
|
+
all_shapes.map(&:text).reject(&:empty?).join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def shape_tree_node
|
|
104
|
+
@node
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def owner_part
|
|
108
|
+
@slide
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The placeholders of a slide, addressable by type or by index.
|
|
113
|
+
#
|
|
114
|
+
# slide.placeholders[:title] # first placeholder of type :title
|
|
115
|
+
# slide.placeholders[1] # placeholder with <p:ph idx="1">
|
|
116
|
+
#
|
|
117
|
+
# Integer keys follow python-pptx: they match the placeholder's +idx+
|
|
118
|
+
# attribute (see {Shape#placeholder_idx}), *not* its position in the list.
|
|
119
|
+
class Placeholders
|
|
120
|
+
include Enumerable
|
|
121
|
+
|
|
122
|
+
# @param shapes [Array<Shape>]
|
|
123
|
+
def initialize(shapes)
|
|
124
|
+
@shapes = shapes.select(&:placeholder?)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @yieldparam shape [Shape]
|
|
128
|
+
def each(&block)
|
|
129
|
+
return enum_for(:each) { size } unless block
|
|
130
|
+
|
|
131
|
+
@shapes.each(&block)
|
|
132
|
+
self
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param key [Symbol, Integer] placeholder type, or placeholder +idx+
|
|
136
|
+
# @return [Shape, nil]
|
|
137
|
+
def [](key)
|
|
138
|
+
case key
|
|
139
|
+
when Symbol then @shapes.find { |s| s.placeholder_type == key }
|
|
140
|
+
when Integer then @shapes.find { |s| s.placeholder_idx == key }
|
|
141
|
+
else raise TypeError, "placeholder key must be a Symbol (type) or Integer (idx), got #{key.class}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Integer]
|
|
146
|
+
def size
|
|
147
|
+
@shapes.size
|
|
148
|
+
end
|
|
149
|
+
alias length size
|
|
150
|
+
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def empty?
|
|
153
|
+
@shapes.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Array<Shape>]
|
|
157
|
+
def to_a
|
|
158
|
+
@shapes.dup
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|