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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/NOTICE +48 -0
- data/README.md +272 -0
- data/lib/ruby_pptx/action.rb +120 -0
- data/lib/ruby_pptx/autoshape_spec.rb +337 -0
- data/lib/ruby_pptx/builder.rb +228 -0
- data/lib/ruby_pptx/chart/categories.rb +208 -0
- data/lib/ruby_pptx/chart/chart.rb +443 -0
- data/lib/ruby_pptx/chart/combo.rb +230 -0
- data/lib/ruby_pptx/chart/data.rb +147 -0
- data/lib/ruby_pptx/chart/format.rb +647 -0
- data/lib/ruby_pptx/chart/workbook_writer.rb +212 -0
- data/lib/ruby_pptx/chart/xml_writer.rb +953 -0
- data/lib/ruby_pptx/chart/xy_data.rb +182 -0
- data/lib/ruby_pptx/core_ext.rb +12 -0
- data/lib/ruby_pptx/dml/color.rb +155 -0
- data/lib/ruby_pptx/dml/effect.rb +33 -0
- data/lib/ruby_pptx/dml/fill.rb +234 -0
- data/lib/ruby_pptx/element_proxy.rb +53 -0
- data/lib/ruby_pptx/enum/action.rb +46 -0
- data/lib/ruby_pptx/enum/base.rb +139 -0
- data/lib/ruby_pptx/enum/chart.rb +278 -0
- data/lib/ruby_pptx/enum/dml.rb +237 -0
- data/lib/ruby_pptx/enum/lang.rb +441 -0
- data/lib/ruby_pptx/enum/prog_id.rb +38 -0
- data/lib/ruby_pptx/enum/shapes.rb +514 -0
- data/lib/ruby_pptx/enum/text.rb +108 -0
- data/lib/ruby_pptx/errors.rb +15 -0
- data/lib/ruby_pptx/geometry.rb +89 -0
- data/lib/ruby_pptx/image.rb +347 -0
- data/lib/ruby_pptx/length.rb +120 -0
- data/lib/ruby_pptx/media.rb +64 -0
- data/lib/ruby_pptx/numeric_lengths.rb +30 -0
- data/lib/ruby_pptx/opc/constants.rb +205 -0
- data/lib/ruby_pptx/opc/oxml.rb +99 -0
- data/lib/ruby_pptx/opc/pack_uri.rb +146 -0
- data/lib/ruby_pptx/opc/package.rb +482 -0
- data/lib/ruby_pptx/opc/serialized.rb +229 -0
- data/lib/ruby_pptx/opc/spec.rb +37 -0
- data/lib/ruby_pptx/oxml/action.rb +43 -0
- data/lib/ruby_pptx/oxml/chart.rb +785 -0
- data/lib/ruby_pptx/oxml/content_model.rb +186 -0
- data/lib/ruby_pptx/oxml/core_properties.rb +145 -0
- data/lib/ruby_pptx/oxml/dml/color.rb +115 -0
- data/lib/ruby_pptx/oxml/dml/fill.rb +137 -0
- data/lib/ruby_pptx/oxml/element.rb +329 -0
- data/lib/ruby_pptx/oxml/ns.rb +115 -0
- data/lib/ruby_pptx/oxml/presentation.rb +110 -0
- data/lib/ruby_pptx/oxml/section.rb +65 -0
- data/lib/ruby_pptx/oxml/shapes/autoshape.rb +310 -0
- data/lib/ruby_pptx/oxml/shapes/groupshape.rb +217 -0
- data/lib/ruby_pptx/oxml/shapes/other.rb +405 -0
- data/lib/ruby_pptx/oxml/shapes/shared.rb +292 -0
- data/lib/ruby_pptx/oxml/simple_types.rb +563 -0
- data/lib/ruby_pptx/oxml/slide.rb +290 -0
- data/lib/ruby_pptx/oxml/table.rb +253 -0
- data/lib/ruby_pptx/oxml/text.rb +324 -0
- data/lib/ruby_pptx/oxml/theme.rb +93 -0
- data/lib/ruby_pptx/package.rb +139 -0
- data/lib/ruby_pptx/parts/chart.rb +75 -0
- data/lib/ruby_pptx/parts/core_properties.rb +40 -0
- data/lib/ruby_pptx/parts/embedded_package.rb +43 -0
- data/lib/ruby_pptx/parts/image.rb +55 -0
- data/lib/ruby_pptx/parts/media.rb +17 -0
- data/lib/ruby_pptx/parts/presentation.rb +79 -0
- data/lib/ruby_pptx/parts/slide.rb +221 -0
- data/lib/ruby_pptx/parts/theme.rb +26 -0
- data/lib/ruby_pptx/pattern_matching.rb +50 -0
- data/lib/ruby_pptx/presentation.rb +128 -0
- data/lib/ruby_pptx/refinements.rb +21 -0
- data/lib/ruby_pptx/section.rb +130 -0
- data/lib/ruby_pptx/shapes/adjustments.rb +83 -0
- data/lib/ruby_pptx/shapes/authoring.rb +110 -0
- data/lib/ruby_pptx/shapes/base.rb +138 -0
- data/lib/ruby_pptx/shapes/freeform.rb +141 -0
- data/lib/ruby_pptx/shapes/placeholder.rb +184 -0
- data/lib/ruby_pptx/shapes/shape.rb +337 -0
- data/lib/ruby_pptx/shapes/shape_tree.rb +634 -0
- data/lib/ruby_pptx/sliceable.rb +34 -0
- data/lib/ruby_pptx/slide.rb +430 -0
- data/lib/ruby_pptx/table.rb +316 -0
- data/lib/ruby_pptx/table_paging.rb +91 -0
- data/lib/ruby_pptx/templates/default.pptx +0 -0
- data/lib/ruby_pptx/templates/docx-icon.emf +0 -0
- data/lib/ruby_pptx/templates/generic-icon.emf +0 -0
- data/lib/ruby_pptx/templates/media-speaker.png +0 -0
- data/lib/ruby_pptx/templates/notes.xml +23 -0
- data/lib/ruby_pptx/templates/notesMaster.xml +352 -0
- data/lib/ruby_pptx/templates/pptx-icon.emf +0 -0
- data/lib/ruby_pptx/templates/slideMaster.xml +277 -0
- data/lib/ruby_pptx/templates/theme.xml +321 -0
- data/lib/ruby_pptx/templates/xlsx-icon.emf +0 -0
- data/lib/ruby_pptx/text/fitter.rb +72 -0
- data/lib/ruby_pptx/text/font_metrics.rb +221 -0
- data/lib/ruby_pptx/text/text.rb +393 -0
- data/lib/ruby_pptx/theme.rb +98 -0
- data/lib/ruby_pptx/version.rb +5 -0
- data/lib/ruby_pptx.rb +90 -0
- 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
|