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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/chart/data"
4
+
5
+ module Pptx
6
+ # Data for a scatter chart: series of (x, y) points.
7
+ #
8
+ # data = Pptx::XyChartData.new
9
+ # data.add_series("Alpha") do |s|
10
+ # s.add_point(1, 10)
11
+ # s.add_point(2, 20)
12
+ # end
13
+ #
14
+ # Unlike a category chart, each series has its own x values, so the
15
+ # worksheet stacks the series in blocks rather than putting them side by
16
+ # side in shared columns.
17
+ class XyChartData
18
+ include Enumerable
19
+
20
+ WORKSHEET_NAME = "Sheet1"
21
+ DEFAULT_NUMBER_FORMAT = "General"
22
+ # Each series block is followed by a blank row, and preceded by its title
23
+ # row -- two rows of overhead per series.
24
+ ROWS_PER_SERIES_OVERHEAD = 2
25
+
26
+ attr_reader :series, :number_format
27
+
28
+ def initialize(number_format: DEFAULT_NUMBER_FORMAT)
29
+ @series = []
30
+ @number_format = number_format
31
+ end
32
+
33
+ # Add a series. Points may be given up front, added in a block, or both.
34
+ #
35
+ # @param points [Array<Array>] each [x, y]
36
+ # @return [XySeries]
37
+ def add_series(name, points: [], number_format: nil)
38
+ series = series_class.new(self, @series.size, name, number_format || @number_format)
39
+ @series << series
40
+ points.each { |point| series.add_point(*point) }
41
+ yield series if block_given?
42
+ series
43
+ end
44
+
45
+ def each(&) = @series.each(&)
46
+
47
+ def size = @series.size
48
+
49
+ # The row a series' title sits on, counting the blocks before it.
50
+ #
51
+ # @api private
52
+ def title_row(series)
53
+ preceding_points = @series[0...series.index].sum(&:size)
54
+ (series.index * ROWS_PER_SERIES_OVERHEAD) + preceding_points + 1
55
+ end
56
+
57
+ # @api private
58
+ def series_name_ref(series) = "#{WORKSHEET_NAME}!$B$#{title_row(series)}"
59
+
60
+ # @api private
61
+ def x_values_ref(series) = column_ref(series, "A")
62
+
63
+ # @api private
64
+ def y_values_ref(series) = column_ref(series, "B")
65
+
66
+ # @api private
67
+ def column_ref(series, column)
68
+ first = title_row(series) + 1
69
+ last = first + [series.size, 1].max - 1
70
+ "#{WORKSHEET_NAME}!$#{column}$#{first}:$#{column}$#{last}"
71
+ end
72
+
73
+ # @api private
74
+ def xlsx_blob = XyWorkbookWriter.new(self).blob
75
+
76
+ # @api private
77
+ # Whether the worksheet carries a third column of bubble sizes.
78
+ def bubble? = false
79
+
80
+ def inspect = "#<#{self.class.name} series=#{size}>"
81
+
82
+ private
83
+
84
+ def series_class = XySeries
85
+ end
86
+
87
+ # Data for a bubble chart: series of (x, y, size) points.
88
+ class BubbleChartData < XyChartData
89
+ # @api private
90
+ def bubble_sizes_ref(series) = column_ref(series, "C")
91
+
92
+ # @api private
93
+ def bubble? = true
94
+
95
+ private
96
+
97
+ def series_class = BubbleSeries
98
+ end
99
+
100
+ # One series of a scatter chart.
101
+ class XySeries
102
+ include Enumerable
103
+
104
+ attr_reader :chart_data, :index, :name, :number_format, :points
105
+
106
+ def initialize(chart_data, index, name, number_format)
107
+ @chart_data = chart_data
108
+ @index = index
109
+ @name = name
110
+ @number_format = number_format
111
+ @points = []
112
+ end
113
+
114
+ # @return [self] so points can be chained
115
+ def add_point(x, y)
116
+ @points << [x, y]
117
+ self
118
+ end
119
+
120
+ def each(&) = @points.each(&)
121
+
122
+ def size = @points.size
123
+
124
+ def x_values = @points.map(&:first)
125
+
126
+ def y_values = @points.map { |point| point[1] }
127
+
128
+ def name_ref = @chart_data.series_name_ref(self)
129
+ def x_values_ref = @chart_data.x_values_ref(self)
130
+ def y_values_ref = @chart_data.y_values_ref(self)
131
+
132
+ def inspect = "#<#{self.class.name} #{@name.inspect} #{size} points>"
133
+ end
134
+
135
+ # One series of a bubble chart, whose points carry a size as well.
136
+ class BubbleSeries < XySeries
137
+ def add_point(x, y, size)
138
+ @points << [x, y, size]
139
+ self
140
+ end
141
+
142
+ def bubble_sizes = @points.map { |point| point[2] }
143
+
144
+ def bubble_sizes_ref = @chart_data.bubble_sizes_ref(self)
145
+ end
146
+
147
+ # Writes the workbook behind a scatter or bubble chart.
148
+ #
149
+ # The layout differs from a category chart: each series gets its own block of
150
+ # rows, since the x values belong to the series rather than being shared.
151
+ class XyWorkbookWriter < ChartWorkbookWriter
152
+ private
153
+
154
+ def rows_xml
155
+ @chart_data.series.flat_map { |series| series_rows(series) }.join
156
+ end
157
+
158
+ def series_rows(series)
159
+ title_row = @chart_data.title_row(series)
160
+ rows = [title_row_xml(series, title_row)]
161
+ series.points.each_with_index do |point, offset|
162
+ rows << point_row_xml(point, title_row + 1 + offset)
163
+ end
164
+ rows
165
+ end
166
+
167
+ # Column A is left empty on the title row; the series name sits in B, and
168
+ # a bubble chart labels its third column.
169
+ def title_row_xml(series, row)
170
+ cells = [string_cell("B#{row}", series.name)]
171
+ cells << string_cell("C#{row}", "Size") if @chart_data.bubble?
172
+ %(<row r="#{row}">#{cells.join}</row>)
173
+ end
174
+
175
+ def point_row_xml(point, row)
176
+ cells = point.each_with_index.map do |value, column|
177
+ number_cell("#{(65 + column).chr}#{row}", value)
178
+ end
179
+ %(<row r="#{row}">#{cells.join}</row>)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/numeric_lengths"
4
+
5
+ # Opt-in numeric sugar: `require "ruby_pptx/core_ext"` to write `1.inch` or `2.5.cm`
6
+ # instead of `Pptx.inches(1)`. Kept out of the default require so the gem does
7
+ # not monkey-patch Numeric behind your back.
8
+ #
9
+ # This patches Numeric for the whole process. `require "ruby_pptx/refinements"` and
10
+ # `using Pptx::Lengths` does the same thing scoped to one file, and is the
11
+ # better choice inside a library.
12
+ Numeric.include(Pptx::NumericLengths)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/pattern_matching"
4
+
5
+ require "ruby_pptx/element_proxy"
6
+ require "ruby_pptx/oxml/dml/color"
7
+ require "ruby_pptx/enum/dml"
8
+
9
+ module Pptx
10
+ # An RGB colour.
11
+ #
12
+ # Pptx::RGBColor.new(60, 47, 128)
13
+ # Pptx::RGBColor["3C2F80"]
14
+ class RGBColor
15
+ include PatternMatching
16
+
17
+ pattern_keys :r, :g, :b
18
+
19
+ attr_reader :r, :g, :b
20
+
21
+ # Array patterns too: `in [r, g, b]`.
22
+ def deconstruct = to_a
23
+
24
+ # Parse a hex string such as "3C2F80".
25
+ def self.from_string(hex)
26
+ unless /\A\h{6}\z/.match?(hex.to_s)
27
+ raise ArgumentError, "expected a six-digit hex string, got #{hex.inspect}"
28
+ end
29
+
30
+ new(hex[0, 2].to_i(16), hex[2, 2].to_i(16), hex[4, 2].to_i(16))
31
+ end
32
+
33
+ class << self
34
+ alias [] from_string
35
+ end
36
+
37
+ def initialize(r, g, b)
38
+ [r, g, b].each do |component|
39
+ unless component.is_a?(Integer) && component.between?(0, 255)
40
+ raise ArgumentError, "RGBColor takes three integers 0-255, got #{[r, g, b].inspect}"
41
+ end
42
+ end
43
+ @r = r
44
+ @g = g
45
+ @b = b
46
+ freeze
47
+ end
48
+
49
+ def to_a = [@r, @g, @b]
50
+
51
+ # The uppercase hex form OOXML uses, e.g. "3C2F80".
52
+ def to_s = format("%02X%02X%02X", @r, @g, @b)
53
+
54
+ def ==(other) = other.is_a?(RGBColor) && other.to_a == to_a
55
+ alias eql? ==
56
+
57
+ def hash = to_a.hash
58
+
59
+ def inspect = "#<Pptx::RGBColor #{self}>"
60
+ end
61
+
62
+ # The colour of a font, fill or line.
63
+ #
64
+ # A colour may be an explicit RGB value, a reference to a theme colour, or
65
+ # absent -- in which case it is inherited and {#type} is nil.
66
+ class ColorFormat
67
+ # @param color_choice_parent [Pptx::Oxml::Element] the element holding the
68
+ # colour choice, e.g. `a:solidFill`
69
+ def self.from_color_choice_parent(color_choice_parent) = new(color_choice_parent)
70
+
71
+ def initialize(color_choice_parent)
72
+ @parent = color_choice_parent
73
+ end
74
+
75
+ # @return [Pptx::Enum::MSO_COLOR_TYPE, nil] nil when no colour is set here
76
+ def type
77
+ element = color_element
78
+ return nil if element.nil?
79
+
80
+ case element.nsptag
81
+ when "a:srgbClr" then Enum::MSO_COLOR_TYPE::RGB
82
+ when "a:schemeClr" then Enum::MSO_COLOR_TYPE::SCHEME
83
+ when "a:hslClr" then Enum::MSO_COLOR_TYPE::HSL
84
+ when "a:prstClr" then Enum::MSO_COLOR_TYPE::PRESET
85
+ when "a:scrgbClr" then Enum::MSO_COLOR_TYPE::SCRGB
86
+ when "a:sysClr" then Enum::MSO_COLOR_TYPE::SYSTEM
87
+ end
88
+ end
89
+
90
+ # @return [RGBColor, nil] nil unless an explicit RGB colour is set
91
+ def rgb
92
+ element = color_element
93
+ return nil unless element&.nsptag == "a:srgbClr"
94
+
95
+ RGBColor.from_string(element.val)
96
+ end
97
+
98
+ def rgb=(value)
99
+ value = RGBColor.from_string(value) if value.is_a?(String)
100
+ raise TypeError, "expected an RGBColor, got #{value.class}" unless value.is_a?(RGBColor)
101
+
102
+ @parent.get_or_change_to_srgbClr.val = value.to_s
103
+ end
104
+
105
+ # @return [Pptx::Enum::MSO_THEME_COLOR, nil] nil unless a theme colour is set
106
+ def theme_color
107
+ element = color_element
108
+ return nil unless element&.nsptag == "a:schemeClr"
109
+
110
+ element.val
111
+ end
112
+
113
+ def theme_color=(value)
114
+ @parent.get_or_change_to_schemeClr.val = Enum::MSO_THEME_COLOR.fetch(value)
115
+ end
116
+
117
+ # A luminance adjustment between -1.0 and 1.0: negative is darker (a
118
+ # shade), positive is lighter (a tint).
119
+ def brightness
120
+ element = color_element
121
+ return 0.0 if element.nil?
122
+
123
+ lum_off = element.lumOff
124
+ return lum_off.val if lum_off
125
+
126
+ lum_mod = element.lumMod
127
+ return lum_mod.val - 1.0 if lum_mod
128
+
129
+ 0.0
130
+ end
131
+
132
+ def brightness=(value)
133
+ unless value.is_a?(Numeric) && value.between?(-1.0, 1.0)
134
+ raise ArgumentError, "brightness must be a number between -1.0 and 1.0, got #{value.inspect}"
135
+ end
136
+
137
+ element = color_element
138
+ raise Error, "cannot set brightness before a colour; set rgb or theme_color first" if element.nil?
139
+
140
+ element.clear_lum
141
+ if value.positive?
142
+ element.add_lumMod(1.0 - value)
143
+ element.add_lumOff(value)
144
+ elsif value.negative?
145
+ element.add_lumMod(1.0 + value)
146
+ end
147
+ end
148
+
149
+ def inspect = "#<Pptx::ColorFormat type=#{type&.name.inspect} rgb=#{rgb&.to_s.inspect}>"
150
+
151
+ private
152
+
153
+ def color_element = @parent.eg_colorChoice
154
+ end
155
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pptx
4
+ # The shadow of a shape.
5
+ #
6
+ # Only inheritance can be controlled so far, as in python-pptx: a shape
7
+ # either takes its shadow from the theme and style hierarchy, or has its
8
+ # effects switched off explicitly.
9
+ #
10
+ # shape.shadow.inherit = false # no shadow, whatever the theme says
11
+ class ShadowFormat
12
+ # @param shape_properties [Pptx::Oxml::Element] `p:spPr` or `p:grpSpPr`,
13
+ # which both carry the `a:effectLst`
14
+ def initialize(shape_properties)
15
+ @element = shape_properties
16
+ end
17
+
18
+ # True when the shape takes its shadow from the style hierarchy.
19
+ def inherit? = @element.effectLst.nil?
20
+
21
+ alias inherit inherit?
22
+
23
+ # Restore inheritance with true; with false, break it so no effects show.
24
+ #
25
+ # Inheritance is all or nothing: restoring it removes every explicit
26
+ # effect on the shape -- glow and reflection too, not only the shadow.
27
+ def inherit=(value)
28
+ value ? @element.remove_effectLst : @element.get_or_add_effectLst
29
+ end
30
+
31
+ def inspect = "#<Pptx::ShadowFormat inherit=#{inherit?}>"
32
+ end
33
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/dml/color"
4
+ require "ruby_pptx/oxml/dml/fill"
5
+ require "ruby_pptx/enum/dml"
6
+ require "ruby_pptx/element_proxy"
7
+ require "ruby_pptx/sliceable"
8
+
9
+ module Pptx
10
+ # The fill of a shape, text run, line or slide background.
11
+ #
12
+ # A fill starts out inherited -- {#type} is nil and nothing is written.
13
+ # Calling {#solid}, {#background} or {#gradient} makes it explicit, after
14
+ # which {#fore_color} and friends become available.
15
+ #
16
+ # shape.fill.solid
17
+ # shape.fill.fore_color.rgb = Pptx::RGBColor["C0504D"]
18
+ class FillFormat
19
+ # @param fill_parent [Pptx::Oxml::Element] the element carrying the fill
20
+ # choice group, e.g. `p:spPr` or `a:rPr`
21
+ def self.from_fill_parent(fill_parent) = new(fill_parent)
22
+
23
+ def initialize(fill_parent)
24
+ @parent = fill_parent
25
+ end
26
+
27
+ # @return [Pptx::Enum::MSO_FILL_TYPE, nil] nil when the fill is inherited
28
+ def type
29
+ element = fill_element
30
+ return nil if element.nil?
31
+
32
+ TYPES[element.nsptag]
33
+ end
34
+
35
+ TYPES = {
36
+ "a:noFill" => Enum::MSO_FILL_TYPE::BACKGROUND,
37
+ "a:solidFill" => Enum::MSO_FILL_TYPE::SOLID,
38
+ "a:gradFill" => Enum::MSO_FILL_TYPE::GRADIENT,
39
+ "a:blipFill" => Enum::MSO_FILL_TYPE::PICTURE,
40
+ "a:pattFill" => Enum::MSO_FILL_TYPE::PATTERNED,
41
+ "a:grpFill" => Enum::MSO_FILL_TYPE::GROUP
42
+ }.freeze
43
+
44
+ # Make this a solid (flat colour) fill. The colour itself is then set
45
+ # through {#fore_color}.
46
+ def solid
47
+ @parent.get_or_change_to_solidFill
48
+ self
49
+ end
50
+
51
+ # Make this fill transparent, so whatever is behind shows through.
52
+ def background
53
+ @parent.get_or_change_to_noFill
54
+ self
55
+ end
56
+
57
+ # Make this a gradient fill, with PowerPoint's default stops.
58
+ def gradient
59
+ @parent.get_or_change_to_gradFill
60
+ self
61
+ end
62
+
63
+ # Make this a patterned fill.
64
+ def patterned
65
+ @parent.get_or_change_to_pattFill
66
+ self
67
+ end
68
+
69
+ # The foreground colour.
70
+ #
71
+ # @raise [Error] unless the fill is solid or patterned
72
+ def fore_color
73
+ case type&.name
74
+ when :SOLID then ColorFormat.from_color_choice_parent(fill_element)
75
+ when :PATTERNED then ColorFormat.from_color_choice_parent(fill_element.get_or_add_fgClr)
76
+ else
77
+ raise Error, "fill type #{type&.name.inspect} has no foreground colour"
78
+ end
79
+ end
80
+
81
+ # The background colour of a patterned fill.
82
+ #
83
+ # @raise [Error] unless the fill is patterned
84
+ def back_color
85
+ raise Error, "fill type #{type&.name.inspect} has no background colour" unless type&.name == :PATTERNED
86
+
87
+ ColorFormat.from_color_choice_parent(fill_element.get_or_add_bgClr)
88
+ end
89
+
90
+ # @return [Pptx::Enum::MSO_PATTERN_TYPE, nil]
91
+ def pattern
92
+ raise Error, "fill type #{type&.name.inspect} is not patterned" unless type&.name == :PATTERNED
93
+
94
+ fill_element.prst
95
+ end
96
+
97
+ def pattern=(value)
98
+ patterned unless type&.name == :PATTERNED
99
+ fill_element.prst = value
100
+ end
101
+
102
+ # The angle of a linear gradient in degrees, counter-clockwise from
103
+ # pointing right -- the way PowerPoint's dialog and trigonometry both
104
+ # count, although the file stores it clockwise. nil when inherited.
105
+ #
106
+ # @raise [Error] unless this is a gradient fill, or if it is not linear
107
+ def gradient_angle
108
+ gradient = require_gradient
109
+ raise Error, "not a linear gradient" unless gradient.path.nil?
110
+
111
+ clockwise = gradient.lin&.ang
112
+ return nil if clockwise.nil?
113
+
114
+ clockwise.zero? ? 0.0 : 360.0 - clockwise
115
+ end
116
+
117
+ def gradient_angle=(value)
118
+ linear = require_gradient.lin
119
+ raise Error, "not a linear gradient" if linear.nil?
120
+
121
+ linear.ang = 360.0 - value
122
+ end
123
+
124
+ # The colour stops the gradient passes through, in order.
125
+ #
126
+ # @return [GradientStops]
127
+ def gradient_stops = GradientStops.new(require_gradient.get_or_add_gsLst)
128
+
129
+ def inspect = "#<Pptx::FillFormat type=#{type&.name.inspect}>"
130
+
131
+ private
132
+
133
+ def fill_element = @parent.eg_fillProperties
134
+
135
+ def require_gradient
136
+ raise Error, "fill type #{type&.name.inspect} is not a gradient" unless type&.name == :GRADIENT
137
+
138
+ fill_element
139
+ end
140
+ end
141
+
142
+ # The outline of a shape.
143
+ # The stops of a gradient fill.
144
+ #
145
+ # stops = shape.fill.gradient_stops
146
+ # stops[0].color.rgb = Pptx::RGBColor["1F497D"]
147
+ # stops[1].position = 0.75
148
+ class GradientStops
149
+ include Enumerable
150
+ include Sliceable
151
+
152
+ def initialize(gs_lst)
153
+ @element = gs_lst
154
+ end
155
+
156
+ def each(&)
157
+ return enum_for(:each) { size } unless block_given?
158
+
159
+ @element.gs_list.each { |gs| yield GradientStop.new(gs) }
160
+ self
161
+ end
162
+
163
+ def size = @element.gs_list.size
164
+ alias length size
165
+
166
+ def [](index, length = nil) = slice_members(@element.gs_list, index, length) { |gs| GradientStop.new(gs) }
167
+
168
+ def inspect = "#<Pptx::GradientStops size=#{size}>"
169
+ end
170
+
171
+ # One colour stop in a gradient.
172
+ class GradientStop < ElementProxy
173
+ def color = @color ||= ColorFormat.from_color_choice_parent(@element)
174
+
175
+ # Where along the gradient this stop sits, from 0.0 at the start to 1.0
176
+ # at the end.
177
+ def position = @element.pos
178
+
179
+ def position=(value)
180
+ @element.pos = Float(value)
181
+ end
182
+
183
+ def inspect = "#<Pptx::GradientStop position=#{position}>"
184
+ end
185
+
186
+ class LineFormat
187
+ def initialize(parent)
188
+ @parent = parent
189
+ end
190
+
191
+ # The `a:ln` element, added if not present.
192
+ def element = @element ||= @parent.get_or_add_ln
193
+
194
+ def fill = @fill ||= FillFormat.from_fill_parent(element)
195
+
196
+ # Shortcut to the line's solid colour, making the fill solid on first use.
197
+ def color
198
+ fill.solid if fill.type.nil?
199
+ fill.fore_color
200
+ end
201
+
202
+ # @return [Pptx::Length, nil] nil when the width is inherited
203
+ #
204
+ # Reading never adds an `a:ln`; only assigning does.
205
+ def width
206
+ value = @parent.ln&.w
207
+ value.nil? || value.zero? ? nil : value
208
+ end
209
+
210
+ # The dash pattern, or nil when it is inherited.
211
+ #
212
+ # @return [Pptx::Enum::Member, nil] a member of MSO_LINE_DASH_STYLE
213
+ def dash_style = @parent.ln&.prstDash_val
214
+
215
+ # Set the dash pattern, or restore inheritance with nil -- which also
216
+ # drops a custom dash, since either one would override the inherited
217
+ # style.
218
+ def dash_style=(value)
219
+ if value.nil?
220
+ line = @parent.ln or return
221
+ line.remove_prstDash
222
+ line.remove_custDash
223
+ else
224
+ element.prstDash_val = Enum::MSO_LINE_DASH_STYLE.fetch(value)
225
+ end
226
+ end
227
+
228
+ def width=(value)
229
+ element.w = value.nil? ? Pptx::Length.emu(0) : value
230
+ end
231
+
232
+ def inspect = "#<Pptx::LineFormat width=#{width&.pt}>"
233
+ end
234
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/sliceable"
4
+
5
+ module Pptx
6
+ # Base for the objects that make up the public API.
7
+ #
8
+ # Almost every class a caller touches is a thin proxy whose state lives
9
+ # entirely in the XML element it wraps. Two proxies wrapping the same element
10
+ # are equal, whether or not they are the same object.
11
+ class ElementProxy
12
+ include Sliceable
13
+
14
+ attr_reader :element
15
+
16
+ def initialize(element)
17
+ @element = element
18
+ end
19
+
20
+ def ==(other) = other.is_a?(ElementProxy) && other.element == @element
21
+ alias eql? ==
22
+
23
+ def hash = @element.hash
24
+
25
+ def inspect = "#<#{self.class.name} <#{@element.nsptag}>>"
26
+ end
27
+
28
+ # A proxy that knows its parent, and through it the part it belongs to.
29
+ #
30
+ # An ancestor is occasionally needed to do something the proxy cannot, such
31
+ # as add or drop a relationship.
32
+ class ParentedElementProxy < ElementProxy
33
+ attr_reader :parent
34
+
35
+ def initialize(element, parent)
36
+ super(element)
37
+ @parent = parent
38
+ end
39
+
40
+ # The package part this object lives in.
41
+ def part = @parent.part
42
+ end
43
+
44
+ # A proxy wrapping a part's root element, such as `p:sld`.
45
+ class PartElementProxy < ElementProxy
46
+ attr_reader :part
47
+
48
+ def initialize(element, part)
49
+ super(element)
50
+ @part = part
51
+ end
52
+ end
53
+ end