ruby_pptx 0.1.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.
Files changed (101) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE +48 -0
  5. data/README.md +272 -0
  6. data/lib/ruby_pptx/action.rb +120 -0
  7. data/lib/ruby_pptx/autoshape_spec.rb +337 -0
  8. data/lib/ruby_pptx/builder.rb +228 -0
  9. data/lib/ruby_pptx/chart/categories.rb +208 -0
  10. data/lib/ruby_pptx/chart/chart.rb +443 -0
  11. data/lib/ruby_pptx/chart/combo.rb +230 -0
  12. data/lib/ruby_pptx/chart/data.rb +147 -0
  13. data/lib/ruby_pptx/chart/format.rb +647 -0
  14. data/lib/ruby_pptx/chart/workbook_writer.rb +212 -0
  15. data/lib/ruby_pptx/chart/xml_writer.rb +953 -0
  16. data/lib/ruby_pptx/chart/xy_data.rb +182 -0
  17. data/lib/ruby_pptx/core_ext.rb +12 -0
  18. data/lib/ruby_pptx/dml/color.rb +155 -0
  19. data/lib/ruby_pptx/dml/effect.rb +33 -0
  20. data/lib/ruby_pptx/dml/fill.rb +234 -0
  21. data/lib/ruby_pptx/element_proxy.rb +53 -0
  22. data/lib/ruby_pptx/enum/action.rb +46 -0
  23. data/lib/ruby_pptx/enum/base.rb +139 -0
  24. data/lib/ruby_pptx/enum/chart.rb +278 -0
  25. data/lib/ruby_pptx/enum/dml.rb +237 -0
  26. data/lib/ruby_pptx/enum/lang.rb +441 -0
  27. data/lib/ruby_pptx/enum/prog_id.rb +38 -0
  28. data/lib/ruby_pptx/enum/shapes.rb +514 -0
  29. data/lib/ruby_pptx/enum/text.rb +108 -0
  30. data/lib/ruby_pptx/errors.rb +15 -0
  31. data/lib/ruby_pptx/geometry.rb +89 -0
  32. data/lib/ruby_pptx/image.rb +347 -0
  33. data/lib/ruby_pptx/length.rb +120 -0
  34. data/lib/ruby_pptx/media.rb +64 -0
  35. data/lib/ruby_pptx/numeric_lengths.rb +30 -0
  36. data/lib/ruby_pptx/opc/constants.rb +205 -0
  37. data/lib/ruby_pptx/opc/oxml.rb +99 -0
  38. data/lib/ruby_pptx/opc/pack_uri.rb +146 -0
  39. data/lib/ruby_pptx/opc/package.rb +482 -0
  40. data/lib/ruby_pptx/opc/serialized.rb +229 -0
  41. data/lib/ruby_pptx/opc/spec.rb +37 -0
  42. data/lib/ruby_pptx/oxml/action.rb +43 -0
  43. data/lib/ruby_pptx/oxml/chart.rb +785 -0
  44. data/lib/ruby_pptx/oxml/content_model.rb +186 -0
  45. data/lib/ruby_pptx/oxml/core_properties.rb +145 -0
  46. data/lib/ruby_pptx/oxml/dml/color.rb +115 -0
  47. data/lib/ruby_pptx/oxml/dml/fill.rb +137 -0
  48. data/lib/ruby_pptx/oxml/element.rb +329 -0
  49. data/lib/ruby_pptx/oxml/ns.rb +115 -0
  50. data/lib/ruby_pptx/oxml/presentation.rb +110 -0
  51. data/lib/ruby_pptx/oxml/section.rb +65 -0
  52. data/lib/ruby_pptx/oxml/shapes/autoshape.rb +310 -0
  53. data/lib/ruby_pptx/oxml/shapes/groupshape.rb +217 -0
  54. data/lib/ruby_pptx/oxml/shapes/other.rb +405 -0
  55. data/lib/ruby_pptx/oxml/shapes/shared.rb +292 -0
  56. data/lib/ruby_pptx/oxml/simple_types.rb +563 -0
  57. data/lib/ruby_pptx/oxml/slide.rb +290 -0
  58. data/lib/ruby_pptx/oxml/table.rb +253 -0
  59. data/lib/ruby_pptx/oxml/text.rb +324 -0
  60. data/lib/ruby_pptx/oxml/theme.rb +93 -0
  61. data/lib/ruby_pptx/package.rb +139 -0
  62. data/lib/ruby_pptx/parts/chart.rb +75 -0
  63. data/lib/ruby_pptx/parts/core_properties.rb +40 -0
  64. data/lib/ruby_pptx/parts/embedded_package.rb +43 -0
  65. data/lib/ruby_pptx/parts/image.rb +55 -0
  66. data/lib/ruby_pptx/parts/media.rb +17 -0
  67. data/lib/ruby_pptx/parts/presentation.rb +79 -0
  68. data/lib/ruby_pptx/parts/slide.rb +221 -0
  69. data/lib/ruby_pptx/parts/theme.rb +26 -0
  70. data/lib/ruby_pptx/pattern_matching.rb +50 -0
  71. data/lib/ruby_pptx/presentation.rb +128 -0
  72. data/lib/ruby_pptx/refinements.rb +21 -0
  73. data/lib/ruby_pptx/section.rb +130 -0
  74. data/lib/ruby_pptx/shapes/adjustments.rb +83 -0
  75. data/lib/ruby_pptx/shapes/authoring.rb +110 -0
  76. data/lib/ruby_pptx/shapes/base.rb +138 -0
  77. data/lib/ruby_pptx/shapes/freeform.rb +141 -0
  78. data/lib/ruby_pptx/shapes/placeholder.rb +184 -0
  79. data/lib/ruby_pptx/shapes/shape.rb +337 -0
  80. data/lib/ruby_pptx/shapes/shape_tree.rb +634 -0
  81. data/lib/ruby_pptx/sliceable.rb +34 -0
  82. data/lib/ruby_pptx/slide.rb +430 -0
  83. data/lib/ruby_pptx/table.rb +316 -0
  84. data/lib/ruby_pptx/table_paging.rb +91 -0
  85. data/lib/ruby_pptx/templates/default.pptx +0 -0
  86. data/lib/ruby_pptx/templates/docx-icon.emf +0 -0
  87. data/lib/ruby_pptx/templates/generic-icon.emf +0 -0
  88. data/lib/ruby_pptx/templates/media-speaker.png +0 -0
  89. data/lib/ruby_pptx/templates/notes.xml +23 -0
  90. data/lib/ruby_pptx/templates/notesMaster.xml +352 -0
  91. data/lib/ruby_pptx/templates/pptx-icon.emf +0 -0
  92. data/lib/ruby_pptx/templates/slideMaster.xml +277 -0
  93. data/lib/ruby_pptx/templates/theme.xml +321 -0
  94. data/lib/ruby_pptx/templates/xlsx-icon.emf +0 -0
  95. data/lib/ruby_pptx/text/fitter.rb +72 -0
  96. data/lib/ruby_pptx/text/font_metrics.rb +221 -0
  97. data/lib/ruby_pptx/text/text.rb +393 -0
  98. data/lib/ruby_pptx/theme.rb +98 -0
  99. data/lib/ruby_pptx/version.rb +5 -0
  100. data/lib/ruby_pptx.rb +90 -0
  101. metadata +174 -0
