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,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/element_proxy"
4
+ require "ruby_pptx/sliceable"
5
+ require "ruby_pptx/text/text"
6
+ require "ruby_pptx/enum/chart"
7
+ require "ruby_pptx/dml/fill"
8
+
9
+ module Pptx
10
+ # The fill and outline of a chart element -- a series, a point, an axis,
11
+ # gridlines, a title or a marker.
12
+ #
13
+ # Reached through the `format` of whatever it belongs to.
14
+ class ChartFormat < ElementProxy
15
+ def fill = @fill ||= FillFormat.from_fill_parent(@element.get_or_add_spPr)
16
+
17
+ def line = @line ||= LineFormat.new(@element.get_or_add_spPr)
18
+
19
+ def inspect = "#<Pptx::ChartFormat #{fill.type&.name}>"
20
+ end
21
+
22
+ # A chart's legend.
23
+ class ChartLegend < ElementProxy
24
+ # The legend's font, created on first use.
25
+ def font = @font ||= Font.new(@element.defRPr)
26
+
27
+ # Where the legend sits. Absent from the file means PowerPoint's default,
28
+ # which is the right-hand side.
29
+ #
30
+ # @return [Pptx::Enum::Member] a member of XL_LEGEND_POSITION
31
+ def position = @element.legendPos&.val || Enum::XL_LEGEND_POSITION::RIGHT
32
+
33
+ def position=(value)
34
+ @element.get_or_add_legendPos.val = Enum::XL_LEGEND_POSITION.fetch(value)
35
+ end
36
+
37
+ # How far the legend is shifted sideways, as a fraction of the chart
38
+ # width: -1.0 to 1.0, with 0.0 meaning PowerPoint places it.
39
+ def horz_offset = @element.horz_offset
40
+
41
+ def horz_offset=(value)
42
+ @element.horz_offset = value
43
+ end
44
+
45
+ # True when the legend sits inside the plot area, overlapping it, rather
46
+ # than having space made for it. An absent `c:overlay` reads as true.
47
+ def include_in_layout? = @element.overlay.nil? || @element.overlay.val
48
+
49
+ # nil removes the setting and returns to the default.
50
+ def include_in_layout=(value)
51
+ if value.nil?
52
+ @element.remove_overlay
53
+ else
54
+ @element.get_or_add_overlay.val = value ? true : false
55
+ end
56
+ end
57
+
58
+ def inspect = "#<Pptx::ChartLegend #{position.name}>"
59
+ end
60
+
61
+ # A chart or axis title.
62
+ #
63
+ # The title carries a text frame like any shape, so it can be formatted the
64
+ # same way.
65
+ class ChartTitle < ElementProxy
66
+ # The title box's fill and outline.
67
+ def format = @format ||= ChartFormat.new(@element)
68
+
69
+ # True when the title has text of its own rather than being generated.
70
+ def text_frame? = !@element.rich.nil?
71
+
72
+ # The title's text, created on first use.
73
+ def text_frame = TextFrame.new(@element.get_or_add_rich, self)
74
+
75
+ # Titles are formatting, not content; there is no part to reach for.
76
+ def part = nil
77
+
78
+ def text = text_frame? ? text_frame.text : ""
79
+
80
+ def text=(value)
81
+ text_frame.text = value
82
+ end
83
+
84
+ def inspect = "#<Pptx::ChartTitle #{text.inspect}>"
85
+ end
86
+
87
+ # The major gridlines of an axis.
88
+ class Gridlines < ElementProxy
89
+ def format = @format ||= ChartFormat.new(@element.get_or_add_majorGridlines)
90
+ end
91
+
92
+ # The labels along an axis: their font, number format and spacing.
93
+ class TickLabels < ElementProxy
94
+ def font = @font ||= Font.new(@element.defRPr)
95
+
96
+ # The Excel number format, "General" when none is set.
97
+ def number_format = @element.numFmt&.formatCode || "General"
98
+
99
+ # Setting a format stops it following the source data's format.
100
+ def number_format=(value)
101
+ @element.get_or_add_numFmt.formatCode = value
102
+ self.number_format_linked = false
103
+ end
104
+
105
+ # True when the labels take their number format from the worksheet.
106
+ def number_format_linked?
107
+ format = @element.numFmt
108
+ return false if format.nil?
109
+
110
+ format.sourceLinked.nil? || format.sourceLinked
111
+ end
112
+
113
+ def number_format_linked=(value)
114
+ @element.get_or_add_numFmt.sourceLinked = value
115
+ end
116
+
117
+ # Distance of the labels from the axis as a percentage of the default;
118
+ # 100 is the default. Only a category axis has one.
119
+ def offset = @element.respond_to?(:lblOffset) ? (@element.lblOffset&.val || 100) : 100
120
+
121
+ def offset=(value)
122
+ raise Error, "only a category axis has a label offset" unless @element.nsptag == "c:catAx"
123
+
124
+ @element.remove_lblOffset
125
+ return if value == 100
126
+
127
+ @element.get_or_add_lblOffset.val = value
128
+ end
129
+ end
130
+
131
+ # One axis of a chart. {CategoryAxis}, {DateAxis} and {ValueAxis} add what
132
+ # only they have.
133
+ class ChartAxis < ElementProxy
134
+ # The axis line and its fill.
135
+ def format = @format ||= ChartFormat.new(@element)
136
+
137
+ # Whether the axis is drawn. A `c:delete` of true hides it.
138
+ #
139
+ # An axis with no `c:delete` at all is drawn, which is what the schema and
140
+ # PowerPoint say. python-pptx reports it as hidden; see PORTING.md.
141
+ def visible? = @element.delete.nil? || !@element.delete.val
142
+
143
+ def visible=(value)
144
+ unless [true, false].include?(value)
145
+ raise ArgumentError, "visible must be true or false, got #{value.inspect}"
146
+ end
147
+
148
+ @element.get_or_add_delete.val = !value
149
+ end
150
+
151
+ def major_gridlines? = !@element.majorGridlines.nil?
152
+
153
+ def major_gridlines=(value)
154
+ value ? @element.get_or_add_majorGridlines : @element.remove_majorGridlines
155
+ end
156
+
157
+ # The major gridlines, for formatting. Asking for them switches them on.
158
+ def major_gridlines = @major_gridlines ||= Gridlines.new(@element)
159
+
160
+ def minor_gridlines? = !@element.minorGridlines.nil?
161
+
162
+ def minor_gridlines=(value)
163
+ value ? @element.get_or_add_minorGridlines : @element.remove_minorGridlines
164
+ end
165
+
166
+ # @return [Pptx::Enum::Member] a member of XL_TICK_MARK
167
+ def major_tick_mark = @element.majorTickMark&.val || Enum::XL_TICK_MARK::CROSS
168
+
169
+ # The default, cross, is written by leaving the element out.
170
+ def major_tick_mark=(value)
171
+ set_tick_mark(:majorTickMark, value)
172
+ end
173
+
174
+ def minor_tick_mark = @element.minorTickMark&.val || Enum::XL_TICK_MARK::CROSS
175
+
176
+ def minor_tick_mark=(value)
177
+ set_tick_mark(:minorTickMark, value)
178
+ end
179
+
180
+ # The fixed end of the scale, or nil when PowerPoint scales automatically.
181
+ def maximum_scale = @element.scaling.maximum
182
+
183
+ def maximum_scale=(value)
184
+ @element.scaling.maximum = value
185
+ end
186
+
187
+ def minimum_scale = @element.scaling.minimum
188
+
189
+ def minimum_scale=(value)
190
+ @element.scaling.minimum = value
191
+ end
192
+
193
+ # True when the axis runs from its maximum to its minimum.
194
+ def reverse_order? = @element.orientation == Oxml::SimpleTypes::ST_Orientation::MAX_MIN
195
+
196
+ def reverse_order=(value)
197
+ @element.orientation = if value
198
+ Oxml::SimpleTypes::ST_Orientation::MAX_MIN
199
+ else
200
+ Oxml::SimpleTypes::ST_Orientation::MIN_MAX
201
+ end
202
+ end
203
+
204
+ # @return [Pptx::Enum::Member] a member of XL_TICK_LABEL_POSITION
205
+ def tick_label_position
206
+ @element.tickLblPos&.val || Enum::XL_TICK_LABEL_POSITION::NEXT_TO_AXIS
207
+ end
208
+
209
+ def tick_label_position=(value)
210
+ @element.get_or_add_tickLblPos.val = Enum::XL_TICK_LABEL_POSITION.fetch(value)
211
+ end
212
+
213
+ # The labels along the axis.
214
+ def tick_labels = @tick_labels ||= TickLabels.new(@element)
215
+
216
+ # Shortcuts for the tick-label number format, the setting most often
217
+ # wanted from an axis.
218
+ def number_format = tick_labels.number_format
219
+
220
+ def number_format=(value)
221
+ tick_labels.number_format = value
222
+ end
223
+
224
+ def title? = !@element.title.nil?
225
+
226
+ # @return [ChartTitle, nil] nil when the axis has no title
227
+ def title
228
+ element = @element.title
229
+ element && ChartTitle.new(element)
230
+ end
231
+
232
+ # A String sets the title's text; true shows a title with no text of its
233
+ # own; nil or false removes it.
234
+ def title=(value)
235
+ case value
236
+ when nil, false then @element.remove_title
237
+ when true then @element.get_or_add_title
238
+ else ChartTitle.new(@element.get_or_add_title).text = value
239
+ end
240
+ end
241
+
242
+ def inspect = "#<#{self.class.name} visible=#{visible?}>"
243
+
244
+ private
245
+
246
+ def set_tick_mark(name, value)
247
+ member = Enum::XL_TICK_MARK.fetch(value)
248
+ @element.public_send(:"remove_#{name}")
249
+ return if member == Enum::XL_TICK_MARK::CROSS
250
+
251
+ @element.public_send(:"get_or_add_#{name}").val = member
252
+ end
253
+ end
254
+
255
+ # The horizontal axis of most charts: one label per category.
256
+ class CategoryAxis < ChartAxis
257
+ def category_type = Enum::XL_CATEGORY_TYPE::CATEGORY_SCALE
258
+ end
259
+
260
+ # A category axis whose categories are dates.
261
+ class DateAxis < ChartAxis
262
+ def category_type = Enum::XL_CATEGORY_TYPE::TIME_SCALE
263
+ end
264
+
265
+ # An axis measuring values. An XY or bubble chart has two.
266
+ class ValueAxis < ChartAxis
267
+ # Where the *other* axis crosses this one. `crosses` describes the
268
+ # crossing and is stored on the axis being crossed, which is why these
269
+ # two read and write the perpendicular axis.
270
+ #
271
+ # @return [Pptx::Enum::Member] a member of XL_AXIS_CROSSES; CUSTOM when a
272
+ # specific value is set through {#crosses_at}
273
+ def crosses = cross_axis.crosses&.val || Enum::XL_AXIS_CROSSES::CUSTOM
274
+
275
+ def crosses=(value)
276
+ member = Enum::XL_AXIS_CROSSES.fetch(value)
277
+ axis = cross_axis
278
+ return if member == Enum::XL_AXIS_CROSSES::CUSTOM && axis.crossesAt
279
+
280
+ axis.remove_crosses
281
+ axis.remove_crossesAt
282
+ if member == Enum::XL_AXIS_CROSSES::CUSTOM
283
+ axis.get_or_add_crossesAt.val = 0.0
284
+ else
285
+ axis.get_or_add_crosses.val = member
286
+ end
287
+ end
288
+
289
+ # The value on this axis at which the other crosses, or nil.
290
+ def crosses_at = cross_axis.crossesAt&.val
291
+
292
+ def crosses_at=(value)
293
+ axis = cross_axis
294
+ axis.remove_crosses
295
+ axis.remove_crossesAt
296
+ axis.get_or_add_crossesAt.val = value unless value.nil?
297
+ end
298
+
299
+ # The distance between major tick marks, or nil when automatic.
300
+ def major_unit = @element.majorUnit&.val
301
+
302
+ def major_unit=(value)
303
+ @element.remove_majorUnit
304
+ @element.get_or_add_majorUnit.val = value unless value.nil?
305
+ end
306
+
307
+ def minor_unit = @element.minorUnit&.val
308
+
309
+ def minor_unit=(value)
310
+ @element.remove_minorUnit
311
+ @element.get_or_add_minorUnit.val = value unless value.nil?
312
+ end
313
+
314
+ private
315
+
316
+ # The axis whose `c:axId` this one names in its `c:crossAx`.
317
+ def cross_axis
318
+ id = @element.crossAx.val
319
+ @element.xpath("(../c:catAx | ../c:valAx | ../c:dateAx)/c:axId[@val=\"#{id}\"]").first.parent
320
+ end
321
+ end
322
+
323
+ # The data labels of a plot or series.
324
+ class ChartDataLabels < ElementProxy
325
+ SHOW_FLAGS = {
326
+ value: "c:showVal", category_name: "c:showCatName", series_name: "c:showSerName",
327
+ percentage: "c:showPercent", legend_key: "c:showLegendKey",
328
+ bubble_size: "c:showBubbleSize"
329
+ }.freeze
330
+
331
+ # Reading a flag does not add it. python-pptx's getters do -- asking
332
+ # whether values are shown writes `c:showVal val="0"` into the file --
333
+ # and a read that changes the document is not one this library will make.
334
+ SHOW_FLAGS.each do |name, tag|
335
+ local = Oxml::Ns.split_tag(tag).last
336
+
337
+ define_method("show_#{name}?") do
338
+ flag = @element.public_send(local)
339
+ flag.nil? ? false : flag.val
340
+ end
341
+
342
+ define_method("show_#{name}=") do |value|
343
+ @element.public_send("get_or_add_#{local}").val = value ? true : false
344
+ end
345
+ end
346
+
347
+ def font = @font ||= Font.new(@element.defRPr)
348
+
349
+ # The Excel number format, "General" when none is set.
350
+ def number_format = @element.numFmt&.formatCode || "General"
351
+
352
+ def number_format=(value)
353
+ @element.get_or_add_numFmt.formatCode = value
354
+ self.number_format_linked = false
355
+ end
356
+
357
+ # True when the labels take their number format from the worksheet. Data
358
+ # labels with no number format at all follow the source.
359
+ def number_format_linked?
360
+ format = @element.numFmt
361
+ format.nil? || format.sourceLinked.nil? || format.sourceLinked
362
+ end
363
+
364
+ def number_format_linked=(value)
365
+ @element.get_or_add_numFmt.sourceLinked = value
366
+ end
367
+
368
+ # @return [Pptx::Enum::Member, nil] a member of XL_DATA_LABEL_POSITION
369
+ def position = @element.dLblPos&.val
370
+
371
+ def position=(value)
372
+ if value.nil?
373
+ @element.remove_dLblPos
374
+ else
375
+ @element.get_or_add_dLblPos.val = Enum::XL_DATA_LABEL_POSITION.fetch(value)
376
+ end
377
+ end
378
+
379
+ def inspect = "#<Pptx::ChartDataLabels value=#{show_value?}>"
380
+ end
381
+
382
+ # The label of a single data point, overriding the series' labels for it.
383
+ class DataLabel
384
+ def initialize(ser, idx)
385
+ @ser = ser
386
+ @idx = idx
387
+ end
388
+
389
+ def font = @font ||= TextFrame.new(label_element.get_or_add_txPr, self).paragraphs[0].font
390
+
391
+ # True when the label has text of its own rather than a generated value.
392
+ def text_frame? = !@ser.dLbl_for_point(@idx)&.rich.nil?
393
+
394
+ # The label's own text, replacing the generated value. Created on use.
395
+ def text_frame = TextFrame.new(label_element.get_or_add_rich, self)
396
+
397
+ # Labels are formatting, not content; there is no part to reach for.
398
+ def part = nil
399
+
400
+ # @return [Pptx::Enum::Member, nil] a member of XL_DATA_LABEL_POSITION
401
+ def position = @ser.dLbl_for_point(@idx)&.dLblPos&.val
402
+
403
+ def position=(value)
404
+ if value.nil?
405
+ @ser.dLbl_for_point(@idx)&.remove_dLblPos
406
+ else
407
+ label_element.get_or_add_dLblPos.val = Enum::XL_DATA_LABEL_POSITION.fetch(value)
408
+ end
409
+ end
410
+
411
+ def inspect = "#<Pptx::DataLabel point=#{@idx}>"
412
+
413
+ private
414
+
415
+ # This point's `c:dLbl`, created on first use.
416
+ def label_element = @ser.get_or_add_dLbl_for_point(@idx)
417
+ end
418
+
419
+ # The symbol drawn at each point of a line, radar or XY series, or at one
420
+ # point.
421
+ class Marker < ElementProxy
422
+ def format = @format ||= ChartFormat.new(@element.get_or_add_marker)
423
+
424
+ # Size in points, 2 to 72, or nil when inherited.
425
+ def size = @element.marker&.size&.val
426
+
427
+ def size=(value)
428
+ marker = @element.get_or_add_marker
429
+ marker.remove_size
430
+ marker.get_or_add_size.val = value unless value.nil?
431
+ end
432
+
433
+ # @return [Pptx::Enum::Member, nil] a member of XL_MARKER_STYLE
434
+ def style = @element.marker&.symbol&.val
435
+
436
+ def style=(value)
437
+ marker = @element.get_or_add_marker
438
+ marker.remove_symbol
439
+ marker.get_or_add_symbol.val = Enum::XL_MARKER_STYLE.fetch(value) unless value.nil?
440
+ end
441
+ end
442
+
443
+ # One data point of a series, for formatting it apart from the rest.
444
+ class ChartPoint
445
+ def initialize(ser, idx)
446
+ @ser = ser
447
+ @idx = idx
448
+ end
449
+
450
+ def data_label = @data_label ||= DataLabel.new(@ser, @idx)
451
+
452
+ def format = @format ||= ChartFormat.new(@ser.get_or_add_dPt_for_point(@idx))
453
+
454
+ def marker = @marker ||= Marker.new(@ser.get_or_add_dPt_for_point(@idx))
455
+
456
+ def inspect = "#<Pptx::ChartPoint #{@idx}>"
457
+ end
458
+
459
+ # The points of a series, indexed from 0.
460
+ class ChartPoints
461
+ include Enumerable
462
+ include Sliceable
463
+
464
+ def initialize(ser, count)
465
+ @ser = ser
466
+ @count = count
467
+ end
468
+
469
+ def size = @count
470
+ alias length size
471
+
472
+ def [](index, length = nil)
473
+ slice_members((0...@count).to_a, index, length) { |i| ChartPoint.new(@ser, i) }
474
+ end
475
+
476
+ def each
477
+ return enum_for(:each) { size } unless block_given?
478
+
479
+ @count.times { |i| yield ChartPoint.new(@ser, i) }
480
+ self
481
+ end
482
+
483
+ def inspect = "#<Pptx::ChartPoints size=#{size}>"
484
+ end
485
+
486
+ # One plot -- a "chart group" in the MS API -- within a chart.
487
+ #
488
+ # A chart usually has exactly one; a combo chart has several, which is why
489
+ # these are a collection rather than properties of the chart itself.
490
+ class ChartPlot < ElementProxy
491
+ attr_reader :chart
492
+
493
+ def initialize(element, chart)
494
+ super(element)
495
+ @chart = chart
496
+ end
497
+
498
+ # The categories this plot's series are plotted against.
499
+ def categories = ChartCategories.new(@element)
500
+
501
+ # This plot's series, in the order the chart draws them.
502
+ def series = @element.sers.map { |ser| ChartSeriesView.for(ser, @chart) }
503
+
504
+ # The space between bars, as a percentage of bar width.
505
+ def gap_width = @element.gapWidth&.val || 150
506
+
507
+ def gap_width=(value)
508
+ @element.get_or_add_gapWidth.val = value
509
+ end
510
+
511
+ # How far bars in a group overlap, -100 to 100. Zero is written by
512
+ # leaving the element out.
513
+ def overlap = @element.overlap&.val || 0
514
+
515
+ def overlap=(value)
516
+ if value.zero?
517
+ @element.remove_overlap
518
+ else
519
+ @element.get_or_add_overlap.val = value
520
+ end
521
+ end
522
+
523
+ # The bubble size as a percentage of the default, 0 to 300.
524
+ def bubble_scale = @element.bubbleScale&.val || 100
525
+
526
+ # nil returns to the default.
527
+ def bubble_scale=(value)
528
+ @element.remove_bubbleScale
529
+ @element.get_or_add_bubbleScale.val = value unless value.nil?
530
+ end
531
+
532
+ # Whether each data point gets its own colour, as a pie does. An absent
533
+ # `c:varyColors` reads as true, the schema default.
534
+ def vary_by_categories? = @element.varyColors.nil? || @element.varyColors.val
535
+
536
+ def vary_by_categories=(value)
537
+ @element.get_or_add_varyColors.val = value ? true : false
538
+ end
539
+
540
+ # Whether this plot shows data labels at all.
541
+ def data_labels? = !@element.dLbls.nil?
542
+
543
+ # Switching labels on shows values, as PowerPoint's default does.
544
+ def data_labels=(value)
545
+ if value
546
+ @element.get_or_add_default_dLbls.showVal.val = true unless @element.dLbls
547
+ else
548
+ @element.remove_dLbls
549
+ end
550
+ @data_labels = nil
551
+ end
552
+
553
+ # The data-label settings, switched on with PowerPoint's defaults if the
554
+ # plot has none yet. python-pptx makes you set `has_data_labels` first;
555
+ # asking for them is enough here.
556
+ def data_labels = @data_labels ||= ChartDataLabels.new(@element.get_or_add_default_dLbls)
557
+
558
+ def inspect = "#<Pptx::ChartPlot #{@element.nsptag}>"
559
+ end
560
+
561
+ # The categories of a plot, read back from the chart's cached values.
562
+ #
563
+ # A category can be empty -- an empty worksheet cell -- in which case it
564
+ # has no cached point but still counts, so {#size} is the declared count
565
+ # rather than the number of labels found.
566
+ class ChartCategories
567
+ include Enumerable
568
+
569
+ def initialize(plot_element)
570
+ @plot = plot_element
571
+ end
572
+
573
+ def each
574
+ return enum_for(:each) { size } unless block_given?
575
+
576
+ @plot.cat_pts.each_with_index { |point, i| yield ChartCategory.new(point, i) }
577
+ self
578
+ end
579
+
580
+ def [](index) = to_a[index]
581
+
582
+ def size = @plot.cat_pt_count
583
+ alias length size
584
+
585
+ # How many levels of labels there are: 0 with no categories, 1 for a
586
+ # plain list, more for grouped categories.
587
+ def depth
588
+ cat = @plot.cat
589
+ return 0 if cat.nil?
590
+ return 1 if cat.multiLvlStrRef.nil?
591
+
592
+ cat.lvls.size
593
+ end
594
+
595
+ # Each level's labels, leaf level first.
596
+ def levels
597
+ cat = @plot.cat
598
+ return [] if cat.nil?
599
+
600
+ cat.lvls.map { |lvl| lvl.xpath("./c:pt").map { |pt| ChartCategory.new(pt) } }
601
+ end
602
+
603
+ # One tuple per leaf category, its labels listed root first -- the form
604
+ # ChartData takes them in.
605
+ def flattened_labels
606
+ return [] if @plot.cat.nil?
607
+ return map { |category| [category.label] } if @plot.cat.multiLvlStrRef.nil?
608
+
609
+ leaf, *parents = levels
610
+ leaf.map { |category| lineage(category, parents).reverse.map(&:label) }
611
+ end
612
+
613
+ def inspect = "#<Pptx::ChartCategories #{map(&:label).inspect}>"
614
+
615
+ private
616
+
617
+ # The category followed by its parent at each level above it: the last
618
+ # parent whose index is not past the child's.
619
+ def lineage(category, parents)
620
+ parents.each_with_object([category]) do |level, chain|
621
+ break chain if level.empty?
622
+
623
+ chain << (level.take_while { |parent| parent.idx <= chain.last.idx }.last || level.first)
624
+ end
625
+ end
626
+ end
627
+
628
+ # One category label.
629
+ class ChartCategory
630
+ def initialize(point, idx = nil)
631
+ @point = point
632
+ @idx = idx
633
+ end
634
+
635
+ # The label, "" for an empty category.
636
+ def label = @point.nil? ? "" : @point.v.text
637
+
638
+ def idx = @point.nil? ? @idx : @point.idx
639
+
640
+ def to_s = label
641
+ alias to_str to_s
642
+
643
+ def ==(other) = other.is_a?(ChartCategory) ? label == other.label && idx == other.idx : label == other
644
+
645
+ def inspect = "#<Pptx::ChartCategory #{idx}:#{label.inspect}>"
646
+ end
647
+ end