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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/oxml/shapes/shared"
4
+ require "ruby_pptx/oxml/text"
5
+
6
+ module Pptx
7
+ module Oxml
8
+ # `a:avLst`, the adjustment values of a preset geometry.
9
+ class CT_GeomGuideList < Element
10
+ tag "a:avLst"
11
+ zero_or_more "a:gd", as: :gd
12
+ end
13
+
14
+ # `a:gd`, one geometry guide (adjustment value).
15
+ class CT_GeomGuide < Element
16
+ tag "a:gd"
17
+ required_attr "name", type: SimpleTypes::XsdString
18
+ required_attr "fmla", type: SimpleTypes::XsdString
19
+ end
20
+
21
+ # `a:prstGeom`, a preset (built-in) shape geometry.
22
+ class CT_PresetGeometry2D < Element
23
+ tag "a:prstGeom"
24
+ zero_or_one "a:avLst", successors: []
25
+ required_attr "prst", type: Enum::MSO_AUTO_SHAPE_TYPE
26
+
27
+ # The adjustment guides, in document order.
28
+ def gd_list = avLst ? avLst.gd_list : []
29
+
30
+ # Replace the adjustment values with +name_value_pairs+.
31
+ def rewrite_guides(name_value_pairs)
32
+ remove_avLst
33
+ av_lst = get_or_add_avLst
34
+ name_value_pairs.each do |name, value|
35
+ av_lst.add_gd(name: name, fmla: "val #{value}")
36
+ end
37
+ end
38
+ end
39
+
40
+ # `a:custGeom`, a freeform shape geometry.
41
+ class CT_CustomGeometry2D < Element
42
+ tag "a:custGeom"
43
+ zero_or_one "a:pathLst", successors: []
44
+ end
45
+
46
+ # `a:pt`, one point of a path.
47
+ class CT_AdjPoint2D < Element
48
+ tag "a:pt"
49
+ required_attr "x", type: SimpleTypes::ST_Coordinate
50
+ required_attr "y", type: SimpleTypes::ST_Coordinate
51
+ end
52
+
53
+ # `a:moveTo` and `a:lnTo`: lift the pen to a point, or draw to it.
54
+ class CT_Path2DPoint < Element
55
+ tag "a:moveTo", "a:lnTo"
56
+ zero_or_one "a:pt", successors: []
57
+
58
+ def point_at(x, y)
59
+ point = get_or_add_pt
60
+ point.x = x
61
+ point.y = y
62
+ point
63
+ end
64
+ end
65
+
66
+ # `a:close`, ending a contour by joining it back to its start.
67
+ class CT_Path2DClose < Element
68
+ tag "a:close"
69
+ end
70
+
71
+ # `a:path`, one contour of a freeform shape.
72
+ #
73
+ # Its `w` and `h` are in the path's own coordinate space, not EMU: the
74
+ # shape's extents scale that space onto the slide.
75
+ class CT_Path2D < Element
76
+ tag "a:path"
77
+ zero_or_more "a:close", successors: [], as: :close
78
+ zero_or_more "a:lnTo", successors: [], as: :lnTo
79
+ zero_or_more "a:moveTo", successors: [], as: :moveTo
80
+ optional_attr "w", type: SimpleTypes::ST_PositiveCoordinate
81
+ optional_attr "h", type: SimpleTypes::ST_PositiveCoordinate
82
+
83
+ def move_to(x, y) = add_moveTo.point_at(x, y)
84
+
85
+ def line_to(x, y) = add_lnTo.point_at(x, y)
86
+
87
+ def close_contour = add_close
88
+ end
89
+
90
+ # `a:pathLst`, the contours of a freeform shape.
91
+ class CT_Path2DList < Element
92
+ tag "a:pathLst"
93
+ zero_or_more "a:path", successors: [], as: :path
94
+
95
+ def add_path_of(width, height)
96
+ add_path(w: width, h: height)
97
+ end
98
+ end
99
+
100
+ # `p:cNvSpPr`, the non-visual properties specific to a `p:sp`.
101
+ class CT_NonVisualDrawingShapeProps < Element
102
+ tag "p:cNvSpPr"
103
+ zero_or_one "a:spLocks", successors: %w[a:extLst]
104
+ optional_attr "txBox", type: SimpleTypes::XsdBoolean
105
+ end
106
+
107
+ # `p:nvSpPr`, the non-visual properties group of a `p:sp`.
108
+ class CT_ShapeNonVisual < Element
109
+ tag "p:nvSpPr"
110
+ one_and_only_one "p:cNvPr"
111
+ one_and_only_one "p:cNvSpPr"
112
+ one_and_only_one "p:nvPr"
113
+ end
114
+
115
+ # `p:sp`, an auto shape, text box, placeholder or freeform.
116
+ class CT_Shape < Element
117
+ include BaseShapeElement
118
+
119
+ tag "p:sp"
120
+ one_and_only_one "p:nvSpPr"
121
+ one_and_only_one "p:spPr"
122
+ zero_or_one "p:txBody", successors: %w[p:extLst]
123
+
124
+ # Placeholder types that come with a text frame when created.
125
+ TEXTUAL_PLACEHOLDERS = [
126
+ Enum::PP_PLACEHOLDER::TITLE, Enum::PP_PLACEHOLDER::CENTER_TITLE,
127
+ Enum::PP_PLACEHOLDER::SUBTITLE, Enum::PP_PLACEHOLDER::BODY,
128
+ Enum::PP_PLACEHOLDER::OBJECT
129
+ ].freeze
130
+
131
+ class << self
132
+ # A `p:sp` configured as a placeholder inheriting from a layout.
133
+ #
134
+ # The shape carries no geometry or position of its own: a placeholder
135
+ # inherits both from the corresponding placeholder on its layout.
136
+ def new_placeholder_sp(id, name, ph_type, orient, sz, idx)
137
+ sp = Element.parse(<<~XML)
138
+ <p:sp #{Ns.nsdecls("a", "p")}>
139
+ <p:nvSpPr>
140
+ <p:cNvPr id="#{id}" name="#{escape(name)}"/>
141
+ <p:cNvSpPr>
142
+ <a:spLocks noGrp="1"/>
143
+ </p:cNvSpPr>
144
+ <p:nvPr/>
145
+ </p:nvSpPr>
146
+ <p:spPr/>
147
+ </p:sp>
148
+ XML
149
+
150
+ ph = sp.nvSpPr.nvPr.get_or_add_ph
151
+ ph.type = ph_type
152
+ ph.idx = idx
153
+ ph.orient = orient
154
+ ph.sz = sz
155
+
156
+ sp.append(CT_TextBody.new_element(sp)) if TEXTUAL_PLACEHOLDERS.include?(ph_type)
157
+
158
+ sp
159
+ end
160
+
161
+ # A `p:sp` configured as an auto shape with preset geometry.
162
+ def new_autoshape_sp(id, name, prst, x, y, cx, cy)
163
+ Element.parse(<<~XML)
164
+ <p:sp #{Ns.nsdecls("a", "p")}>
165
+ <p:nvSpPr>
166
+ <p:cNvPr id="#{id}" name="#{escape(name)}"/>
167
+ <p:cNvSpPr/>
168
+ <p:nvPr/>
169
+ </p:nvSpPr>
170
+ <p:spPr>
171
+ <a:xfrm>
172
+ <a:off x="#{x.to_i}" y="#{y.to_i}"/>
173
+ <a:ext cx="#{cx.to_i}" cy="#{cy.to_i}"/>
174
+ </a:xfrm>
175
+ <a:prstGeom prst="#{prst}">
176
+ <a:avLst/>
177
+ </a:prstGeom>
178
+ </p:spPr>
179
+ #{DEFAULT_STYLE}
180
+ <p:txBody>
181
+ <a:bodyPr rtlCol="0" anchor="ctr"/>
182
+ <a:lstStyle/>
183
+ <a:p>
184
+ <a:pPr algn="ctr"/>
185
+ </a:p>
186
+ </p:txBody>
187
+ </p:sp>
188
+ XML
189
+ end
190
+
191
+ # A `p:sp` configured as a freeform: custom geometry with an empty
192
+ # path list, ready for contours to be added.
193
+ def new_freeform_sp(id, name, x, y, cx, cy)
194
+ Element.parse(<<~XML)
195
+ <p:sp #{Ns.nsdecls("a", "p")}>
196
+ <p:nvSpPr>
197
+ <p:cNvPr id="#{id}" name="#{escape(name)}"/>
198
+ <p:cNvSpPr/>
199
+ <p:nvPr/>
200
+ </p:nvSpPr>
201
+ <p:spPr>
202
+ <a:xfrm>
203
+ <a:off x="#{x.to_i}" y="#{y.to_i}"/>
204
+ <a:ext cx="#{cx.to_i}" cy="#{cy.to_i}"/>
205
+ </a:xfrm>
206
+ <a:custGeom>
207
+ <a:avLst/>
208
+ <a:gdLst/>
209
+ <a:ahLst/>
210
+ <a:cxnLst/>
211
+ <a:rect l="l" t="t" r="r" b="b"/>
212
+ <a:pathLst/>
213
+ </a:custGeom>
214
+ </p:spPr>
215
+ #{DEFAULT_STYLE}
216
+ <p:txBody>
217
+ <a:bodyPr rtlCol="0" anchor="ctr"/>
218
+ <a:lstStyle/>
219
+ <a:p>
220
+ <a:pPr algn="ctr"/>
221
+ </a:p>
222
+ </p:txBody>
223
+ </p:sp>
224
+ XML
225
+ end
226
+
227
+ # A `p:sp` configured as a text box.
228
+ def new_textbox_sp(id, name, x, y, cx, cy)
229
+ Element.parse(<<~XML)
230
+ <p:sp #{Ns.nsdecls("a", "p")}>
231
+ <p:nvSpPr>
232
+ <p:cNvPr id="#{id}" name="#{escape(name)}"/>
233
+ <p:cNvSpPr txBox="1"/>
234
+ <p:nvPr/>
235
+ </p:nvSpPr>
236
+ <p:spPr>
237
+ <a:xfrm>
238
+ <a:off x="#{x.to_i}" y="#{y.to_i}"/>
239
+ <a:ext cx="#{cx.to_i}" cy="#{cy.to_i}"/>
240
+ </a:xfrm>
241
+ <a:prstGeom prst="rect">
242
+ <a:avLst/>
243
+ </a:prstGeom>
244
+ <a:noFill/>
245
+ </p:spPr>
246
+ <p:txBody>
247
+ <a:bodyPr wrap="none">
248
+ <a:spAutoFit/>
249
+ </a:bodyPr>
250
+ <a:lstStyle/>
251
+ <a:p/>
252
+ </p:txBody>
253
+ </p:sp>
254
+ XML
255
+ end
256
+
257
+ def escape(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub('"', "&quot;")
258
+
259
+ DEFAULT_STYLE = <<~XML
260
+ <p:style>
261
+ <a:lnRef idx="1">
262
+ <a:schemeClr val="accent1"/>
263
+ </a:lnRef>
264
+ <a:fillRef idx="3">
265
+ <a:schemeClr val="accent1"/>
266
+ </a:fillRef>
267
+ <a:effectRef idx="2">
268
+ <a:schemeClr val="accent1"/>
269
+ </a:effectRef>
270
+ <a:fontRef idx="minor">
271
+ <a:schemeClr val="lt1"/>
272
+ </a:fontRef>
273
+ </p:style>
274
+ XML
275
+ end
276
+
277
+ # A text body added to a shape that has none needs the minimum structure
278
+ # the schema requires, not an empty element.
279
+ def new_txBody = CT_TextBody.new_element(self)
280
+
281
+ # Start a new contour on this shape's custom geometry.
282
+ #
283
+ # @raise [Error] unless the shape has custom geometry
284
+ def add_path(width, height)
285
+ geometry = spPr.custGeom
286
+ raise Error, "shape does not have custom geometry" if geometry.nil?
287
+
288
+ geometry.get_or_add_pathLst.add_path_of(width, height)
289
+ end
290
+
291
+ def prstGeom = spPr.prstGeom
292
+
293
+ def prst = prstGeom&.prst
294
+
295
+ def ln = spPr.ln
296
+
297
+ def get_or_add_ln = spPr.get_or_add_ln
298
+
299
+ # A shape has custom geometry -- is a freeform -- when it carries
300
+ # `a:custGeom` in place of `a:prstGeom`.
301
+ def custom_geometry? = !spPr.custGeom.nil?
302
+
303
+ # A text box is an `p:sp` whose `p:cNvSpPr` says `txBox="1"`.
304
+ def textbox? = nvSpPr.cNvSpPr.txBox == true
305
+
306
+ # An auto shape has preset geometry and is not a text box.
307
+ def autoshape? = !prstGeom.nil? && nvSpPr.cNvSpPr.txBox != true
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/oxml/shapes/shared"
4
+ require "ruby_pptx/oxml/shapes/autoshape"
5
+ require "ruby_pptx/oxml/shapes/other"
6
+
7
+ module Pptx
8
+ module Oxml
9
+ # `p:nvGrpSpPr`, the non-visual properties of a group shape.
10
+ class CT_GroupShapeNonVisual < Element
11
+ tag "p:nvGrpSpPr"
12
+ one_and_only_one "p:cNvPr"
13
+ end
14
+
15
+ # `p:spTree` and `p:grpSp`: a shape tree or a group of shapes.
16
+ class CT_GroupShape < Element
17
+ include BaseShapeElement
18
+
19
+ tag "p:spTree", "p:grpSp"
20
+
21
+ one_and_only_one "p:nvGrpSpPr"
22
+ one_and_only_one "p:grpSpPr"
23
+
24
+ # The child tags that count as shapes. Anything else in a shape tree
25
+ # (extension lists and so on) is not part of the shape sequence.
26
+ SHAPE_TAGS = %w[p:sp p:grpSp p:graphicFrame p:cxnSp p:pic p:contentPart].freeze
27
+
28
+ # A group's transform hangs off `p:grpSpPr`, not `p:spPr`.
29
+ def xfrm = grpSpPr.xfrm
30
+
31
+ def get_or_add_xfrm = grpSpPr.get_or_add_xfrm
32
+
33
+ def chOff = get_or_add_xfrm.get_or_add_chOff
34
+
35
+ def chExt = get_or_add_xfrm.get_or_add_chExt
36
+
37
+ # Each shape child, in document order.
38
+ def shape_elements
39
+ return enum_for(:shape_elements) unless block_given?
40
+
41
+ tags = SHAPE_TAGS.map { |t| Ns.qn(t) }
42
+ @node.element_children.each do |child|
43
+ yield Element.wrap(child) if tags.include?(Ns.clark_name_of(child))
44
+ end
45
+ end
46
+
47
+ # Each placeholder shape child, in document order.
48
+ def placeholder_elements
49
+ return enum_for(:placeholder_elements) unless block_given?
50
+
51
+ shape_elements { |shape| yield shape if shape.placeholder? }
52
+ end
53
+
54
+ def add_placeholder(id, name, ph_type, orient, sz, idx)
55
+ sp = adopt_xml(CT_Shape.new_placeholder_sp(id, name, ph_type, orient, sz, idx))
56
+ insert_element_before(sp, "p:extLst")
57
+ sp
58
+ end
59
+
60
+ def add_autoshape(id, name, prst, x, y, cx, cy)
61
+ sp = adopt_xml(CT_Shape.new_autoshape_sp(id, name, prst, x, y, cx, cy))
62
+ insert_element_before(sp, "p:extLst")
63
+ sp
64
+ end
65
+
66
+ def add_pic(id, name, desc, r_id, x, y, cx, cy)
67
+ pic = adopt_xml(CT_Picture.new_pic(id, name, desc, r_id, x, y, cx, cy))
68
+ insert_element_before(pic, "p:extLst")
69
+ pic
70
+ end
71
+
72
+ def add_graphic_frame_chart(id, name, r_id, x, y, cx, cy)
73
+ frame = adopt_xml(
74
+ CT_GraphicalObjectFrame.new_chart_graphic_frame(id, name, r_id, x, y, cx, cy)
75
+ )
76
+ insert_element_before(frame, "p:extLst")
77
+ frame
78
+ end
79
+
80
+ def add_video_pic(id, name, video_r_id, media_r_id, poster_r_id, x, y, cx, cy)
81
+ pic = adopt_xml(CT_Picture.new_video_pic(id, name, video_r_id, media_r_id,
82
+ poster_r_id, x, y, cx, cy))
83
+ insert_element_before(pic, "p:extLst")
84
+ pic
85
+ end
86
+
87
+ def add_graphic_frame_ole_object(id, name, ole_r_id, prog_id, icon_r_id, x, y, cx, cy, img_w, img_h)
88
+ frame = adopt_xml(
89
+ CT_GraphicalObjectFrame.new_ole_object_graphic_frame(id, name, ole_r_id, prog_id, icon_r_id,
90
+ x, y, cx, cy, img_w, img_h)
91
+ )
92
+ insert_element_before(frame, "p:extLst")
93
+ frame
94
+ end
95
+
96
+ def add_graphic_frame_table(id, name, rows, cols, x, y, cx, cy)
97
+ frame = adopt_xml(
98
+ CT_GraphicalObjectFrame.new_table_graphic_frame(id, name, rows, cols, x, y, cx, cy)
99
+ )
100
+ insert_element_before(frame, "p:extLst")
101
+ frame
102
+ end
103
+
104
+ def add_cxnSp(id, name, prst, x, y, cx, cy, flip_h, flip_v)
105
+ cxn_sp = adopt_xml(CT_Connector.new_cxnSp(id, name, prst, x, y, cx, cy, flip_h, flip_v))
106
+ insert_element_before(cxn_sp, "p:extLst")
107
+ cxn_sp
108
+ end
109
+
110
+ # An empty `p:grpSp` at the origin with no size; its extents follow from
111
+ # whatever is put in it.
112
+ def add_grpSp
113
+ id = next_shape_id
114
+ grp_sp = adopt_xml(CT_GroupShape.new_grpSp(id, "Group #{id - 1}"))
115
+ insert_element_before(grp_sp, "p:extLst")
116
+ grp_sp
117
+ end
118
+
119
+ def self.new_grpSp(id, name)
120
+ Element.parse(<<~XML)
121
+ <p:grpSp #{Ns.nsdecls("a", "p", "r")}>
122
+ <p:nvGrpSpPr>
123
+ <p:cNvPr id="#{id}" name="#{CT_Picture.escape(name)}"/>
124
+ <p:cNvGrpSpPr/>
125
+ <p:nvPr/>
126
+ </p:nvGrpSpPr>
127
+ <p:grpSpPr>
128
+ <a:xfrm>
129
+ <a:off x="0" y="0"/>
130
+ <a:ext cx="0" cy="0"/>
131
+ <a:chOff x="0" y="0"/>
132
+ <a:chExt cx="0" cy="0"/>
133
+ </a:xfrm>
134
+ </p:grpSpPr>
135
+ </p:grpSp>
136
+ XML
137
+ end
138
+
139
+ # Resize this group to just contain its shapes.
140
+ #
141
+ # A group's own extents are not stored by PowerPoint as anything the
142
+ # caller sets; they follow from the contents. Changing a group can also
143
+ # change the group containing it, so this walks upward.
144
+ def recalculate_extents
145
+ return self unless nsptag == "p:grpSp"
146
+
147
+ x, y, cx, cy = child_extents
148
+ chOff.x = self.x = x
149
+ chOff.y = self.y = y
150
+ chExt.cx = self.cx = cx
151
+ chExt.cy = self.cy = cy
152
+ parent.recalculate_extents if parent.respond_to?(:recalculate_extents)
153
+ self
154
+ end
155
+
156
+ # The bounding box of the contained shapes, or all zeroes when empty.
157
+ def child_extents
158
+ children = shape_elements.to_a
159
+ return [0, 0, 0, 0] if children.empty?
160
+
161
+ min_x = children.map { |child| child.x.to_i }.min
162
+ min_y = children.map { |child| child.y.to_i }.min
163
+ max_x = children.map { |child| child.x.to_i + child.cx.to_i }.max
164
+ max_y = children.map { |child| child.y.to_i + child.cy.to_i }.max
165
+ [min_x, min_y, max_x - min_x, max_y - min_y]
166
+ end
167
+
168
+ def add_freeform_sp(x, y, cx, cy)
169
+ id = next_shape_id
170
+ sp = adopt_xml(CT_Shape.new_freeform_sp(id, "Freeform #{id - 1}", x, y, cx, cy))
171
+ insert_element_before(sp, "p:extLst")
172
+ sp
173
+ end
174
+
175
+ def add_textbox(id, name, x, y, cx, cy)
176
+ sp = adopt_xml(CT_Shape.new_textbox_sp(id, name, x, y, cx, cy))
177
+ insert_element_before(sp, "p:extLst")
178
+ sp
179
+ end
180
+
181
+ # The largest @id anywhere in the document.
182
+ #
183
+ # Ids have document scope and are not used only by shapes, so every @id
184
+ # counts. The minimum in practice is 1, because the `p:spTree` element
185
+ # itself is always id="1".
186
+ def max_shape_id
187
+ used_ids.max || 0
188
+ end
189
+
190
+ # The lowest unused positive @id, filling gaps.
191
+ #
192
+ # Note this differs from the shape collection's own allocator, which
193
+ # takes max+1; both behaviours are preserved from python-pptx.
194
+ def next_shape_id
195
+ used = used_ids
196
+ (1..(used.size + 1)).find { |n| !used.include?(n) }
197
+ end
198
+
199
+ private
200
+
201
+ def used_ids
202
+ xpath("//@id").filter_map do |attr|
203
+ value = attr.value
204
+ Integer(value, 10) if /\A\d+\z/.match?(value)
205
+ end
206
+ end
207
+
208
+ # A shape built from an XML literal is parsed into its own document, so
209
+ # it must be rebuilt inside this tree's document before insertion.
210
+ def adopt_xml(element)
211
+ return element if element.document.equal?(document)
212
+
213
+ build_from_xml(element.node.to_xml)
214
+ end
215
+ end
216
+ end
217
+ end