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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Pptx
6
+ # The categories of a chart being built -- a plain list, or a tree when
7
+ # categories are grouped.
8
+ #
9
+ # data.categories = %w[East West Mid]
10
+ #
11
+ # data.categories = { "East" => %w[North South], "West" => %w[Coast] }
12
+ #
13
+ # east = data.categories.add_category("East")
14
+ # east.add_sub_category("North")
15
+ #
16
+ # Grouped categories become a multi-level axis: the leaves are plotted,
17
+ # and each level above labels a run of them. Every branch must be equally
18
+ # deep.
19
+ #
20
+ # Dates make a date axis, and numbers a numeric one; both are decided by the
21
+ # first category, as python-pptx decides them.
22
+ class ChartDataCategories
23
+ include Enumerable
24
+
25
+ GENERAL = "General"
26
+ DATE_FORMAT = "yyyy\\-mm\\-dd"
27
+
28
+ # Excel's own number format for dates, used unless one is set.
29
+ attr_writer :number_format
30
+
31
+ def initialize
32
+ @categories = []
33
+ @number_format = nil
34
+ end
35
+
36
+ # Build from a list of labels, or from a Hash whose values are the
37
+ # sub-categories of each key, nested as deep as needed.
38
+ def self.from(value)
39
+ new.tap { |categories| categories.add_all(value) }
40
+ end
41
+
42
+ # @api private
43
+ def add_all(value)
44
+ if value.is_a?(Hash)
45
+ value.each { |label, children| add_category(label).add_all(children) }
46
+ else
47
+ Array(value).each { |label| add_category(label) }
48
+ end
49
+ self
50
+ end
51
+
52
+ # @return [ChartDataCategory] the new top-level category
53
+ def add_category(label)
54
+ ChartDataCategory.new(label, self).tap { |category| @categories << category }
55
+ end
56
+
57
+ def each(&) = @categories.each(&)
58
+
59
+ def [](index) = @categories[index]
60
+
61
+ def size = @categories.size
62
+ alias length size
63
+
64
+ def empty? = @categories.empty?
65
+
66
+ # Levels of labels: 0 with no categories, 1 for a plain list.
67
+ #
68
+ # @raise [Error] when the branches are not all equally deep
69
+ def depth
70
+ return 0 if @categories.empty?
71
+
72
+ depths = @categories.map(&:depth).uniq
73
+ raise Error, "category depth not uniform" if depths.size > 1
74
+
75
+ depths.first
76
+ end
77
+
78
+ # The number of categories actually plotted.
79
+ def leaf_count = @categories.sum(&:leaf_count)
80
+
81
+ # Each level as [index, label] pairs, leaf level first. The index is the
82
+ # leaf position where that label's run begins.
83
+ def levels
84
+ collect_levels(@categories)
85
+ end
86
+
87
+ # The leaf labels in plotting order.
88
+ def leaf_labels = levels.first.to_a.map(&:last)
89
+
90
+ def dates? = depth == 1 && date?(first.label)
91
+
92
+ def numeric? = depth == 1 && (first.label.is_a?(Numeric) || date?(first.label))
93
+
94
+ # The number format for the categories: as set, or a date format for
95
+ # dates, or "General".
96
+ def number_format
97
+ return @number_format unless @number_format.nil?
98
+
99
+ dates? ? DATE_FORMAT : GENERAL
100
+ end
101
+
102
+ # @api private
103
+ # The leaf offset at which +category+'s run begins.
104
+ def index_of(category)
105
+ offset = 0
106
+ @categories.each do |candidate|
107
+ return offset if candidate.equal?(category)
108
+
109
+ offset += candidate.leaf_count
110
+ end
111
+ raise Error, "category not in these categories"
112
+ end
113
+
114
+ def inspect = "#<Pptx::ChartDataCategories depth=#{depth} #{leaf_labels.inspect}>"
115
+
116
+ private
117
+
118
+ def collect_levels(categories)
119
+ children = categories.flat_map(&:sub_categories)
120
+ lower = children.empty? ? [] : collect_levels(children)
121
+ lower + [categories.map { |category| [category.idx, category.label] }]
122
+ end
123
+
124
+ def date?(value) = value.is_a?(Date) || value.is_a?(Time)
125
+ end
126
+
127
+ # One category, or a group of them when it has sub-categories.
128
+ class ChartDataCategory
129
+ attr_reader :sub_categories
130
+
131
+ def initialize(label, parent)
132
+ @label = label
133
+ @parent = parent
134
+ @sub_categories = []
135
+ end
136
+
137
+ # The label; "" for a category given as nil.
138
+ def label = @label.nil? ? "" : @label
139
+
140
+ # @return [ChartDataCategory] the new sub-category
141
+ def add_sub_category(label)
142
+ ChartDataCategory.new(label, self).tap { |category| @sub_categories << category }
143
+ end
144
+
145
+ # @api private
146
+ def add_all(value)
147
+ if value.is_a?(Hash)
148
+ value.each { |label, children| add_sub_category(label).add_all(children) }
149
+ else
150
+ Array(value).each { |label| add_sub_category(label) }
151
+ end
152
+ self
153
+ end
154
+
155
+ def depth
156
+ return 1 if @sub_categories.empty?
157
+
158
+ depths = @sub_categories.map(&:depth).uniq
159
+ raise Error, "category depth not uniform" if depths.size > 1
160
+
161
+ depths.first + 1
162
+ end
163
+
164
+ def leaf_count = @sub_categories.empty? ? 1 : @sub_categories.sum(&:leaf_count)
165
+
166
+ # The leaf position at which this category's run begins.
167
+ def idx = @parent.index_of(self)
168
+
169
+ # @api private
170
+ def index_of(sub_category)
171
+ offset = idx
172
+ @sub_categories.each do |candidate|
173
+ return offset if candidate.equal?(sub_category)
174
+
175
+ offset += candidate.leaf_count
176
+ end
177
+ raise Error, "sub-category not in this category"
178
+ end
179
+
180
+ # The label as a numeric category's cached value: a date as its Excel
181
+ # serial day number with one decimal, as python-pptx writes it.
182
+ def numeric_str_val(date_1904: false)
183
+ value = @label
184
+ if value.is_a?(Date) || value.is_a?(Time)
185
+ return format("%.1f",
186
+ excel_day_number(value.to_date, date_1904))
187
+ end
188
+
189
+ value.to_s
190
+ end
191
+
192
+ # The label as it appears in the category cache and worksheet.
193
+ def to_s = label.to_s
194
+
195
+ def inspect = "#<Pptx::ChartDataCategory #{label.inspect}>"
196
+
197
+ private
198
+
199
+ # Days since Excel's epoch. Excel treats 1900 as a leap year, so every
200
+ # date after 28 February 1900 is one day further on than the calendar
201
+ # says.
202
+ def excel_day_number(date, date_1904)
203
+ epoch = date_1904 ? Date.new(1904, 1, 1) : Date.new(1899, 12, 31)
204
+ days = (date - epoch).to_i
205
+ !date_1904 && days > 59 ? days + 1 : days
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,443 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/pattern_matching"
4
+
5
+ require "ruby_pptx/element_proxy"
6
+ require "ruby_pptx/enum/chart"
7
+ require "ruby_pptx/chart/format"
8
+
9
+ module Pptx
10
+ # A chart, reached through the graphic frame that holds it.
11
+ class Chart < PartElementProxy
12
+ include PatternMatching
13
+
14
+ pattern_keys :chart_type, :plot_type, :categories, :series
15
+
16
+ PLOT_TAG_TO_TYPE = {
17
+ "c:barChart" => :BAR, "c:lineChart" => :LINE, "c:pieChart" => :PIE,
18
+ "c:doughnutChart" => :DOUGHNUT, "c:areaChart" => :AREA, "c:area3DChart" => :AREA,
19
+ "c:radarChart" => :RADAR, "c:scatterChart" => :XY, "c:bubbleChart" => :BUBBLE
20
+ }.freeze
21
+
22
+ # The family of plot this chart draws with, e.g. :BAR. {#chart_type} is
23
+ # the precise type.
24
+ def plot_type
25
+ element = @element.plot_element
26
+ element && PLOT_TAG_TO_TYPE[element.nsptag]
27
+ end
28
+
29
+ # The chart type of the first plot, e.g. COLUMN_STACKED. Recovered from
30
+ # the plot's direction, grouping, markers and other settings, as
31
+ # python-pptx's PlotTypeInspector does.
32
+ #
33
+ # @return [Pptx::Enum::Member] a member of XL_CHART_TYPE
34
+ def chart_type = ChartTypeInspector.chart_type(plot_area.plot_elements.first)
35
+
36
+ # The chart style number, 1 to 48, or nil when none is set.
37
+ def chart_style = @element.style&.val
38
+
39
+ def chart_style=(value)
40
+ @element.remove_style
41
+ @element.get_or_add_style.val = value unless value.nil?
42
+ end
43
+
44
+ # The default font for all text in the chart, created on first use.
45
+ def font = @font ||= Font.new(@element.defRPr)
46
+
47
+ # Every series in the chart: plot by plot, and within a plot in the order
48
+ # it draws them.
49
+ #
50
+ # @return [Array<ChartSeriesView>]
51
+ def series
52
+ plot_area.plot_elements.flat_map { |plot| plot.sers.map { |ser| ChartSeriesView.for(ser, self) } }
53
+ end
54
+
55
+ # Replace this chart's categories and series with those in +chart_data+.
56
+ #
57
+ # Series-level formatting is left alone. If there are more series than
58
+ # before, the extra ones are cloned from the last series of the last plot
59
+ # so they inherit its formatting; if fewer, the surplus is removed along
60
+ # with any plot left empty.
61
+ def replace_data(chart_data)
62
+ SeriesRewriter.new(chart_data).rewrite(@element)
63
+ part.workbook.replace_with(chart_data.xlsx_blob)
64
+ self
65
+ end
66
+
67
+ # Whether a legend is drawn.
68
+ def legend? = !chart_element.legend.nil?
69
+
70
+ def legend=(value)
71
+ value ? chart_element.get_or_add_legend : chart_element.remove_legend
72
+ @legend = nil
73
+ end
74
+
75
+ # @return [ChartLegend, nil] nil when the chart has no legend
76
+ def legend
77
+ element = chart_element.legend
78
+ element.nil? ? nil : (@legend ||= ChartLegend.new(element))
79
+ end
80
+
81
+ def title? = !chart_element.title.nil?
82
+
83
+ # @return [ChartTitle, nil]
84
+ def title
85
+ element = chart_element.title
86
+ element.nil? ? nil : ChartTitle.new(element)
87
+ end
88
+
89
+ # A String sets the title's text; true shows a title with no text of its
90
+ # own, which PowerPoint fills in from the series; nil or false removes it.
91
+ #
92
+ # Removing it also records that it was deleted. Without that flag a
93
+ # single-series chart grows its generated title back when opened.
94
+ def title=(value)
95
+ case value
96
+ when nil, false
97
+ chart_element.remove_title
98
+ chart_element.get_or_add_autoTitleDeleted.val = true
99
+ when true then chart_element.get_or_add_title
100
+ else ChartTitle.new(chart_element.get_or_add_title).text = value
101
+ end
102
+ end
103
+
104
+ # The plots -- "chart groups" in the MS API -- this chart draws.
105
+ #
106
+ # Nearly every chart has exactly one; a combo chart has several, which is
107
+ # why this is a collection rather than a property of the chart.
108
+ def plots = plot_area.plot_elements.map { |element| ChartPlot.new(element, self) }
109
+
110
+ # The horizontal axis: a category or date axis, or, on an XY or bubble
111
+ # chart, the first value axis. nil for a chart with no axes, such as a pie.
112
+ #
113
+ # @return [CategoryAxis, DateAxis, ValueAxis, nil]
114
+ def category_axis
115
+ if (element = plot_area.find("c:catAx")) then CategoryAxis.new(element)
116
+ elsif (element = plot_area.find("c:dateAx")) then DateAxis.new(element)
117
+ elsif (element = plot_area.value_axes.first) then ValueAxis.new(element)
118
+ end
119
+ end
120
+
121
+ # The vertical value axis, or nil for a chart with none.
122
+ #
123
+ # An XY or bubble chart has two value axes, and this is the second -- the
124
+ # vertical one -- as in python-pptx. {#category_axis} is the horizontal.
125
+ def value_axis
126
+ axes = plot_area.value_axes
127
+ return nil if axes.empty?
128
+
129
+ ValueAxis.new(axes[axes.size > 1 ? 1 : 0])
130
+ end
131
+
132
+ def value_axes = plot_area.value_axes.map { |element| ValueAxis.new(element) }
133
+
134
+ # The category labels cached in the chart XML.
135
+ def categories
136
+ first = @element.series_elements.first
137
+ return [] if first.nil?
138
+
139
+ first.xpath("./c:cat//c:pt/c:v").map(&:text)
140
+ end
141
+
142
+ def inspect = "#<Pptx::Chart #{chart_type.name} series=#{series.size}>"
143
+
144
+ private
145
+
146
+ def chart_element = @element.chart
147
+
148
+ def plot_area = chart_element.plotArea
149
+ end
150
+
151
+ # Works out the full chart type from a plot element, after python-pptx's
152
+ # PlotTypeInspector. A bar and a column chart, or a clustered and a stacked
153
+ # one, share an element and differ only in its settings.
154
+ module ChartTypeInspector
155
+ XL = Enum::XL_CHART_TYPE
156
+
157
+ GROUPED = {
158
+ "c:areaChart" => { "standard" => :AREA, "stacked" => :AREA_STACKED,
159
+ "percentStacked" => :AREA_STACKED_100 },
160
+ "c:area3DChart" => { "standard" => :THREE_D_AREA, "stacked" => :THREE_D_AREA_STACKED,
161
+ "percentStacked" => :THREE_D_AREA_STACKED_100 }
162
+ }.freeze
163
+
164
+ BAR = {
165
+ "bar" => { "clustered" => :BAR_CLUSTERED, "stacked" => :BAR_STACKED,
166
+ "percentStacked" => :BAR_STACKED_100 },
167
+ "col" => { "clustered" => :COLUMN_CLUSTERED, "stacked" => :COLUMN_STACKED,
168
+ "percentStacked" => :COLUMN_STACKED_100 }
169
+ }.freeze
170
+
171
+ LINE = {
172
+ true => { "standard" => :LINE_MARKERS, "stacked" => :LINE_MARKERS_STACKED,
173
+ "percentStacked" => :LINE_MARKERS_STACKED_100 },
174
+ false => { "standard" => :LINE, "stacked" => :LINE_STACKED,
175
+ "percentStacked" => :LINE_STACKED_100 }
176
+ }.freeze
177
+
178
+ module_function
179
+
180
+ # @return [Pptx::Enum::Member]
181
+ def chart_type(plot)
182
+ XL.fetch(name_for(plot))
183
+ end
184
+
185
+ # How to tell the types apart, per plot element.
186
+ RULES = {
187
+ "c:areaChart" => ->(plot) { GROUPED.fetch("c:areaChart").fetch(plot.grouping_val) },
188
+ "c:area3DChart" => ->(plot) { GROUPED.fetch("c:area3DChart").fetch(plot.grouping_val) },
189
+ "c:barChart" => ->(plot) { BAR.fetch(plot.barDir.val).fetch(plot.grouping_val) },
190
+ "c:lineChart" => ->(plot) { LINE.fetch(line_markers?(plot)).fetch(plot.grouping_val) },
191
+ "c:bubbleChart" => ->(plot) { bubble(plot) },
192
+ "c:doughnutChart" => ->(plot) { exploded?(plot) ? :DOUGHNUT_EXPLODED : :DOUGHNUT },
193
+ "c:pieChart" => ->(plot) { exploded?(plot) ? :PIE_EXPLODED : :PIE },
194
+ "c:radarChart" => ->(plot) { radar(plot) },
195
+ "c:scatterChart" => ->(plot) { scatter(plot) }
196
+ }.freeze
197
+
198
+ def name_for(plot)
199
+ rule = RULES.fetch(plot.nsptag) { raise Error, "no chart type for #{plot.nsptag}" }
200
+ rule.call(plot)
201
+ end
202
+
203
+ def line_markers?(plot) = plot.xpath('c:ser/c:marker/c:symbol[@val="none"]').empty?
204
+
205
+ def exploded?(plot) = !plot.xpath("./c:ser/c:explosion").empty?
206
+
207
+ def first_symbol(plot) = plot.xpath("c:ser/c:marker/c:symbol").first&.get("val")
208
+
209
+ def bubble(plot)
210
+ bubble3d = plot.xpath("c:ser/c:bubble3D").first
211
+ bubble3d&.val ? :BUBBLE_THREE_D_EFFECT : :BUBBLE
212
+ end
213
+
214
+ def radar(plot)
215
+ style = plot.xpath("c:radarStyle").first&.get("val")
216
+ return :RADAR if style.nil?
217
+ return :RADAR_FILLED if style == "filled"
218
+
219
+ first_symbol(plot) == "none" ? :RADAR : :RADAR_MARKERS
220
+ end
221
+
222
+ def scatter(plot)
223
+ no_markers = first_symbol(plot) == "none"
224
+ case plot.xpath("c:scatterStyle").first&.get("val")
225
+ when "lineMarker"
226
+ return :XY_SCATTER unless plot.xpath("c:ser/c:spPr/a:ln/a:noFill").empty?
227
+
228
+ no_markers ? :XY_SCATTER_LINES_NO_MARKERS : :XY_SCATTER_LINES
229
+ when "smoothMarker"
230
+ no_markers ? :XY_SCATTER_SMOOTH_NO_MARKERS : :XY_SCATTER_SMOOTH
231
+ else :XY_SCATTER
232
+ end
233
+ end
234
+ end
235
+
236
+ # One series as read back from a chart's cached XML.
237
+ #
238
+ # {.for} returns the subclass matching the plot the series belongs to, so a
239
+ # series offers only what its kind of chart supports: {LineSeriesView#smooth},
240
+ # {BarSeriesView#invert_if_negative}, markers on the kinds that draw them.
241
+ class ChartSeriesView < ElementProxy
242
+ include PatternMatching
243
+
244
+ pattern_keys :name, :values
245
+
246
+ # @return [ChartSeriesView] the subclass for the series' plot
247
+ def self.for(ser, chart)
248
+ klass = {
249
+ "c:areaChart" => AreaSeriesView, "c:area3DChart" => AreaSeriesView, "c:barChart" => BarSeriesView,
250
+ "c:bubbleChart" => BubbleSeriesView, "c:doughnutChart" => PieSeriesView,
251
+ "c:lineChart" => LineSeriesView, "c:pieChart" => PieSeriesView, "c:radarChart" => RadarSeriesView,
252
+ "c:scatterChart" => XySeriesView
253
+ }.fetch(ser.parent.nsptag, ChartSeriesView)
254
+ klass.new(ser, chart)
255
+ end
256
+
257
+ def initialize(ser, chart)
258
+ super(ser)
259
+ @chart = chart
260
+ end
261
+
262
+ # The fill and outline of this series.
263
+ def format = @format ||= ChartFormat.new(@element)
264
+
265
+ # The series' chart-wide index.
266
+ def index = @element.idx.val
267
+
268
+ def name = @element.xpath("./c:tx//c:pt/c:v").first&.text.to_s
269
+
270
+ # Cached values, with nil where the chart records a gap.
271
+ def values = cached(@element.val)
272
+
273
+ def inspect = "#<#{self.class.name} #{name.inspect}>"
274
+
275
+ private
276
+
277
+ def cached(source)
278
+ return [] if source.nil?
279
+
280
+ count = source.xpath(".//c:ptCount/@val").first&.value.to_i
281
+ by_index = source.xpath(".//c:pt").to_h { |pt| [pt.idx, pt.value] }
282
+ Array.new(count) { |i| by_index[i] }
283
+ end
284
+ end
285
+
286
+ # Data labels and per-point formatting, for series plotted by category.
287
+ module CategorySeriesFeatures
288
+ # This series' own data labels, created on first use.
289
+ def data_labels = @data_labels ||= ChartDataLabels.new(@element.get_or_add_dLbls)
290
+
291
+ # The points of this series, one per category.
292
+ def points = ChartPoints.new(@element, @element.cat_ptCount_val)
293
+ end
294
+
295
+ # Markers, for the kinds of series that draw them.
296
+ module MarkerFeatures
297
+ def marker = @marker ||= Marker.new(@element)
298
+ end
299
+
300
+ class AreaSeriesView < ChartSeriesView
301
+ include CategorySeriesFeatures
302
+ end
303
+
304
+ class PieSeriesView < ChartSeriesView
305
+ include CategorySeriesFeatures
306
+ end
307
+
308
+ class BarSeriesView < ChartSeriesView
309
+ include CategorySeriesFeatures
310
+
311
+ # Whether a negative bar is drawn in inverted colours. An absent
312
+ # `c:invertIfNegative` reads as true, the schema default.
313
+ def invert_if_negative? = @element.invertIfNegative.nil? || @element.invertIfNegative.val
314
+
315
+ def invert_if_negative=(value)
316
+ @element.get_or_add_invertIfNegative.val = value ? true : false
317
+ end
318
+ end
319
+
320
+ class LineSeriesView < ChartSeriesView
321
+ include CategorySeriesFeatures
322
+ include MarkerFeatures
323
+
324
+ # Whether the line is drawn as a smooth curve. An absent `c:smooth` reads
325
+ # as true, the schema default.
326
+ def smooth? = @element.smooth.nil? || @element.smooth.val
327
+
328
+ def smooth=(value)
329
+ @element.get_or_add_smooth.val = value ? true : false
330
+ end
331
+ end
332
+
333
+ class RadarSeriesView < ChartSeriesView
334
+ include CategorySeriesFeatures
335
+ include MarkerFeatures
336
+ end
337
+
338
+ # A scatter series, whose values are its y values.
339
+ class XySeriesView < ChartSeriesView
340
+ include MarkerFeatures
341
+
342
+ def values = cached(@element.yVal)
343
+
344
+ def points = ChartPoints.new(@element, [@element.xVal_ptCount_val, @element.yVal_ptCount_val].min)
345
+ end
346
+
347
+ class BubbleSeriesView < XySeriesView
348
+ def points
349
+ counts = [@element.xVal_ptCount_val, @element.yVal_ptCount_val, @element.bubbleSize_ptCount_val]
350
+ ChartPoints.new(@element, counts.min)
351
+ end
352
+ end
353
+
354
+ # Rewrites a chart's series data in place, leaving formatting alone.
355
+ class SeriesRewriter
356
+ def initialize(chart_data)
357
+ @chart_data = chart_data
358
+ end
359
+
360
+ def rewrite(chart_space)
361
+ # A date category's cached serial depends on the chart's date system.
362
+ @date_1904 = chart_space.date1904&.val || false
363
+ plot_area = chart_space.chart.plotArea
364
+ adjust_series_count(plot_area, @chart_data.series.size)
365
+ plot_area.series_elements.zip(@chart_data.series) do |ser, series_data|
366
+ rewrite_series(ser, series_data)
367
+ end
368
+ self
369
+ end
370
+
371
+ private
372
+
373
+ def adjust_series_count(plot_area, wanted)
374
+ difference = wanted - plot_area.series_elements.size
375
+ if difference.positive?
376
+ clone_series(plot_area, difference)
377
+ elsif difference.negative?
378
+ trim_series(plot_area, -difference)
379
+ end
380
+ end
381
+
382
+ # New series are copied from the last one so they pick up its formatting;
383
+ # only their position in the chart is changed.
384
+ def clone_series(plot_area, count)
385
+ last = plot_area.series_elements.last
386
+ raise Error, "cannot add series to a chart that has none" if last.nil?
387
+
388
+ next_index = plot_area.series_elements.size
389
+ count.times do |offset|
390
+ copy = last.parent.build_from_xml(last.node.to_xml)
391
+ copy.get_or_add_idx.val = next_index + offset
392
+ copy.get_or_add_order.val = next_index + offset
393
+ last.node.add_next_sibling(copy.node)
394
+ last = copy
395
+ end
396
+ end
397
+
398
+ # A plot left with no series is removed too; an empty plot element is not
399
+ # valid, and PowerPoint would have nothing to draw for it.
400
+ def trim_series(plot_area, count)
401
+ plot_area.series_elements.last(count).each { |ser| ser.parent.remove(ser) }
402
+ plot_area.plot_elements.each do |plot|
403
+ plot_area.remove(plot) if plot.ser_list.empty?
404
+ end
405
+ end
406
+
407
+ def rewrite_series(ser, series_data)
408
+ ser.remove_tx
409
+ ser.remove_cat
410
+ ser.remove_val
411
+ tx, cat, val = SeriesFragments.new(@chart_data, series_data, date_1904: @date_1904).fragments
412
+ ser.insert_tx(ser.build_from_xml(tx))
413
+ ser.insert_cat(ser.build_from_xml(cat))
414
+ ser.insert_val(ser.build_from_xml(val))
415
+ end
416
+ end
417
+
418
+ # Builds the `c:tx`, `c:cat` and `c:val` of one series as standalone XML.
419
+ #
420
+ # It borrows the writer that produces those fragments when a chart is
421
+ # created, so a rewritten series and a freshly written one cannot drift
422
+ # apart.
423
+ class SeriesFragments < ChartXmlWriter::Base
424
+ def initialize(chart_data, series, date_1904: false)
425
+ super(nil, chart_data)
426
+ @series = series
427
+ @date_1904 = date_1904
428
+ end
429
+
430
+ # @return [Array(String, String, String)] the tx, cat and val fragments
431
+ def fragments
432
+ [namespaced(tx_xml(@series)), namespaced(cat_xml(@series)), namespaced(val_xml(@series))]
433
+ end
434
+
435
+ private
436
+
437
+ # The fragments are written for a document that already declares the chart
438
+ # namespaces; parsed on their own they need their own declaration.
439
+ def namespaced(fragment)
440
+ fragment.sub(/\A<(c:\w+)/, %(<\\1 #{Oxml::Ns.nsdecls("c", "a", "r")}))
441
+ end
442
+ end
443
+ end