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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/chart/xml_writer"
4
+
5
+ module Pptx
6
+ # Describes a chart that draws more than one plot over the same categories --
7
+ # a bar chart with a line over it, most often.
8
+ #
9
+ # python-pptx can read such a chart but only ever writes one plot, so this is
10
+ # new ground rather than a port.
11
+ #
12
+ # slide.shapes.add_combo_chart(data, at: [x, y], size: [w, h]) do |combo|
13
+ # combo.plot :column_clustered, series: "Revenue"
14
+ # combo.plot :line, series: "Margin", secondary_axis: true
15
+ # end
16
+ #
17
+ # All the series live in one {ChartData}, because they share a worksheet;
18
+ # each plot names the ones it draws.
19
+ class ComboChartBuilder
20
+ # What a plot draws and how.
21
+ PlotSpec = Data.define(:chart_type, :series, :secondary_axis)
22
+
23
+ # The families that can share a category axis. A pie or a scatter cannot,
24
+ # so they are not combinable.
25
+ COMBINABLE = %i[bar line area].freeze
26
+
27
+ attr_reader :chart_data, :specs
28
+
29
+ def initialize(chart_data)
30
+ @chart_data = chart_data
31
+ @specs = []
32
+ end
33
+
34
+ # Add a plot drawing +series+.
35
+ #
36
+ # @param series [String, Integer, Array] series names or indices; all of
37
+ # them when omitted
38
+ # @param secondary_axis [Boolean] draw against a second value axis on the
39
+ # right, for values on a different scale
40
+ def plot(chart_type, series: nil, secondary_axis: false)
41
+ member = Enum::XL_CHART_TYPE.fetch(chart_type)
42
+ family = ChartXmlWriter.family_of(member)
43
+ unless COMBINABLE.include?(family)
44
+ raise Error,
45
+ "a #{member.name} chart cannot share a category axis, so it " \
46
+ "cannot be part of a combo chart"
47
+ end
48
+
49
+ @specs << PlotSpec.new(chart_type: member, series: resolve(series),
50
+ secondary_axis: secondary_axis)
51
+ self
52
+ end
53
+
54
+ # @raise [Error] when the combination could not be drawn
55
+ def validate!
56
+ raise Error, "a combo chart needs at least two plots" if @specs.size < 2
57
+
58
+ drawn = @specs.flat_map(&:series)
59
+ duplicated = drawn.map(&:index).tally.select { |_, count| count > 1 }.keys
60
+ raise Error, "series #{duplicated.inspect} appear in more than one plot" unless duplicated.empty?
61
+
62
+ self
63
+ end
64
+
65
+ def secondary_axis? = @specs.any?(&:secondary_axis)
66
+
67
+ private
68
+
69
+ # Accepts a name, an index, or a list of either; nil means every series.
70
+ def resolve(selector)
71
+ return @chart_data.series if selector.nil?
72
+
73
+ Array(selector).map do |key|
74
+ case key
75
+ when Integer then @chart_data.series[key]
76
+ when String then @chart_data.series.find { |s| s.name == key }
77
+ else key
78
+ end || raise(NotFoundError, "no series #{key.inspect} in this chart data")
79
+ end
80
+ end
81
+ end
82
+
83
+ # Writes the `c:chartSpace` for a combo chart.
84
+ #
85
+ # The plot area holds one element per plot followed by the axes, which is the
86
+ # order the schema requires. Every plot names exactly two axis ids and each
87
+ # must be backed by a real axis element, so a secondary value axis brings a
88
+ # category axis with it -- a hidden one, since the categories are already
89
+ # drawn by the primary pair.
90
+ class ComboChartWriter < ChartXmlWriter::Base
91
+ CAT_AX_ID = "-2068027336"
92
+ VAL_AX_ID = "-2113994440"
93
+ SECONDARY_CAT_AX_ID = "-2068027337"
94
+ SECONDARY_VAL_AX_ID = "-2113994441"
95
+
96
+ def initialize(builder)
97
+ super(nil, builder.chart_data)
98
+ @builder = builder
99
+ end
100
+
101
+ def xml
102
+ <<~XML
103
+ #{ChartXmlWriter::Base::DECLARATION}
104
+ <c:chartSpace #{ChartXmlWriter::Base::CHART_SPACE_NS}>
105
+ <c:date1904 val="0"/>
106
+ <c:chart>
107
+ <c:autoTitleDeleted val="0"/>
108
+ <c:plotArea>
109
+ <c:layout/>
110
+ #{indent(plot_elements, 6).chomp}
111
+ #{indent(axes_xml, 6).chomp}
112
+ </c:plotArea>
113
+ #{indent(ChartXmlWriter::Base::LEGEND, 4).chomp}
114
+ <c:plotVisOnly val="1"/>
115
+ <c:dispBlanksAs val="gap"/>
116
+ <c:showDLblsOverMax val="0"/>
117
+ </c:chart>
118
+ #{indent(ChartXmlWriter::Base::TEXT_PROPERTIES, 2).chomp}
119
+ </c:chartSpace>
120
+ XML
121
+ end
122
+
123
+ private
124
+
125
+ def plot_elements = @builder.specs.map { |spec| plot_element(spec) }.join
126
+
127
+ def plot_element(spec)
128
+ family = ChartXmlWriter.family_of(spec.chart_type)
129
+ value_ax = spec.secondary_axis ? SECONDARY_VAL_AX_ID : VAL_AX_ID
130
+ cat_ax = spec.secondary_axis ? SECONDARY_CAT_AX_ID : CAT_AX_ID
131
+ body = send(:"#{family}_plot_body", spec)
132
+
133
+ <<~XML
134
+ <#{plot_tag(family)}>
135
+ #{indent(body, 2).chomp}
136
+ #{indent(spec.series.map { |s| series_xml(s) }.join, 2).chomp}
137
+ #{indent(plot_tail(family, spec), 2).chomp}
138
+ <c:axId val="#{cat_ax}"/>
139
+ <c:axId val="#{value_ax}"/>
140
+ </#{plot_tag(family)}>
141
+ XML
142
+ end
143
+
144
+ def plot_tag(family) = { bar: "c:barChart", line: "c:lineChart", area: "c:areaChart" }[family]
145
+
146
+ def bar_plot_body(spec)
147
+ direction = ChartXmlWriter::BarChartWriter::BAR_TYPES.include?(spec.chart_type.name) ? "bar" : "col"
148
+ grouping = ChartXmlWriter::BarChartWriter::GROUPINGS.fetch(spec.chart_type.name)
149
+ %(<c:barDir val="#{direction}"/>\n<c:grouping val="#{grouping}"/>\n)
150
+ end
151
+
152
+ def line_plot_body(spec)
153
+ grouping = ChartXmlWriter::LineChartWriter::GROUPINGS.fetch(spec.chart_type.name)
154
+ %(<c:grouping val="#{grouping}"/>\n<c:varyColors val="0"/>\n)
155
+ end
156
+
157
+ def area_plot_body(spec)
158
+ grouping = ChartXmlWriter::AreaChartWriter::GROUPINGS.fetch(spec.chart_type.name)
159
+ %(<c:grouping val="#{grouping}"/>\n<c:varyColors val="0"/>\n)
160
+ end
161
+
162
+ def plot_tail(family, spec)
163
+ case family
164
+ when :bar
165
+ stacked = ChartXmlWriter::BarChartWriter::STACKED_TYPES.include?(spec.chart_type.name)
166
+ stacked ? %(<c:overlap val="100"/>\n) : ""
167
+ when :line
168
+ %(<c:marker val="1"/>\n<c:smooth val="0"/>\n)
169
+ else ""
170
+ end
171
+ end
172
+
173
+ def axes_xml
174
+ xml = +""
175
+ xml << category_axis_xml(CAT_AX_ID, VAL_AX_ID, hidden: false)
176
+ xml << value_axis_xml(VAL_AX_ID, CAT_AX_ID, position: "l", crosses: "autoZero")
177
+ return xml unless @builder.secondary_axis?
178
+
179
+ # The secondary pair: a value axis on the right, and the category axis
180
+ # the schema demands to go with it, hidden because the primary pair
181
+ # already draws the categories.
182
+ xml << category_axis_xml(SECONDARY_CAT_AX_ID, SECONDARY_VAL_AX_ID, hidden: true)
183
+ xml << value_axis_xml(SECONDARY_VAL_AX_ID, SECONDARY_CAT_AX_ID,
184
+ position: "r", crosses: "max")
185
+ xml
186
+ end
187
+
188
+ def category_axis_xml(ax_id, cross_ax_id, hidden:)
189
+ <<~XML
190
+ <c:catAx>
191
+ <c:axId val="#{ax_id}"/>
192
+ <c:scaling>
193
+ <c:orientation val="minMax"/>
194
+ </c:scaling>
195
+ <c:delete val="#{hidden ? 1 : 0}"/>
196
+ <c:axPos val="b"/>
197
+ <c:majorTickMark val="out"/>
198
+ <c:minorTickMark val="none"/>
199
+ <c:tickLblPos val="nextTo"/>
200
+ <c:crossAx val="#{cross_ax_id}"/>
201
+ <c:crosses val="autoZero"/>
202
+ <c:auto val="1"/>
203
+ <c:lblAlgn val="ctr"/>
204
+ <c:lblOffset val="100"/>
205
+ <c:noMultiLvlLbl val="0"/>
206
+ </c:catAx>
207
+ XML
208
+ end
209
+
210
+ def value_axis_xml(ax_id, cross_ax_id, position:, crosses:)
211
+ <<~XML
212
+ <c:valAx>
213
+ <c:axId val="#{ax_id}"/>
214
+ <c:scaling>
215
+ <c:orientation val="minMax"/>
216
+ </c:scaling>
217
+ <c:delete val="0"/>
218
+ <c:axPos val="#{position}"/>
219
+ #{"<c:majorGridlines/>" if position == "l"}
220
+ <c:numFmt formatCode="General" sourceLinked="1"/>
221
+ <c:majorTickMark val="out"/>
222
+ <c:minorTickMark val="none"/>
223
+ <c:tickLblPos val="nextTo"/>
224
+ <c:crossAx val="#{cross_ax_id}"/>
225
+ <c:crosses val="#{crosses}"/>
226
+ </c:valAx>
227
+ XML
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/errors"
4
+ require "ruby_pptx/chart/categories"
5
+
6
+ module Pptx
7
+ # The categories and series a chart depicts.
8
+ #
9
+ # data = Pptx::ChartData.new
10
+ # data.categories = ["East", "West", "Midwest"]
11
+ # data.add_series("Q1", [1.2, 2.0, 3.5])
12
+ # data.add_series("Q2", [4.1, 5.0, 6.2])
13
+ #
14
+ # Categories can be grouped, which draws a multi-level axis, and can be
15
+ # dates, which draws a date axis; see {ChartDataCategories}.
16
+ #
17
+ # The values also become an embedded Excel workbook, which is what PowerPoint
18
+ # opens when the user chooses "Edit Data". The worksheet references in the
19
+ # chart XML are derived from the same layout, so the two always agree.
20
+ class ChartData
21
+ include Enumerable
22
+
23
+ # The row the first data value sits on; row 1 holds the series names.
24
+ FIRST_DATA_ROW = 2
25
+ # Category labels fill the first columns, one per level; series follow.
26
+ WORKSHEET_NAME = "Sheet1"
27
+ DEFAULT_NUMBER_FORMAT = "General"
28
+
29
+ attr_reader :series, :number_format, :categories
30
+
31
+ def initialize(number_format: DEFAULT_NUMBER_FORMAT)
32
+ @categories = ChartDataCategories.new
33
+ @series = []
34
+ @number_format = number_format
35
+ end
36
+
37
+ # Replace the categories: a list of labels, or a Hash of groups whose
38
+ # values are their sub-categories.
39
+ def categories=(values)
40
+ @categories = values.is_a?(ChartDataCategories) ? values : ChartDataCategories.from(values)
41
+ end
42
+
43
+ # Add a series of values, one per category.
44
+ #
45
+ # @return [ChartSeries]
46
+ def add_series(name, values, number_format: nil)
47
+ series = ChartSeries.new(self, @series.size, name, values.to_a,
48
+ number_format || @number_format)
49
+ @series << series
50
+ series
51
+ end
52
+
53
+ def each(&) = @series.each(&)
54
+
55
+ def size = @series.size
56
+
57
+ # The number of categories plotted: the leaves, when they are grouped.
58
+ def category_count = @categories.leaf_count
59
+
60
+ # True when the categories are numbers or dates, which makes the category
61
+ # cache numeric rather than textual. Decided by the first category, as
62
+ # python-pptx decides it.
63
+ def numeric_categories? = @categories.numeric?
64
+
65
+ # @api private
66
+ # The category block: one column per level, one row per leaf.
67
+ def categories_ref
68
+ raise Error, "chart data contains no categories" if @categories.depth.zero?
69
+
70
+ right = ChartData.column_reference(@categories.depth)
71
+ "#{WORKSHEET_NAME}!$A$#{FIRST_DATA_ROW}:$#{right}$#{FIRST_DATA_ROW + category_count - 1}"
72
+ end
73
+
74
+ # @api private
75
+ def series_name_ref(series) = "#{WORKSHEET_NAME}!$#{column_letter(series)}$1"
76
+
77
+ # @api private
78
+ # As long as the series itself, which python-pptx also measures by the
79
+ # series rather than by the categories.
80
+ def series_values_ref(series)
81
+ letter = column_letter(series)
82
+ last_row = FIRST_DATA_ROW + series.size - 1
83
+ "#{WORKSHEET_NAME}!$#{letter}$#{FIRST_DATA_ROW}:$#{letter}$#{last_row}"
84
+ end
85
+
86
+ # @api private
87
+ # The Excel column letter for a series: after the category columns.
88
+ def column_letter(series) = ChartData.column_reference(1 + @categories.depth + series.index)
89
+
90
+ # Excel's bijective base-26 column names: 1 => "A", 27 => "AA".
91
+ def self.column_reference(column_number)
92
+ raise ArgumentError, "column number must be positive" unless column_number.positive?
93
+
94
+ letters = +""
95
+ remaining = column_number
96
+ while remaining.positive?
97
+ remaining, remainder = (remaining - 1).divmod(26)
98
+ letters.prepend((remainder + 65).chr)
99
+ end
100
+ letters
101
+ end
102
+
103
+ # @api private
104
+ # Bytes of the embedded Excel workbook holding this data.
105
+ def xlsx_blob = ChartWorkbookWriter.new(self).blob
106
+
107
+ def inspect = "#<Pptx::ChartData categories=#{category_count} series=#{size}>"
108
+ end
109
+
110
+ # One series of a chart: a name and one value per category.
111
+ class ChartSeries
112
+ include Enumerable
113
+
114
+ attr_reader :chart_data, :index, :name, :values, :number_format
115
+
116
+ def initialize(chart_data, index, name, values, number_format)
117
+ @chart_data = chart_data
118
+ @index = index
119
+ @name = name
120
+ @values = values
121
+ @number_format = number_format
122
+ end
123
+
124
+ def each(&) = @values.each(&)
125
+
126
+ def size = @values.size
127
+
128
+ # Append one value, for building a series up point by point.
129
+ #
130
+ # python-pptx's add_data_point also takes a per-point number format, but
131
+ # nothing it writes ever reads it; the series' format is the one used.
132
+ def <<(value)
133
+ @values << value
134
+ self
135
+ end
136
+
137
+ def categories = @chart_data.categories
138
+
139
+ def name_ref = @chart_data.series_name_ref(self)
140
+
141
+ def values_ref = @chart_data.series_values_ref(self)
142
+
143
+ def categories_ref = @chart_data.categories_ref
144
+
145
+ def inspect = "#<Pptx::ChartSeries #{@name.inspect} #{size} values>"
146
+ end
147
+ end