@@ -0,0 +1,634 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/element_proxy"
4
+ require "ruby_pptx/shapes/base"
5
+ require "ruby_pptx/shapes/shape"
6
+ require "ruby_pptx/shapes/placeholder"
7
+ require "ruby_pptx/autoshape_spec"
8
+
9
+ module Pptx
10
+ # Builds the right shape proxy for a shape element.
11
+ module ShapeFactory
12
+ module_function
13
+
14
+ # The default mapping, used for shapes on a slide master or layout and as
15
+ # the base for the slide-specific variants.
16
+ def build(shape_element, parent)
17
+ case shape_element.nsptag
18
+ when "p:pic"
19
+ shape_element.movie? ? Movie.new(shape_element, parent) : Picture.new(shape_element, parent)
20
+ when "p:cxnSp" then Connector.new(shape_element, parent)
21
+ when "p:grpSp" then GroupShape.new(shape_element, parent)
22
+ when "p:sp" then Shape.new(shape_element, parent)
23
+ when "p:graphicFrame" then GraphicFrame.new(shape_element, parent)
24
+ else BaseShape.new(shape_element, parent)
25
+ end
26
+ end
27
+
28
+ # A slide placeholder's proxy depends on what it can be filled with.
29
+ SLIDE_PLACEHOLDER_CLASSES = {
30
+ BITMAP: :PicturePlaceholder, PICTURE: :PicturePlaceholder,
31
+ CHART: :ChartPlaceholder, TABLE: :TablePlaceholder
32
+ }.freeze
33
+
34
+ # On a slide a placeholder gets a placeholder proxy: typed by what it can
35
+ # hold while empty, and a placeholder variant of the picture or graphic
36
+ # frame once it has been filled.
37
+ def build_for_slide(shape_element, parent)
38
+ return build(shape_element, parent) unless shape_element.placeholder?
39
+
40
+ case shape_element.nsptag
41
+ when "p:sp"
42
+ name = SLIDE_PLACEHOLDER_CLASSES.fetch(shape_element.ph_type.name, :SlidePlaceholder)
43
+ Pptx.const_get(name).new(shape_element, parent)
44
+ when "p:pic" then PlaceholderPicture.new(shape_element, parent)
45
+ when "p:graphicFrame" then PlaceholderGraphicFrame.new(shape_element, parent)
46
+ else build(shape_element, parent)
47
+ end
48
+ end
49
+
50
+ def build_for_layout(shape_element, parent)
51
+ return LayoutPlaceholder.new(shape_element, parent) if placeholder_sp?(shape_element)
52
+
53
+ build(shape_element, parent)
54
+ end
55
+
56
+ def build_for_master(shape_element, parent)
57
+ return MasterPlaceholder.new(shape_element, parent) if placeholder_sp?(shape_element)
58
+
59
+ build(shape_element, parent)
60
+ end
61
+
62
+ def build_for_notes_slide(shape_element, parent)
63
+ return NotesSlidePlaceholder.new(shape_element, parent) if placeholder_sp?(shape_element)
64
+
65
+ build(shape_element, parent)
66
+ end
67
+
68
+ def placeholder_sp?(shape_element)
69
+ shape_element.nsptag == "p:sp" && shape_element.placeholder?
70
+ end
71
+ end
72
+
73
+ # The shapes of a slide, layout or master, in z-order: first is backmost.
74
+ class BaseShapes < ParentedElementProxy
75
+ include Enumerable
76
+
77
+ def initialize(sp_tree, parent)
78
+ super
79
+ @sp_tree = sp_tree
80
+ end
81
+
82
+ def each
83
+ return enum_for(:each) { size } unless block_given?
84
+
85
+ member_elements.each { |element| yield shape_factory(element) }
86
+ self
87
+ end
88
+
89
+ # A group counts as one shape, whatever it contains.
90
+ def size = member_elements.size
91
+ alias length size
92
+
93
+ def [](index, length = nil)
94
+ slice_members(member_elements, index, length) { |element| shape_factory(element) }
95
+ end
96
+
97
+ def fetch(index)
98
+ self[index] || raise(IndexError, "shape index #{index} out of range")
99
+ end
100
+
101
+ # Add a placeholder to this collection modelled on +placeholder+.
102
+ def clone_placeholder(placeholder)
103
+ sp = placeholder.element
104
+ id = next_shape_id
105
+ name = next_placeholder_name(sp.ph_type, id, sp.ph_orient)
106
+ @sp_tree.add_placeholder(id, name, sp.ph_type, sp.ph_orient, sp.ph_sz, sp.ph_idx)
107
+ end
108
+
109
+ # The base name PowerPoint gives a placeholder of +ph_type+.
110
+ #
111
+ # A notes slide names its body placeholder differently, so subclasses can
112
+ # override this.
113
+ def placeholder_basename(ph_type)
114
+ PLACEHOLDER_BASENAMES.fetch(ph_type.name) do
115
+ raise ArgumentError, "no placeholder base name for #{ph_type}"
116
+ end
117
+ end
118
+
119
+ PLACEHOLDER_BASENAMES = {
120
+ BITMAP: "ClipArt Placeholder",
121
+ BODY: "Text Placeholder",
122
+ CENTER_TITLE: "Title",
123
+ CHART: "Chart Placeholder",
124
+ DATE: "Date Placeholder",
125
+ FOOTER: "Footer Placeholder",
126
+ HEADER: "Header Placeholder",
127
+ MEDIA_CLIP: "Media Placeholder",
128
+ OBJECT: "Content Placeholder",
129
+ ORG_CHART: "SmartArt Placeholder",
130
+ PICTURE: "Picture Placeholder",
131
+ SLIDE_NUMBER: "Slide Number Placeholder",
132
+ SUBTITLE: "Subtitle",
133
+ TABLE: "Table Placeholder",
134
+ TITLE: "Title"
135
+ }.freeze
136
+
137
+ private
138
+
139
+ # Decide which file the blip points at and which, if any, hangs off its
140
+ # SVG extension.
141
+ #
142
+ # @return [Array(Object, Object)] the raster to embed, and the SVG or nil
143
+ def vector_or_raster(image_file, fallback)
144
+ unless Image.from_file(image_file).vector?
145
+ return [image_file, nil] if fallback.nil?
146
+
147
+ raise ArgumentError, "fallback: is only meaningful for a vector image"
148
+ end
149
+
150
+ if fallback.nil?
151
+ raise ArgumentError,
152
+ "an SVG needs a raster fallback: add_picture(svg, fallback: png). " \
153
+ "This gem cannot rasterize one for you."
154
+ end
155
+
156
+ [fallback, image_file]
157
+ end
158
+
159
+ # Which shape elements belong to this collection; placeholder collections
160
+ # narrow this.
161
+ def member?(_shape_element) = true
162
+
163
+ def member_elements = @sp_tree.shape_elements.select { |e| member?(e) }
164
+
165
+ def shape_factory(shape_element) = ShapeFactory.build(shape_element, self)
166
+
167
+ # One more than the highest id in use. Note the shape tree's own allocator
168
+ # fills gaps instead; both behaviours are inherited from python-pptx.
169
+ def next_shape_id = @sp_tree.max_shape_id + 1
170
+
171
+ # Office's icon for an Office document, a generic one for anything else.
172
+ def default_ole_icon(prog_id)
173
+ name = prog_id.is_a?(Enum::PROG_ID::Member) ? prog_id.icon_filename : "generic-icon.emf"
174
+ File.expand_path("../templates/#{name}", __dir__)
175
+ end
176
+
177
+ # The name PowerPoint would give a new placeholder: the base name for its
178
+ # type followed by id - 1, bumped until it is unique in the tree, and
179
+ # prefixed "Vertical " for a vertical placeholder.
180
+ def next_placeholder_name(ph_type, id, orient)
181
+ basename = placeholder_basename(ph_type)
182
+ basename = "Vertical #{basename}" if orient == Oxml::SimpleTypes::ST_Direction::VERT
183
+
184
+ taken = @sp_tree.xpath("//p:cNvPr/@name").map(&:value)
185
+ numpart = id - 1
186
+ numpart += 1 while taken.include?("#{basename} #{numpart}")
187
+ "#{basename} #{numpart}"
188
+ end
189
+ end
190
+
191
+ # A shape collection you can add shapes to: a slide's tree, or a group
192
+ # within one.
193
+ #
194
+ # Adding to a group changes the group's extents, so every method here ends
195
+ # by recalculating them. On a slide that is a no-op.
196
+ class BaseGroupShapes < BaseShapes
197
+ # Add an auto shape.
198
+ #
199
+ # shapes.add_shape(:rounded_rectangle,
200
+ # at: [Pptx.inches(1), Pptx.inches(1)],
201
+ # size: [Pptx.inches(2), Pptx.inches(1)])
202
+ #
203
+ # @param shape_type [Pptx::Enum::MSO_SHAPE, Symbol, Integer]
204
+ # @param at [Array(Length, Length)] left and top
205
+ # @param size [Array(Length, Length)] width and height
206
+ # @return [Shape]
207
+ def add_shape(shape_type, at:, size:)
208
+ left, top = at
209
+ width, height = size
210
+ member = Enum::MSO_SHAPE.fetch(shape_type)
211
+ id = next_shape_id
212
+ name = "#{AutoShapeSpec.basename(member)} #{id - 1}"
213
+ sp = @sp_tree.add_autoshape(id, name, Enum::MSO_SHAPE.to_xml(member),
214
+ left, top, width, height)
215
+ recalculate_extents
216
+ shape_factory(sp)
217
+ end
218
+
219
+ # Add a picture showing the image in +image_file+, which may be a path or
220
+ # an IO stream.
221
+ #
222
+ # Supplying neither +width+ nor +height+ uses the image's native size;
223
+ # supplying one scales the other to preserve the aspect ratio; supplying
224
+ # both stretches the image to fit.
225
+ #
226
+ # An SVG additionally needs +fallback+: a raster image PowerPoint shows to
227
+ # consumers that cannot draw the vector. This gem has no rasterizer, so
228
+ # the fallback has to be supplied rather than generated. The picture is
229
+ # sized from the fallback, since an SVG carries no pixel size of its own.
230
+ #
231
+ # shapes.add_picture("logo.svg", at: [x, y], fallback: "logo.png")
232
+ #
233
+ # @param at [Array(Length, Length)] left and top
234
+ # @param fallback [String, IO, nil] raster stand-in, required for an SVG
235
+ # @return [Picture]
236
+ def add_picture(image_file, at:, width: nil, height: nil, fallback: nil)
237
+ left, top = at
238
+ raster_file, svg_file = vector_or_raster(image_file, fallback)
239
+
240
+ image_part, r_id = part.get_or_add_image_part(raster_file)
241
+ scaled_width, scaled_height = image_part.scale(width, height)
242
+ id = next_shape_id
243
+ pic = @sp_tree.add_pic(id, "Picture #{id - 1}", image_part.desc, r_id,
244
+ left, top, scaled_width, scaled_height)
245
+
246
+ if svg_file
247
+ _svg_part, svg_r_id = part.get_or_add_image_part(svg_file)
248
+ pic.blip.add_svg_blip(svg_r_id)
249
+ end
250
+
251
+ recalculate_extents
252
+ shape_factory(pic)
253
+ end
254
+
255
+ # Add a movie showing the video in +movie_file+.
256
+ #
257
+ # The size must be given: unlike a picture, a video is not interrogated
258
+ # for its dimensions. Nor is it interrogated for its type, so say what it
259
+ # is with +content_type+; PowerPoint plays "video/unknown" anyway, which
260
+ # is why that is the default.
261
+ #
262
+ # +poster_frame+ is the still shown before the video plays. Without one,
263
+ # the loudspeaker image PowerPoint uses is supplied.
264
+ #
265
+ # @return [Movie]
266
+ def add_movie(movie_file, at:, size:, poster_frame: nil,
267
+ content_type: Video::UNKNOWN_CONTENT_TYPE)
268
+ left, top = at
269
+ width, height = size
270
+ video = Video.from_file(movie_file, content_type)
271
+ media_r_id, video_r_id = part.get_or_add_video_media_part(video)
272
+ _poster_part, poster_r_id = part.get_or_add_image_part(poster_frame || default_poster_frame)
273
+
274
+ id = next_shape_id
275
+ pic = @sp_tree.add_video_pic(id, video.filename, video_r_id, media_r_id, poster_r_id,
276
+ left, top, width, height)
277
+ register_video_timing(pic)
278
+ recalculate_extents
279
+ shape_factory(pic)
280
+ end
281
+
282
+ # Add a chart of +chart_type+ depicting +chart_data+.
283
+ #
284
+ # @return [GraphicFrame] use its `#chart` to reach the chart itself
285
+ def add_chart(chart_type, chart_data, at:, size:)
286
+ left, top = at
287
+ width, height = size
288
+ r_id = part.add_chart_part(chart_type, chart_data)
289
+ id = next_shape_id
290
+ frame = @sp_tree.add_graphic_frame_chart(id, "Chart #{id - 1}", r_id,
291
+ left, top, width, height)
292
+ recalculate_extents
293
+ shape_factory(frame)
294
+ end
295
+
296
+ # Add a chart drawing several plots over the same categories.
297
+ #
298
+ # shapes.add_combo_chart(data, at: [x, y], size: [w, h]) do |combo|
299
+ # combo.plot :column_clustered, series: "Revenue"
300
+ # combo.plot :line, series: "Margin", secondary_axis: true
301
+ # end
302
+ #
303
+ # All the series live in one ChartData, since they share a worksheet; each
304
+ # plot names the ones it draws. PowerPoint can do this and python-pptx
305
+ # cannot, so there is no reference implementation to compare against.
306
+ #
307
+ # @return [GraphicFrame] use its `#chart` to reach the chart itself
308
+ def add_combo_chart(chart_data, at:, size:)
309
+ builder = ComboChartBuilder.new(chart_data)
310
+ yield builder if block_given?
311
+
312
+ left, top = at
313
+ width, height = size
314
+ r_id = part.add_combo_chart_part(builder)
315
+ id = next_shape_id
316
+ frame = @sp_tree.add_graphic_frame_chart(id, "Chart #{id - 1}", r_id,
317
+ left, top, width, height)
318
+ recalculate_extents
319
+ shape_factory(frame)
320
+ end
321
+
322
+ # Add a table of +rows+ by +cols+.
323
+ #
324
+ # @return [GraphicFrame] use its `#table` to reach the table itself
325
+ def add_table(rows, cols, at:, size:)
326
+ left, top = at
327
+ width, height = size
328
+ id = next_shape_id
329
+ frame = @sp_tree.add_graphic_frame_table(id, "Table #{id - 1}", rows, cols,
330
+ left, top, width, height)
331
+ recalculate_extents
332
+ shape_factory(frame)
333
+ end
334
+
335
+ # Embed a file as an OLE object, shown on the slide as an icon.
336
+ #
337
+ # shapes.add_ole_object("budget.xlsx", prog_id: :xlsx, at: [x, y])
338
+ # shapes.add_ole_object("report.pdf", prog_id: "AcroExch.Document", at: [x, y],
339
+ # icon_file: "pdf-icon.png")
340
+ #
341
+ # +prog_id+ is :docx, :pptx or :xlsx for an Office document, which is
342
+ # stored as the document itself and gets Office's icon; any other type is
343
+ # named by its ProgID String and stored as an opaque object with a
344
+ # generic icon. Double-clicking the icon in PowerPoint opens the file in
345
+ # the program the ProgID names.
346
+ #
347
+ # @param size [Array(Length, Length), nil] the shape's size; defaults to
348
+ # the icon's
349
+ # @param icon_file [String, IO, nil] an image to show instead of the
350
+ # default icon
351
+ # @param icon_size [Array(Length, Length), nil] the icon image's own size
352
+ # @return [GraphicFrame]
353
+ def add_ole_object(object_file, prog_id:, at:, size: nil, icon_file: nil, icon_size: nil)
354
+ left, top = at
355
+ prog_id = Enum::PROG_ID.resolve(prog_id)
356
+ default_size = [Length.emu(965_200), Length.emu(609_600)]
357
+ width, height = size || (if prog_id.is_a?(Enum::PROG_ID::Member)
358
+ [prog_id.width,
359
+ prog_id.height]
360
+ else
361
+ default_size
362
+ end)
363
+ icon_width, icon_height = icon_size || default_size
364
+ ole_r_id = part.add_embedded_ole_object_part(prog_id, object_file)
365
+ _icon_part, icon_r_id = part.get_or_add_image_part(icon_file || default_ole_icon(prog_id))
366
+ id = next_shape_id
367
+ prog_id_text = prog_id.is_a?(Enum::PROG_ID::Member) ? prog_id.prog_id : prog_id
368
+ frame = @sp_tree.add_graphic_frame_ole_object(id, "Object #{id - 1}", ole_r_id, prog_id_text,
369
+ icon_r_id, left, top, width, height,
370
+ icon_width, icon_height)
371
+ recalculate_extents
372
+ shape_factory(frame)
373
+ end
374
+
375
+ # Add an empty text box.
376
+ #
377
+ # @return [Shape]
378
+ def add_textbox(at:, size:)
379
+ left, top = at
380
+ width, height = size
381
+ id = next_shape_id
382
+ sp = @sp_tree.add_textbox(id, "TextBox #{id - 1}", left, top, width, height)
383
+ recalculate_extents
384
+ shape_factory(sp)
385
+ end
386
+
387
+ # Add a connector between two points.
388
+ #
389
+ # A connector is stored as a bounding box with flip flags rather than as
390
+ # two points, so the end points are converted here.
391
+ #
392
+ # @return [Connector]
393
+ def add_connector(connector_type, begin_at:, end_at:)
394
+ member = Enum::MSO_CONNECTOR_TYPE.fetch(connector_type)
395
+ begin_x, begin_y = begin_at.map { |value| Length.coerce(value).emu }
396
+ end_x, end_y = end_at.map { |value| Length.coerce(value).emu }
397
+
398
+ id = next_shape_id
399
+ cxn_sp = @sp_tree.add_cxnSp(
400
+ id, "Connector #{id - 1}", Enum::MSO_CONNECTOR_TYPE.to_xml(member),
401
+ [begin_x, end_x].min, [begin_y, end_y].min,
402
+ (end_x - begin_x).abs, (end_y - begin_y).abs,
403
+ begin_x > end_x, begin_y > end_y
404
+ )
405
+ recalculate_extents
406
+ shape_factory(cxn_sp)
407
+ end
408
+
409
+ # Start building a freeform shape.
410
+ #
411
+ # +scale+ says how many EMU one local coordinate unit is worth, so a shape
412
+ # can be described in convenient numbers; pass a pair for different
413
+ # horizontal and vertical scales.
414
+ #
415
+ # @return [FreeformBuilder]
416
+ def build_freeform(start_x: 0, start_y: 0, scale: 1.0)
417
+ FreeformBuilder.new_builder(self, start_x, start_y, scale)
418
+ end
419
+
420
+ # Build a freeform shape and add it, in one call.
421
+ #
422
+ # shapes.add_freeform(at: [x, y], scale: Pptx.inches(1).emu / 100.0) do |f|
423
+ # f.line_to(100, 0)
424
+ # f.line_to(50, 100)
425
+ # end
426
+ #
427
+ # @return [Shape]
428
+ def add_freeform(at: [0, 0], start_x: 0, start_y: 0, scale: 1.0, close: true)
429
+ builder = build_freeform(start_x: start_x, start_y: start_y, scale: scale)
430
+ yield builder if block_given?
431
+ builder.close if close
432
+ builder.convert_to_shape(origin_at: at)
433
+ end
434
+
435
+ # The loudspeaker still PowerPoint shows for a video with no poster frame.
436
+ def default_poster_frame
437
+ StringIO.new(File.binread(
438
+ File.expand_path("../templates/media-speaker.png", __dir__)
439
+ ))
440
+ end
441
+
442
+ # Play controls appear only for a movie listed in the slide's timing tree.
443
+ def register_video_timing(pic)
444
+ slide_element = @sp_tree.xpath("/p:sld").first
445
+ return if slide_element.nil?
446
+
447
+ slide_element.get_or_add_child_time_node_list.add_video(pic.shape_id)
448
+ end
449
+
450
+ # @api private
451
+ # Used by FreeformBuilder, which needs to add the element and then draw
452
+ # into it.
453
+ def add_freeform_element(x, y, width, height)
454
+ @sp_tree.add_freeform_sp(x, y, width, height)
455
+ end
456
+
457
+ # @api private
458
+ def build_shape(shape_element)
459
+ recalculate_extents
460
+ shape_factory(shape_element)
461
+ end
462
+
463
+ # Add a group, optionally moving +shapes+ into it.
464
+ #
465
+ # The group has no position or size of its own: both follow from what it
466
+ # contains, and are recomputed whenever its contents change.
467
+ #
468
+ # @return [GroupShape]
469
+ def add_group_shape(shapes = [])
470
+ grp_sp = @sp_tree.add_grpSp
471
+ shapes.each { |shape| grp_sp.insert_element_before(shape.element, "p:extLst") }
472
+ grp_sp.recalculate_extents unless shapes.empty?
473
+ recalculate_extents
474
+ shape_factory(grp_sp)
475
+ end
476
+
477
+ private
478
+
479
+ # A group resizes itself around its contents; a slide does not move.
480
+ def recalculate_extents = nil
481
+
482
+ def shape_factory(shape_element) = ShapeFactory.build_for_slide(shape_element, self)
483
+ end
484
+
485
+ # The shapes on a slide.
486
+ class SlideShapes < BaseGroupShapes
487
+ # Copy the layout's placeholders onto this slide, preserving z-order.
488
+ #
489
+ # Latent placeholders -- date, footer and slide number -- are not cloned:
490
+ # PowerPoint shows them from the layout without materialising them on the
491
+ # slide.
492
+ def clone_layout_placeholders(slide_layout)
493
+ slide_layout.cloneable_placeholders.each { |ph| clone_placeholder(ph) }
494
+ self
495
+ end
496
+
497
+ # The title placeholder, which is the one with idx 0.
498
+ #
499
+ # @return [SlidePlaceholder, nil]
500
+ def title
501
+ element = @sp_tree.placeholder_elements.find { |e| e.ph_idx.zero? }
502
+ element && shape_factory(element)
503
+ end
504
+
505
+ def placeholders = parent.placeholders
506
+
507
+ private
508
+
509
+ def shape_factory(shape_element) = ShapeFactory.build_for_slide(shape_element, self)
510
+ end
511
+
512
+ # The shapes inside a `p:grpSp`.
513
+ class GroupShapes < BaseGroupShapes
514
+ private
515
+
516
+ # Adding to a group changes where the group sits and how big it is.
517
+ def recalculate_extents
518
+ @sp_tree.recalculate_extents
519
+ nil
520
+ end
521
+ end
522
+
523
+ # The shapes on a slide layout.
524
+ class LayoutShapes < BaseShapes
525
+ private
526
+
527
+ def shape_factory(shape_element) = ShapeFactory.build_for_layout(shape_element, self)
528
+ end
529
+
530
+ # The shapes on a slide master.
531
+ class MasterShapes < BaseShapes
532
+ private
533
+
534
+ def shape_factory(shape_element) = ShapeFactory.build_for_master(shape_element, self)
535
+ end
536
+
537
+ # The shapes on a notes page.
538
+ #
539
+ # A notes slide names its body placeholder "Notes Placeholder" and has a
540
+ # slide-image placeholder no other kind of slide carries, so it has its own
541
+ # naming table.
542
+ class NotesSlideShapes < BaseShapes
543
+ NOTES_PLACEHOLDER_BASENAMES = {
544
+ BODY: "Notes Placeholder", DATE: "Date Placeholder", FOOTER: "Footer Placeholder",
545
+ HEADER: "Header Placeholder", SLIDE_IMAGE: "Slide Image Placeholder",
546
+ SLIDE_NUMBER: "Slide Number Placeholder"
547
+ }.freeze
548
+
549
+ def placeholder_basename(ph_type)
550
+ NOTES_PLACEHOLDER_BASENAMES.fetch(ph_type.name) do
551
+ raise ArgumentError, "no notes placeholder base name for #{ph_type}"
552
+ end
553
+ end
554
+
555
+ private
556
+
557
+ def shape_factory(shape_element) = ShapeFactory.build_for_notes_slide(shape_element, self)
558
+ end
559
+
560
+ # The placeholders on a notes page.
561
+ class NotesSlidePlaceholders < NotesSlideShapes
562
+ # @return [NotesSlidePlaceholder, nil]
563
+ def by_type(ph_type) = find { |ph| ph.element.ph_type == ph_type }
564
+
565
+ private
566
+
567
+ def member?(shape_element) = shape_element.placeholder?
568
+ end
569
+
570
+ # The placeholders of a slide layout, in `idx` order.
571
+ class LayoutPlaceholders < LayoutShapes
572
+ include PlaceholderAuthoring
573
+
574
+ # @return [LayoutPlaceholder, nil]
575
+ def by_idx(idx) = find { |ph| ph.element.ph_idx == idx }
576
+
577
+ private
578
+
579
+ def member?(shape_element) = shape_element.placeholder?
580
+
581
+ def member_elements = super.sort_by(&:ph_idx)
582
+ end
583
+
584
+ # The placeholders of a slide master, in `idx` order.
585
+ class MasterPlaceholders < MasterShapes
586
+ include PlaceholderAuthoring
587
+
588
+ # @return [MasterPlaceholder, nil]
589
+ def by_type(ph_type) = find { |ph| ph.element.ph_type == ph_type }
590
+
591
+ private
592
+
593
+ def member?(shape_element) = shape_element.placeholder?
594
+
595
+ def member_elements = super.sort_by(&:ph_idx)
596
+ end
597
+
598
+ # The placeholders of a slide.
599
+ #
600
+ # Ordered by `idx` and looked up by it: `placeholders[1]` is the placeholder
601
+ # whose idx is 1, not the second one in the collection.
602
+ class SlidePlaceholders < ParentedElementProxy
603
+ include Enumerable
604
+
605
+ def initialize(sp_tree, parent)
606
+ super
607
+ @sp_tree = sp_tree
608
+ end
609
+
610
+ def each
611
+ return enum_for(:each) { size } unless block_given?
612
+
613
+ placeholder_elements.each { |e| yield ShapeFactory.build_for_slide(e, self) }
614
+ self
615
+ end
616
+
617
+ def size = @sp_tree.placeholder_elements.count
618
+ alias length size
619
+
620
+ # The placeholder whose `idx` is +idx+, or nil.
621
+ def [](idx)
622
+ element = @sp_tree.placeholder_elements.find { |e| e.ph_idx == idx }
623
+ element && ShapeFactory.build_for_slide(element, self)
624
+ end
625
+
626
+ def fetch(idx)
627
+ self[idx] || raise(NotFoundError, "no placeholder on this slide with idx #{idx}")
628
+ end
629
+
630
+ private
631
+
632
+ def placeholder_elements = @sp_tree.placeholder_elements.sort_by(&:ph_idx)
633
+ end
634
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pptx
4
+ # Array-like indexing for the collections that wrap their members lazily.
5
+ #
6
+ # Every collection in this library holds a list of XML elements and builds
7
+ # the Ruby object for one only when it is asked for. That makes `[]` a little
8
+ # more than a delegation: an integer index yields one wrapped member, while a
9
+ # range or a (start, length) pair yields an array of them.
10
+ #
11
+ # slides[2] #=> a Slide
12
+ # slides[-1] #=> the last Slide
13
+ # slides[1..3] #=> an Array of Slides
14
+ # slides[1, 2] #=> an Array of Slides
15
+ #
16
+ # Out of range gives nil, and a range that starts past the end gives nil
17
+ # rather than [], matching Array.
18
+ module Sliceable
19
+ private
20
+
21
+ # @param members [Array] the backing elements
22
+ # @yieldparam member [Object] one backing element, to be wrapped
23
+ # @return [Object, Array, nil]
24
+ def slice_members(members, index, length = nil, &)
25
+ if length.nil? && !index.is_a?(Range)
26
+ member = members[index]
27
+ return member && yield(member)
28
+ end
29
+
30
+ selected = length.nil? ? members[index] : members[index, length]
31
+ selected&.map(&)
32
+ end
33
+ end
34
+ end