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.
@@ -1,18 +1,25 @@
1
1
  module Rbpptx
2
2
  # A PowerPoint presentation loaded from a +.pptx+ file.
3
3
  #
4
- # Reading is the only mode wired up at present: the package is opened, the
5
- # presentation part is located via +_rels/.rels+, the slide id list is read
6
- # in document order, and slides are resolved through the presentation's own
7
- # rels. Each {Slide} retains a handle on the underlying ZIP and parses its
8
- # XML lazily.
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
- @zip = Zip::File.open(@path)
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. Slides whose shapes have been edited are
49
- # re-serialized from their in-memory Nokogiri document; all other entries
50
- # (themes, masters, layouts, media, fonts, rels) are streamed straight
51
- # from the source ZIP without re-parsing, so unknown PowerPoint features
52
- # round-trip unchanged.
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
- @zip&.close
98
- @zip = nil
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
- pres_part = locate_presentation_part
131
- pres_rels = parse_rels(rels_path_for(pres_part))
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 = parse_xml(pres_part)
135
- sld_ids = doc.xpath("/p:presentation/p:sldIdLst/p:sldId", "p" => P_NS)
278
+ doc = @package.xml(@presentation_part)
279
+ raise PresentationFormatError, "#{@path}: #{@presentation_part} has no root element" unless doc.root
136
280
 
137
- sld_ids.map do |sld_id|
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
- target = pres_rels[rid] or raise PresentationFormatError, "#{@path}: rId #{rid.inspect} not found in presentation rels"
140
- Slide.new(zip: @zip, part_name: resolve_relative(pres_dir, target))
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 = parse_xml("_rels/.rels")
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 parse_rels(rels_part)
158
- entry = @zip.find_entry(rels_part)
159
- return {} unless entry
160
-
161
- doc = Nokogiri::XML(entry.get_input_stream.read)
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} reads the shape's placeholder role from
19
- # +<p:nvSpPr>/<p:nvPr>/<p:ph type="...">+ so callers can target shapes by
20
- # semantic role (e.g. +:ctrTitle+, +:subTitle+, +:body+) instead of by
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 slide dirty
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 = @node.at_xpath("./p:nvSpPr/p:nvPr/p:ph", "p" => P_NS)
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 [Array<Paragraph>] paragraphs in the shape's text body, in
54
- # document order. Returns +[]+ if the shape has no +<p:txBody>+ (e.g.
55
- # picture or group shapes).
56
- def paragraphs
57
- txbody = @node.at_xpath("./p:txBody", "p" => P_NS)
58
- return [] unless txbody
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
- txbody.xpath("./a:p", "a" => A_NS).map { |p| Paragraph.new(node: p, slide: @slide) }
66
+ ph["idx"] ? Integer(ph["idx"], 10) : 0
61
67
  end
62
68
 
63
- # @return [Array<Run>] all runs in the shape's text body, flattened
64
- # across paragraphs in document order
65
- def runs
66
- paragraphs.flat_map(&:runs)
67
- end
69
+ private
68
70
 
69
- # Concatenated text of the shape's text body. Paragraphs are joined with
70
- # +"\n"+; soft line breaks (+<a:br>+) inside a paragraph are also
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
- # Replaces the shape's text body. Lines (separated by +"\n"+) become
79
- # individual +<a:p>+ paragraphs. Run properties from the first existing
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
- private
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