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,953 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/enum/chart"
4
+ require "ruby_pptx/errors"
5
+
6
+ module Pptx
7
+ # Builds the `c:chartSpace` XML for a new chart.
8
+ #
9
+ # Each chart family has its own skeleton, transcribed from the one
10
+ # PowerPoint itself writes. The values are cached in this XML, which is what
11
+ # PowerPoint renders from; the embedded workbook is only consulted when the
12
+ # user edits the data.
13
+ module ChartXmlWriter
14
+ XL = Enum::XL_CHART_TYPE
15
+
16
+ # Chart types this library can create, grouped by the plot element they
17
+ # use. Anything outside this list raises rather than producing XML
18
+ # PowerPoint would reject.
19
+ FAMILIES = {
20
+ bar: %i[BAR_CLUSTERED BAR_STACKED BAR_STACKED_100
21
+ COLUMN_CLUSTERED COLUMN_STACKED COLUMN_STACKED_100],
22
+ line: %i[LINE LINE_STACKED LINE_STACKED_100
23
+ LINE_MARKERS LINE_MARKERS_STACKED LINE_MARKERS_STACKED_100],
24
+ xy: %i[XY_SCATTER XY_SCATTER_LINES XY_SCATTER_LINES_NO_MARKERS
25
+ XY_SCATTER_SMOOTH XY_SCATTER_SMOOTH_NO_MARKERS],
26
+ bubble: %i[BUBBLE BUBBLE_THREE_D_EFFECT],
27
+ pie: %i[PIE],
28
+ doughnut: %i[DOUGHNUT],
29
+ area: %i[AREA AREA_STACKED AREA_STACKED_100],
30
+ radar: %i[RADAR RADAR_MARKERS RADAR_FILLED]
31
+ }.freeze
32
+
33
+ module_function
34
+
35
+ # @param chart_type [Pptx::Enum::XL_CHART_TYPE]
36
+ # @param chart_data [Pptx::ChartData]
37
+ # @return [String] the chart part XML
38
+ def write(chart_type, chart_data)
39
+ member = XL.fetch(chart_type)
40
+ case family_of(member)
41
+ when :bar then BarChartWriter.new(member, chart_data).xml
42
+ when :line then LineChartWriter.new(member, chart_data).xml
43
+ when :pie then PieChartWriter.new(member, chart_data).xml
44
+ when :doughnut then DoughnutChartWriter.new(member, chart_data).xml
45
+ when :area then AreaChartWriter.new(member, chart_data).xml
46
+ when :radar then RadarChartWriter.new(member, chart_data).xml
47
+ when :xy then XyChartWriter.new(member, chart_data).xml
48
+ when :bubble then BubbleChartWriter.new(member, chart_data).xml
49
+ end
50
+ end
51
+
52
+ def family_of(member)
53
+ FAMILIES.each { |family, names| return family if names.include?(member.name) }
54
+ raise Error,
55
+ "creating a #{member.name} chart is not supported yet; " \
56
+ "supported types: #{FAMILIES.values.flatten.sort.join(", ")}"
57
+ end
58
+
59
+ def supported?(chart_type)
60
+ member = XL[chart_type]
61
+ !member.nil? && FAMILIES.values.any? { |names| names.include?(member.name) }
62
+ end
63
+
64
+ # Behaviour shared by the per-family writers.
65
+ class Base
66
+ DECLARATION = "<?xml version='1.0' encoding='UTF-8' standalone='yes'?>"
67
+ CHART_SPACE_NS =
68
+ 'xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" ' \
69
+ 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' \
70
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
71
+
72
+ TEXT_PROPERTIES = <<~XML
73
+ <c:txPr>
74
+ <a:bodyPr/>
75
+ <a:lstStyle/>
76
+ <a:p>
77
+ <a:pPr>
78
+ <a:defRPr sz="1800"/>
79
+ </a:pPr>
80
+ <a:endParaRPr lang="en-US"/>
81
+ </a:p>
82
+ </c:txPr>
83
+ XML
84
+
85
+ # Doughnut and area charts leave the language off the trailing run
86
+ # properties where every other family sets it. There is no reason for
87
+ # the difference beyond what the reference implementation writes, and a
88
+ # byte comparison is the only thing that notices.
89
+ TEXT_PROPERTIES_WITHOUT_LANG = TEXT_PROPERTIES.sub(
90
+ %(<a:endParaRPr lang="en-US"/>), "<a:endParaRPr/>"
91
+ )
92
+
93
+ LEGEND = <<~XML
94
+ <c:legend>
95
+ <c:legendPos val="r"/>
96
+ <c:layout/>
97
+ <c:overlay val="0"/>
98
+ </c:legend>
99
+ XML
100
+
101
+ def initialize(chart_type, chart_data)
102
+ @chart_type = chart_type
103
+ @chart_data = chart_data
104
+ end
105
+
106
+ # Protected rather than private: ComboChartWriter builds its plots from
107
+ # the same fragments, and is a sibling rather than a subclass of the
108
+ # per-family writers.
109
+ protected
110
+
111
+ attr_reader :chart_type, :chart_data
112
+
113
+ # Ruby's squiggly heredoc only indents the first line of interpolated
114
+ # content, so every fragment below is produced at zero indentation and
115
+ # placed by its caller.
116
+ def indent(text, spaces)
117
+ pad = " " * spaces
118
+ text.lines.map { |line| line.strip.empty? ? line : pad + line }.join
119
+ end
120
+
121
+ # One `c:ser` element: index, name, categories and values.
122
+ def series_xml(series, extra_before_cat: "", extra_after_val: "")
123
+ parts = [
124
+ %( <c:idx val="#{series.index}"/>\n),
125
+ %( <c:order val="#{series.index}"/>\n),
126
+ indent(tx_xml(series), 2),
127
+ extra_before_cat.empty? ? "" : indent(extra_before_cat, 2),
128
+ indent(cat_xml(series), 2),
129
+ indent(val_xml(series), 2),
130
+ extra_after_val.empty? ? "" : indent(extra_after_val, 2)
131
+ ]
132
+ "<c:ser>\n#{parts.join}</c:ser>\n"
133
+ end
134
+
135
+ def tx_xml(series)
136
+ <<~XML
137
+ <c:tx>
138
+ <c:strRef>
139
+ <c:f>#{series.name_ref}</c:f>
140
+ <c:strCache>
141
+ <c:ptCount val="1"/>
142
+ #{indent(point_xml(0, escape(series.name)), 6).chomp}
143
+ </c:strCache>
144
+ </c:strRef>
145
+ </c:tx>
146
+ XML
147
+ end
148
+
149
+ # The categories: a numeric cache for numbers and dates, a string cache
150
+ # for a plain list, and a multi-level cache for grouped categories.
151
+ def cat_xml(series)
152
+ categories = chart_data.categories
153
+ if categories.numeric?
154
+ numeric_cat_xml(series, categories)
155
+ elsif categories.depth == 1
156
+ string_cat_xml(series, categories)
157
+ else
158
+ multi_level_cat_xml(series, categories)
159
+ end
160
+ end
161
+
162
+ def val_xml(series)
163
+ num_ref_xml("c:val", series.values_ref, series.values, series.number_format)
164
+ end
165
+
166
+ # A `c:val`, `c:xVal`, `c:yVal` or `c:bubbleSize` block: a worksheet
167
+ # reference plus the cached values.
168
+ #
169
+ # A nil value means no data at that position: the point is omitted while
170
+ # ptCount still counts it, which is how a gap is expressed.
171
+ def num_ref_xml(tag, reference, values, number_format)
172
+ points = values.each_with_index.filter_map do |value, index|
173
+ point_xml(index, format_value(value)) unless value.nil?
174
+ end.join
175
+
176
+ <<~XML
177
+ <#{tag}>
178
+ <c:numRef>
179
+ <c:f>#{reference}</c:f>
180
+ <c:numCache>
181
+ <c:formatCode>#{number_format}</c:formatCode>
182
+ <c:ptCount val="#{values.size}"/>
183
+ #{indent(points, 6).chomp}
184
+ </c:numCache>
185
+ </c:numRef>
186
+ </#{tag}>
187
+ XML
188
+ end
189
+
190
+ def numeric_cat_xml(series, categories)
191
+ points = categories.each_with_index.map do |category, index|
192
+ point_xml(index, category.numeric_str_val(date_1904: date_1904?))
193
+ end.join
194
+ <<~XML
195
+ <c:cat>
196
+ <c:numRef>
197
+ <c:f>#{series.categories_ref}</c:f>
198
+ <c:numCache>
199
+ <c:formatCode>#{categories.number_format}</c:formatCode>
200
+ <c:ptCount val="#{categories.leaf_count}"/>
201
+ #{indent(points, 6).chomp}
202
+ </c:numCache>
203
+ </c:numRef>
204
+ </c:cat>
205
+ XML
206
+ end
207
+
208
+ def string_cat_xml(series, categories)
209
+ points = categories.each_with_index.map do |category, index|
210
+ point_xml(index, escape(category.label))
211
+ end.join
212
+ <<~XML
213
+ <c:cat>
214
+ <c:strRef>
215
+ <c:f>#{series.categories_ref}</c:f>
216
+ <c:strCache>
217
+ <c:ptCount val="#{categories.leaf_count}"/>
218
+ #{indent(points, 6).chomp}
219
+ </c:strCache>
220
+ </c:strRef>
221
+ </c:cat>
222
+ XML
223
+ end
224
+
225
+ # One `c:lvl` per level, leaf level first. Each label sits at the leaf
226
+ # index where its run begins, so a parent's points are sparse.
227
+ def multi_level_cat_xml(series, categories)
228
+ levels = categories.levels.map do |level|
229
+ points = level.map { |index, label| point_xml(index, escape(label)) }.join
230
+ "<c:lvl>\n#{indent(points, 2)}</c:lvl>\n"
231
+ end.join
232
+ <<~XML
233
+ <c:cat>
234
+ <c:multiLvlStrRef>
235
+ <c:f>#{series.categories_ref}</c:f>
236
+ <c:multiLvlStrCache>
237
+ <c:ptCount val="#{categories.leaf_count}"/>
238
+ #{indent(levels, 6).chomp}
239
+ </c:multiLvlStrCache>
240
+ </c:multiLvlStrRef>
241
+ </c:cat>
242
+ XML
243
+ end
244
+
245
+ # The axis written instead of a category axis when the categories are
246
+ # dates. Bar, line and area charts share it, differing only in ids and
247
+ # position.
248
+ def date_ax_xml(ax_id, cross_ax_id, position)
249
+ <<~XML
250
+ <c:dateAx>
251
+ <c:axId val="#{ax_id}"/>
252
+ <c:scaling>
253
+ <c:orientation val="minMax"/>
254
+ </c:scaling>
255
+ <c:delete val="0"/>
256
+ <c:axPos val="#{position}"/>
257
+ <c:numFmt formatCode="#{chart_data.categories.number_format}" sourceLinked="1"/>
258
+ <c:majorTickMark val="out"/>
259
+ <c:minorTickMark val="none"/>
260
+ <c:tickLblPos val="nextTo"/>
261
+ <c:crossAx val="#{cross_ax_id}"/>
262
+ <c:crosses val="autoZero"/>
263
+ <c:auto val="1"/>
264
+ <c:lblOffset val="100"/>
265
+ <c:baseTimeUnit val="days"/>
266
+ </c:dateAx>
267
+ XML
268
+ end
269
+
270
+ def dates? = chart_data.categories.dates?
271
+
272
+ # A chart being written from scratch uses the 1900 date system;
273
+ # rewriting an existing chart's data honours the chart's own setting.
274
+ def date_1904? = @date_1904 || false
275
+
276
+ def point_xml(index, value)
277
+ %(<c:pt idx="#{index}">\n <c:v>#{value}</c:v>\n</c:pt>\n)
278
+ end
279
+
280
+ # Floats keep their trailing ".0" here, unlike in the workbook, because
281
+ # that is what the reference implementation writes.
282
+ def format_value(value) = value.is_a?(String) ? escape(value) : value.to_s
283
+
284
+ def escape(text)
285
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
286
+ end
287
+
288
+ def all_series_xml(&)
289
+ chart_data.series.map(&).join
290
+ end
291
+ end
292
+
293
+ # `c:barChart`: clustered, stacked and 100% stacked bars and columns.
294
+ class BarChartWriter < Base
295
+ CAT_AX_ID = "-2068027336"
296
+ VAL_AX_ID = "-2113994440"
297
+
298
+ BAR_TYPES = %i[BAR_CLUSTERED BAR_STACKED BAR_STACKED_100].freeze
299
+
300
+ GROUPINGS = {
301
+ BAR_CLUSTERED: "clustered", COLUMN_CLUSTERED: "clustered",
302
+ BAR_STACKED: "stacked", COLUMN_STACKED: "stacked",
303
+ BAR_STACKED_100: "percentStacked", COLUMN_STACKED_100: "percentStacked"
304
+ }.freeze
305
+
306
+ STACKED_TYPES = %i[BAR_STACKED BAR_STACKED_100 COLUMN_STACKED COLUMN_STACKED_100].freeze
307
+
308
+ def xml
309
+ <<~XML
310
+ #{Base::DECLARATION}
311
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
312
+ <c:date1904 val="0"/>
313
+ <c:chart>
314
+ <c:autoTitleDeleted val="0"/>
315
+ <c:plotArea>
316
+ <c:barChart>
317
+ <c:barDir val="#{bar_dir}"/>
318
+ <c:grouping val="#{grouping}"/>
319
+ #{indent(series_blocks, 8).chomp}
320
+ #{overlap} <c:axId val="#{CAT_AX_ID}"/>
321
+ <c:axId val="#{VAL_AX_ID}"/>
322
+ </c:barChart>
323
+ #{indent(cat_ax_xml, 6).chomp}
324
+ <c:valAx>
325
+ <c:axId val="#{VAL_AX_ID}"/>
326
+ <c:scaling/>
327
+ <c:delete val="0"/>
328
+ <c:axPos val="#{val_ax_pos}"/>
329
+ <c:majorGridlines/>
330
+ <c:majorTickMark val="out"/>
331
+ <c:minorTickMark val="none"/>
332
+ <c:tickLblPos val="nextTo"/>
333
+ <c:crossAx val="#{CAT_AX_ID}"/>
334
+ <c:crosses val="autoZero"/>
335
+ </c:valAx>
336
+ </c:plotArea>
337
+ <c:dispBlanksAs val="gap"/>
338
+ </c:chart>
339
+ #{indent(Base::TEXT_PROPERTIES, 2).chomp}
340
+ </c:chartSpace>
341
+ XML
342
+ end
343
+
344
+ private
345
+
346
+ def bar? = BAR_TYPES.include?(chart_type.name)
347
+
348
+ def bar_dir = bar? ? "bar" : "col"
349
+
350
+ def grouping = GROUPINGS.fetch(chart_type.name)
351
+
352
+ # A bar chart's category axis runs down the left; a column chart's runs
353
+ # along the bottom, and the value axis takes the other position.
354
+ def cat_ax_pos = bar? ? "l" : "b"
355
+
356
+ def val_ax_pos = bar? ? "b" : "l"
357
+
358
+ def overlap
359
+ STACKED_TYPES.include?(chart_type.name) ? %( <c:overlap val="100"/>\n) : ""
360
+ end
361
+
362
+ def series_blocks = all_series_xml { |series| series_xml(series) }
363
+
364
+ def cat_ax_xml
365
+ return date_ax_xml(CAT_AX_ID, VAL_AX_ID, cat_ax_pos) if dates?
366
+
367
+ <<~XML
368
+ <c:catAx>
369
+ <c:axId val="#{CAT_AX_ID}"/>
370
+ <c:scaling>
371
+ <c:orientation val="minMax"/>
372
+ </c:scaling>
373
+ <c:delete val="0"/>
374
+ <c:axPos val="#{cat_ax_pos}"/>
375
+ <c:majorTickMark val="out"/>
376
+ <c:minorTickMark val="none"/>
377
+ <c:tickLblPos val="nextTo"/>
378
+ <c:crossAx val="#{VAL_AX_ID}"/>
379
+ <c:crosses val="autoZero"/>
380
+ <c:auto val="1"/>
381
+ <c:lblAlgn val="ctr"/>
382
+ <c:lblOffset val="100"/>
383
+ <c:noMultiLvlLbl val="0"/>
384
+ </c:catAx>
385
+ XML
386
+ end
387
+ end
388
+
389
+ # `c:lineChart`, with or without markers.
390
+ class LineChartWriter < Base
391
+ CAT_AX_ID = "2118791784"
392
+ VAL_AX_ID = "2140495176"
393
+
394
+ MARKER_TYPES = %i[LINE_MARKERS LINE_MARKERS_STACKED LINE_MARKERS_STACKED_100].freeze
395
+
396
+ GROUPINGS = {
397
+ LINE: "standard", LINE_MARKERS: "standard",
398
+ LINE_STACKED: "stacked", LINE_MARKERS_STACKED: "stacked",
399
+ LINE_STACKED_100: "percentStacked", LINE_MARKERS_STACKED_100: "percentStacked"
400
+ }.freeze
401
+
402
+ def xml
403
+ <<~XML
404
+ #{Base::DECLARATION}
405
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
406
+ <c:date1904 val="0"/>
407
+ <c:chart>
408
+ <c:autoTitleDeleted val="0"/>
409
+ <c:plotArea>
410
+ <c:lineChart>
411
+ <c:grouping val="#{grouping}"/>
412
+ <c:varyColors val="0"/>
413
+ #{indent(series_blocks, 8).chomp}
414
+ <c:marker val="1"/>
415
+ <c:smooth val="0"/>
416
+ <c:axId val="#{CAT_AX_ID}"/>
417
+ <c:axId val="#{VAL_AX_ID}"/>
418
+ </c:lineChart>
419
+ #{indent(cat_ax_xml, 6).chomp}
420
+ <c:valAx>
421
+ <c:axId val="#{VAL_AX_ID}"/>
422
+ <c:scaling/>
423
+ <c:delete val="0"/>
424
+ <c:axPos val="l"/>
425
+ <c:majorGridlines/>
426
+ <c:majorTickMark val="out"/>
427
+ <c:minorTickMark val="none"/>
428
+ <c:tickLblPos val="nextTo"/>
429
+ <c:crossAx val="#{CAT_AX_ID}"/>
430
+ <c:crosses val="autoZero"/>
431
+ </c:valAx>
432
+ </c:plotArea>
433
+ #{indent(Base::LEGEND, 4).chomp}
434
+ <c:plotVisOnly val="1"/>
435
+ <c:dispBlanksAs val="gap"/>
436
+ <c:showDLblsOverMax val="0"/>
437
+ </c:chart>
438
+ #{indent(Base::TEXT_PROPERTIES, 2).chomp}
439
+ </c:chartSpace>
440
+ XML
441
+ end
442
+
443
+ private
444
+
445
+ def grouping = GROUPINGS.fetch(chart_type.name)
446
+
447
+ def markers? = MARKER_TYPES.include?(chart_type.name)
448
+
449
+ # A line chart without markers says so per series, by asking for the
450
+ # "none" marker symbol.
451
+ def marker_none_xml
452
+ return "" if markers?
453
+
454
+ <<~XML
455
+ <c:marker>
456
+ <c:symbol val="none"/>
457
+ </c:marker>
458
+ XML
459
+ end
460
+
461
+ def smooth_xml = %(<c:smooth val="0"/>\n)
462
+
463
+ def series_blocks
464
+ all_series_xml do |series|
465
+ series_xml(series, extra_before_cat: marker_none_xml, extra_after_val: smooth_xml)
466
+ end
467
+ end
468
+
469
+ def cat_ax_xml
470
+ return date_ax_xml(CAT_AX_ID, VAL_AX_ID, "b") if dates?
471
+
472
+ <<~XML
473
+ <c:catAx>
474
+ <c:axId val="#{CAT_AX_ID}"/>
475
+ <c:scaling>
476
+ <c:orientation val="minMax"/>
477
+ </c:scaling>
478
+ <c:delete val="0"/>
479
+ <c:axPos val="b"/>
480
+ <c:majorTickMark val="out"/>
481
+ <c:minorTickMark val="none"/>
482
+ <c:tickLblPos val="nextTo"/>
483
+ <c:crossAx val="#{VAL_AX_ID}"/>
484
+ <c:crosses val="autoZero"/>
485
+ <c:auto val="1"/>
486
+ <c:lblAlgn val="ctr"/>
487
+ <c:lblOffset val="100"/>
488
+ <c:noMultiLvlLbl val="0"/>
489
+ </c:catAx>
490
+ XML
491
+ end
492
+ end
493
+
494
+ # `c:pieChart`, a single series shown as slices.
495
+ class PieChartWriter < Base
496
+ def xml
497
+ <<~XML
498
+ #{Base::DECLARATION}
499
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
500
+ <c:chart>
501
+ <c:autoTitleDeleted val="0"/>
502
+ <c:plotArea>
503
+ <c:pieChart>
504
+ <c:varyColors val="1"/>
505
+ #{indent(series_blocks, 8).chomp}
506
+ </c:pieChart>
507
+ </c:plotArea>
508
+ <c:dispBlanksAs val="gap"/>
509
+ </c:chart>
510
+ #{indent(Base::TEXT_PROPERTIES, 2).chomp}
511
+ </c:chartSpace>
512
+ XML
513
+ end
514
+
515
+ private
516
+
517
+ # A pie plots one series only; any others live in the workbook but are
518
+ # not drawn.
519
+ def series_blocks = series_xml(chart_data.series.first)
520
+ end
521
+
522
+ # Shared by the two families whose points carry their own x value rather
523
+ # than sitting against a shared category.
524
+ class XyBase < Base
525
+ private
526
+
527
+ # A scatter or bubble series names its own x and y ranges instead of
528
+ # sharing a category axis.
529
+ def xy_series_xml(series, extra_after_tx: "", extra_after_values: "")
530
+ parts = [
531
+ %( <c:idx val="#{series.index}"/>\n),
532
+ %( <c:order val="#{series.index}"/>\n),
533
+ indent(tx_xml(series), 2),
534
+ extra_after_tx.empty? ? "" : indent(extra_after_tx, 2),
535
+ indent(num_ref_xml("c:xVal", series.x_values_ref, series.x_values,
536
+ series.number_format), 2),
537
+ indent(num_ref_xml("c:yVal", series.y_values_ref, series.y_values,
538
+ series.number_format), 2),
539
+ extra_after_values.empty? ? "" : indent(extra_after_values, 2)
540
+ ]
541
+ "<c:ser>\n#{parts.join}</c:ser>\n"
542
+ end
543
+
544
+ def value_axes_xml(x_ax_id, y_ax_id)
545
+ <<~XML
546
+ <c:valAx>
547
+ <c:axId val="#{x_ax_id}"/>
548
+ <c:scaling>
549
+ <c:orientation val="minMax"/>
550
+ </c:scaling>
551
+ <c:delete val="0"/>
552
+ <c:axPos val="b"/>
553
+ <c:numFmt formatCode="General" sourceLinked="1"/>
554
+ <c:majorTickMark val="out"/>
555
+ <c:minorTickMark val="none"/>
556
+ <c:tickLblPos val="nextTo"/>
557
+ <c:crossAx val="#{y_ax_id}"/>
558
+ <c:crosses val="autoZero"/>
559
+ <c:crossBetween val="midCat"/>
560
+ </c:valAx>
561
+ <c:valAx>
562
+ <c:axId val="#{y_ax_id}"/>
563
+ <c:scaling>
564
+ <c:orientation val="minMax"/>
565
+ </c:scaling>
566
+ <c:delete val="0"/>
567
+ <c:axPos val="l"/>
568
+ <c:majorGridlines/>
569
+ <c:numFmt formatCode="General" sourceLinked="1"/>
570
+ <c:majorTickMark val="out"/>
571
+ <c:minorTickMark val="none"/>
572
+ <c:tickLblPos val="nextTo"/>
573
+ <c:crossAx val="#{x_ax_id}"/>
574
+ <c:crosses val="autoZero"/>
575
+ <c:crossBetween val="midCat"/>
576
+ </c:valAx>
577
+ XML
578
+ end
579
+ end
580
+
581
+ # `c:scatterChart`, points at (x, y) with optional lines.
582
+ class XyChartWriter < XyBase
583
+ X_AX_ID = "-2128940872"
584
+ Y_AX_ID = "-2129643912"
585
+
586
+ SMOOTH_TYPES = %i[XY_SCATTER_SMOOTH XY_SCATTER_SMOOTH_NO_MARKERS].freeze
587
+ NO_MARKER_TYPES = %i[XY_SCATTER_LINES_NO_MARKERS XY_SCATTER_SMOOTH_NO_MARKERS].freeze
588
+
589
+ def xml
590
+ <<~XML
591
+ #{Base::DECLARATION}
592
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
593
+ <c:chart>
594
+ <c:plotArea>
595
+ <c:scatterChart>
596
+ <c:scatterStyle val="#{scatter_style}"/>
597
+ <c:varyColors val="0"/>
598
+ #{indent(series_blocks, 8).chomp}
599
+ <c:axId val="#{X_AX_ID}"/>
600
+ <c:axId val="#{Y_AX_ID}"/>
601
+ </c:scatterChart>
602
+ #{indent(value_axes_xml(X_AX_ID, Y_AX_ID), 6).chomp}
603
+ </c:plotArea>
604
+ #{indent(Base::LEGEND, 4).chomp}
605
+ <c:plotVisOnly val="1"/>
606
+ <c:dispBlanksAs val="gap"/>
607
+ <c:showDLblsOverMax val="0"/>
608
+ </c:chart>
609
+ #{indent(Base::TEXT_PROPERTIES, 2).chomp}
610
+ </c:chartSpace>
611
+ XML
612
+ end
613
+
614
+ private
615
+
616
+ def scatter_style
617
+ SMOOTH_TYPES.include?(chart_type.name) ? "smoothMarker" : "lineMarker"
618
+ end
619
+
620
+ # Plain XY_SCATTER draws markers only, which is said by giving the series
621
+ # an invisible line rather than by any scatterStyle value.
622
+ def line_suppression_xml
623
+ return "" unless chart_type.name == :XY_SCATTER
624
+
625
+ <<~XML
626
+ <c:spPr>
627
+ <a:ln w="47625">
628
+ <a:noFill/>
629
+ </a:ln>
630
+ </c:spPr>
631
+ XML
632
+ end
633
+
634
+ def marker_none_xml
635
+ return "" unless NO_MARKER_TYPES.include?(chart_type.name)
636
+
637
+ <<~XML
638
+ <c:marker>
639
+ <c:symbol val="none"/>
640
+ </c:marker>
641
+ XML
642
+ end
643
+
644
+ def series_blocks
645
+ all_series_xml do |series|
646
+ xy_series_xml(series,
647
+ extra_after_tx: line_suppression_xml + marker_none_xml,
648
+ extra_after_values: %(<c:smooth val="0"/>\n))
649
+ end
650
+ end
651
+ end
652
+
653
+ # `c:bubbleChart`, points at (x, y) sized by a third value.
654
+ class BubbleChartWriter < XyBase
655
+ X_AX_ID = "-2115720072"
656
+ Y_AX_ID = "-2115723560"
657
+
658
+ def xml
659
+ <<~XML
660
+ #{Base::DECLARATION}
661
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
662
+ <c:chart>
663
+ <c:autoTitleDeleted val="0"/>
664
+ <c:plotArea>
665
+ <c:layout/>
666
+ <c:bubbleChart>
667
+ <c:varyColors val="0"/>
668
+ #{indent(series_blocks, 8).chomp}
669
+ <c:dLbls>
670
+ <c:showLegendKey val="0"/>
671
+ <c:showVal val="0"/>
672
+ <c:showCatName val="0"/>
673
+ <c:showSerName val="0"/>
674
+ <c:showPercent val="0"/>
675
+ <c:showBubbleSize val="0"/>
676
+ </c:dLbls>
677
+ <c:bubbleScale val="100"/>
678
+ <c:showNegBubbles val="0"/>
679
+ <c:axId val="#{X_AX_ID}"/>
680
+ <c:axId val="#{Y_AX_ID}"/>
681
+ </c:bubbleChart>
682
+ #{indent(value_axes_xml(X_AX_ID, Y_AX_ID), 6).chomp}
683
+ </c:plotArea>
684
+ #{indent(Base::LEGEND, 4).chomp}
685
+ <c:plotVisOnly val="1"/>
686
+ <c:dispBlanksAs val="gap"/>
687
+ <c:showDLblsOverMax val="0"/>
688
+ </c:chart>
689
+ #{indent(Base::TEXT_PROPERTIES, 2).chomp}
690
+ </c:chartSpace>
691
+ XML
692
+ end
693
+
694
+ private
695
+
696
+ def three_d? = chart_type.name == :BUBBLE_THREE_D_EFFECT
697
+
698
+ def series_blocks
699
+ all_series_xml do |series|
700
+ sizes = indent(num_ref_xml("c:bubbleSize", series.bubble_sizes_ref,
701
+ series.bubble_sizes, series.number_format), 0)
702
+ xy_series_xml(series,
703
+ extra_after_tx: %(<c:invertIfNegative val="0"/>\n),
704
+ extra_after_values: sizes + %(<c:bubble3D val="#{three_d? ? 1 : 0}"/>\n))
705
+ end
706
+ end
707
+ end
708
+
709
+ # `c:areaChart`, filled bands under each series.
710
+ class AreaChartWriter < Base
711
+ CAT_AX_ID = "-2101159928"
712
+ VAL_AX_ID = "-2100718248"
713
+
714
+ GROUPINGS = {
715
+ AREA: "standard", AREA_STACKED: "stacked", AREA_STACKED_100: "percentStacked"
716
+ }.freeze
717
+
718
+ def xml
719
+ <<~XML
720
+ #{Base::DECLARATION}
721
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
722
+ <c:date1904 val="0"/>
723
+ <c:roundedCorners val="0"/>
724
+ <c:chart>
725
+ <c:autoTitleDeleted val="0"/>
726
+ <c:plotArea>
727
+ <c:layout/>
728
+ <c:areaChart>
729
+ <c:grouping val="#{grouping}"/>
730
+ <c:varyColors val="0"/>
731
+ #{indent(series_blocks, 8).chomp}
732
+ <c:dLbls>
733
+ <c:showLegendKey val="0"/>
734
+ <c:showVal val="0"/>
735
+ <c:showCatName val="0"/>
736
+ <c:showSerName val="0"/>
737
+ <c:showPercent val="0"/>
738
+ <c:showBubbleSize val="0"/>
739
+ </c:dLbls>
740
+ <c:axId val="#{CAT_AX_ID}"/>
741
+ <c:axId val="#{VAL_AX_ID}"/>
742
+ </c:areaChart>
743
+ #{indent(cat_ax_xml, 6).chomp}
744
+ <c:valAx>
745
+ <c:axId val="#{VAL_AX_ID}"/>
746
+ <c:scaling>
747
+ <c:orientation val="minMax"/>
748
+ </c:scaling>
749
+ <c:delete val="0"/>
750
+ <c:axPos val="l"/>
751
+ <c:majorGridlines/>
752
+ <c:numFmt formatCode="General" sourceLinked="1"/>
753
+ <c:majorTickMark val="out"/>
754
+ <c:minorTickMark val="none"/>
755
+ <c:tickLblPos val="nextTo"/>
756
+ <c:crossAx val="#{CAT_AX_ID}"/>
757
+ <c:crosses val="autoZero"/>
758
+ <c:crossBetween val="midCat"/>
759
+ </c:valAx>
760
+ </c:plotArea>
761
+ #{indent(Base::LEGEND, 4).chomp}
762
+ <c:plotVisOnly val="1"/>
763
+ <c:dispBlanksAs val="zero"/>
764
+ <c:showDLblsOverMax val="0"/>
765
+ </c:chart>
766
+ #{indent(Base::TEXT_PROPERTIES_WITHOUT_LANG, 2).chomp}
767
+ </c:chartSpace>
768
+ XML
769
+ end
770
+
771
+ private
772
+
773
+ def grouping = GROUPINGS.fetch(chart_type.name)
774
+
775
+ def series_blocks = all_series_xml { |series| series_xml(series) }
776
+
777
+ def cat_ax_xml
778
+ return date_ax_xml(CAT_AX_ID, VAL_AX_ID, "b") if dates?
779
+
780
+ <<~XML
781
+ <c:catAx>
782
+ <c:axId val="#{CAT_AX_ID}"/>
783
+ <c:scaling>
784
+ <c:orientation val="minMax"/>
785
+ </c:scaling>
786
+ <c:delete val="0"/>
787
+ <c:axPos val="b"/>
788
+ <c:numFmt formatCode="General" sourceLinked="1"/>
789
+ <c:majorTickMark val="out"/>
790
+ <c:minorTickMark val="none"/>
791
+ <c:tickLblPos val="nextTo"/>
792
+ <c:crossAx val="#{VAL_AX_ID}"/>
793
+ <c:crosses val="autoZero"/>
794
+ <c:auto val="1"/>
795
+ <c:lblAlgn val="ctr"/>
796
+ <c:lblOffset val="100"/>
797
+ <c:noMultiLvlLbl val="0"/>
798
+ </c:catAx>
799
+ XML
800
+ end
801
+ end
802
+
803
+ # `c:radarChart`, values plotted on spokes around a centre.
804
+ class RadarChartWriter < Base
805
+ CAT_AX_ID = "2073612648"
806
+ VAL_AX_ID = "-2112772216"
807
+
808
+ # A radar chart carries a style hint PowerPoint 2007 did not understand,
809
+ # so it is written inside an AlternateContent block with a plain
810
+ # fallback.
811
+ ALTERNATE_CONTENT = <<~XML
812
+ <mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
813
+ <mc:Choice xmlns:c14="http://schemas.microsoft.com/office/drawing/2007/8/2/chart" Requires="c14">
814
+ <c14:style val="118"/>
815
+ </mc:Choice>
816
+ <mc:Fallback>
817
+ <c:style val="18"/>
818
+ </mc:Fallback>
819
+ </mc:AlternateContent>
820
+ XML
821
+
822
+ def xml
823
+ <<~XML
824
+ #{Base::DECLARATION}
825
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
826
+ <c:date1904 val="0"/>
827
+ <c:roundedCorners val="0"/>
828
+ #{indent(ALTERNATE_CONTENT, 2).chomp}
829
+ <c:chart>
830
+ <c:autoTitleDeleted val="0"/>
831
+ <c:plotArea>
832
+ <c:layout/>
833
+ <c:radarChart>
834
+ <c:radarStyle val="#{radar_style}"/>
835
+ <c:varyColors val="0"/>
836
+ #{indent(series_blocks, 8).chomp}
837
+ <c:axId val="#{CAT_AX_ID}"/>
838
+ <c:axId val="#{VAL_AX_ID}"/>
839
+ </c:radarChart>
840
+ <c:catAx>
841
+ <c:axId val="#{CAT_AX_ID}"/>
842
+ <c:scaling>
843
+ <c:orientation val="minMax"/>
844
+ </c:scaling>
845
+ <c:delete val="0"/>
846
+ <c:axPos val="b"/>
847
+ <c:majorGridlines/>
848
+ <c:numFmt formatCode="m/d/yy" sourceLinked="1"/>
849
+ <c:majorTickMark val="out"/>
850
+ <c:minorTickMark val="none"/>
851
+ <c:tickLblPos val="nextTo"/>
852
+ <c:crossAx val="#{VAL_AX_ID}"/>
853
+ <c:crosses val="autoZero"/>
854
+ <c:auto val="1"/>
855
+ <c:lblAlgn val="ctr"/>
856
+ <c:lblOffset val="100"/>
857
+ <c:noMultiLvlLbl val="0"/>
858
+ </c:catAx>
859
+ <c:valAx>
860
+ <c:axId val="#{VAL_AX_ID}"/>
861
+ <c:scaling>
862
+ <c:orientation val="minMax"/>
863
+ </c:scaling>
864
+ <c:delete val="0"/>
865
+ <c:axPos val="l"/>
866
+ <c:majorGridlines/>
867
+ <c:numFmt formatCode="General" sourceLinked="1"/>
868
+ <c:majorTickMark val="cross"/>
869
+ <c:minorTickMark val="none"/>
870
+ <c:tickLblPos val="nextTo"/>
871
+ <c:crossAx val="#{CAT_AX_ID}"/>
872
+ <c:crosses val="autoZero"/>
873
+ <c:crossBetween val="between"/>
874
+ </c:valAx>
875
+ </c:plotArea>
876
+ <c:plotVisOnly val="1"/>
877
+ <c:dispBlanksAs val="gap"/>
878
+ <c:showDLblsOverMax val="0"/>
879
+ </c:chart>
880
+ #{indent(Base::TEXT_PROPERTIES, 2).chomp}
881
+ </c:chartSpace>
882
+ XML
883
+ end
884
+
885
+ private
886
+
887
+ def radar_style = chart_type.name == :RADAR_FILLED ? "filled" : "marker"
888
+
889
+ # A plain radar draws lines without markers, which is said per series by
890
+ # asking for the "none" symbol. The markers and filled variants do not.
891
+ def marker_none_xml
892
+ return "" unless chart_type.name == :RADAR
893
+
894
+ <<~XML
895
+ <c:marker>
896
+ <c:symbol val="none"/>
897
+ </c:marker>
898
+ XML
899
+ end
900
+
901
+ def series_blocks
902
+ all_series_xml do |series|
903
+ series_xml(series, extra_before_cat: marker_none_xml,
904
+ extra_after_val: %(<c:smooth val="0"/>\n))
905
+ end
906
+ end
907
+ end
908
+
909
+ # `c:doughnutChart`, a pie with a hole.
910
+ class DoughnutChartWriter < Base
911
+ def xml
912
+ <<~XML
913
+ #{Base::DECLARATION}
914
+ <c:chartSpace #{Base::CHART_SPACE_NS}>
915
+ <c:date1904 val="0"/>
916
+ <c:roundedCorners val="0"/>
917
+ <c:chart>
918
+ <c:autoTitleDeleted val="0"/>
919
+ <c:plotArea>
920
+ <c:layout/>
921
+ <c:doughnutChart>
922
+ <c:varyColors val="1"/>
923
+ #{indent(series_blocks, 8).chomp}
924
+ <c:dLbls>
925
+ <c:showLegendKey val="0"/>
926
+ <c:showVal val="0"/>
927
+ <c:showCatName val="0"/>
928
+ <c:showSerName val="0"/>
929
+ <c:showPercent val="0"/>
930
+ <c:showBubbleSize val="0"/>
931
+ <c:showLeaderLines val="1"/>
932
+ </c:dLbls>
933
+ <c:firstSliceAng val="0"/>
934
+ <c:holeSize val="50"/>
935
+ </c:doughnutChart>
936
+ </c:plotArea>
937
+ #{indent(Base::LEGEND, 4).chomp}
938
+ <c:plotVisOnly val="1"/>
939
+ <c:dispBlanksAs val="gap"/>
940
+ <c:showDLblsOverMax val="0"/>
941
+ </c:chart>
942
+ #{indent(Base::TEXT_PROPERTIES_WITHOUT_LANG, 2).chomp}
943
+ </c:chartSpace>
944
+ XML
945
+ end
946
+
947
+ private
948
+
949
+ # Unlike a pie, a doughnut draws every series as a concentric ring.
950
+ def series_blocks = all_series_xml { |series| series_xml(series) }
951
+ end
952
+ end
953
+ end