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,634 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_pptx/element_proxy"
|
|
4
|
+
require "ruby_pptx/shapes/base"
|
|
5
|
+
require "ruby_pptx/shapes/shape"
|
|
6
|
+
require "ruby_pptx/shapes/placeholder"
|
|
7
|
+
require "ruby_pptx/autoshape_spec"
|
|
8
|
+
|
|
9
|
+
module Pptx
|
|
10
|
+
# Builds the right shape proxy for a shape element.
|
|
11
|
+
module ShapeFactory
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The default mapping, used for shapes on a slide master or layout and as
|
|
15
|
+
# the base for the slide-specific variants.
|
|
16
|
+
def build(shape_element, parent)
|
|
17
|
+
case shape_element.nsptag
|
|
18
|
+
when "p:pic"
|
|
19
|
+
shape_element.movie? ? Movie.new(shape_element, parent) : Picture.new(shape_element, parent)
|
|
20
|
+
when "p:cxnSp" then Connector.new(shape_element, parent)
|
|
21
|
+
when "p:grpSp" then GroupShape.new(shape_element, parent)
|
|
22
|
+
when "p:sp" then Shape.new(shape_element, parent)
|
|
23
|
+
when "p:graphicFrame" then GraphicFrame.new(shape_element, parent)
|
|
24
|
+
else BaseShape.new(shape_element, parent)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A slide placeholder's proxy depends on what it can be filled with.
|
|
29
|
+
SLIDE_PLACEHOLDER_CLASSES = {
|
|
30
|
+
BITMAP: :PicturePlaceholder, PICTURE: :PicturePlaceholder,
|
|
31
|
+
CHART: :ChartPlaceholder, TABLE: :TablePlaceholder
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# On a slide a placeholder gets a placeholder proxy: typed by what it can
|
|
35
|
+
# hold while empty, and a placeholder variant of the picture or graphic
|
|
36
|
+
# frame once it has been filled.
|
|
37
|
+
def build_for_slide(shape_element, parent)
|
|
38
|
+
return build(shape_element, parent) unless shape_element.placeholder?
|
|
39
|
+
|
|
40
|
+
case shape_element.nsptag
|
|
41
|
+
when "p:sp"
|
|
42
|
+
name = SLIDE_PLACEHOLDER_CLASSES.fetch(shape_element.ph_type.name, :SlidePlaceholder)
|
|
43
|
+
Pptx.const_get(name).new(shape_element, parent)
|
|
44
|
+
when "p:pic" then PlaceholderPicture.new(shape_element, parent)
|
|
45
|
+
when "p:graphicFrame" then PlaceholderGraphicFrame.new(shape_element, parent)
|
|
46
|
+
else build(shape_element, parent)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_for_layout(shape_element, parent)
|
|
51
|
+
return LayoutPlaceholder.new(shape_element, parent) if placeholder_sp?(shape_element)
|
|
52
|
+
|
|
53
|
+
build(shape_element, parent)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_for_master(shape_element, parent)
|
|
57
|
+
return MasterPlaceholder.new(shape_element, parent) if placeholder_sp?(shape_element)
|
|
58
|
+
|
|
59
|
+
build(shape_element, parent)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_for_notes_slide(shape_element, parent)
|
|
63
|
+
return NotesSlidePlaceholder.new(shape_element, parent) if placeholder_sp?(shape_element)
|
|
64
|
+
|
|
65
|
+
build(shape_element, parent)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def placeholder_sp?(shape_element)
|
|
69
|
+
shape_element.nsptag == "p:sp" && shape_element.placeholder?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The shapes of a slide, layout or master, in z-order: first is backmost.
|
|
74
|
+
class BaseShapes < ParentedElementProxy
|
|
75
|
+
include Enumerable
|
|
76
|
+
|
|
77
|
+
def initialize(sp_tree, parent)
|
|
78
|
+
super
|
|
79
|
+
@sp_tree = sp_tree
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def each
|
|
83
|
+
return enum_for(:each) { size } unless block_given?
|
|
84
|
+
|
|
85
|
+
member_elements.each { |element| yield shape_factory(element) }
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A group counts as one shape, whatever it contains.
|
|
90
|
+
def size = member_elements.size
|
|
91
|
+
alias length size
|
|
92
|
+
|
|
93
|
+
def [](index, length = nil)
|
|
94
|
+
slice_members(member_elements, index, length) { |element| shape_factory(element) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch(index)
|
|
98
|
+
self[index] || raise(IndexError, "shape index #{index} out of range")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Add a placeholder to this collection modelled on +placeholder+.
|
|
102
|
+
def clone_placeholder(placeholder)
|
|
103
|
+
sp = placeholder.element
|
|
104
|
+
id = next_shape_id
|
|
105
|
+
name = next_placeholder_name(sp.ph_type, id, sp.ph_orient)
|
|
106
|
+
@sp_tree.add_placeholder(id, name, sp.ph_type, sp.ph_orient, sp.ph_sz, sp.ph_idx)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The base name PowerPoint gives a placeholder of +ph_type+.
|
|
110
|
+
#
|
|
111
|
+
# A notes slide names its body placeholder differently, so subclasses can
|
|
112
|
+
# override this.
|
|
113
|
+
def placeholder_basename(ph_type)
|
|
114
|
+
PLACEHOLDER_BASENAMES.fetch(ph_type.name) do
|
|
115
|
+
raise ArgumentError, "no placeholder base name for #{ph_type}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
PLACEHOLDER_BASENAMES = {
|
|
120
|
+
BITMAP: "ClipArt Placeholder",
|
|
121
|
+
BODY: "Text Placeholder",
|
|
122
|
+
CENTER_TITLE: "Title",
|
|
123
|
+
CHART: "Chart Placeholder",
|
|
124
|
+
DATE: "Date Placeholder",
|
|
125
|
+
FOOTER: "Footer Placeholder",
|
|
126
|
+
HEADER: "Header Placeholder",
|
|
127
|
+
MEDIA_CLIP: "Media Placeholder",
|
|
128
|
+
OBJECT: "Content Placeholder",
|
|
129
|
+
ORG_CHART: "SmartArt Placeholder",
|
|
130
|
+
PICTURE: "Picture Placeholder",
|
|
131
|
+
SLIDE_NUMBER: "Slide Number Placeholder",
|
|
132
|
+
SUBTITLE: "Subtitle",
|
|
133
|
+
TABLE: "Table Placeholder",
|
|
134
|
+
TITLE: "Title"
|
|
135
|
+
}.freeze
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Decide which file the blip points at and which, if any, hangs off its
|
|
140
|
+
# SVG extension.
|
|
141
|
+
#
|
|
142
|
+
# @return [Array(Object, Object)] the raster to embed, and the SVG or nil
|
|
143
|
+
def vector_or_raster(image_file, fallback)
|
|
144
|
+
unless Image.from_file(image_file).vector?
|
|
145
|
+
return [image_file, nil] if fallback.nil?
|
|
146
|
+
|
|
147
|
+
raise ArgumentError, "fallback: is only meaningful for a vector image"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if fallback.nil?
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"an SVG needs a raster fallback: add_picture(svg, fallback: png). " \
|
|
153
|
+
"This gem cannot rasterize one for you."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[fallback, image_file]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Which shape elements belong to this collection; placeholder collections
|
|
160
|
+
# narrow this.
|
|
161
|
+
def member?(_shape_element) = true
|
|
162
|
+
|
|
163
|
+
def member_elements = @sp_tree.shape_elements.select { |e| member?(e) }
|
|
164
|
+
|
|
165
|
+
def shape_factory(shape_element) = ShapeFactory.build(shape_element, self)
|
|
166
|
+
|
|
167
|
+
# One more than the highest id in use. Note the shape tree's own allocator
|
|
168
|
+
# fills gaps instead; both behaviours are inherited from python-pptx.
|
|
169
|
+
def next_shape_id = @sp_tree.max_shape_id + 1
|
|
170
|
+
|
|
171
|
+
# Office's icon for an Office document, a generic one for anything else.
|
|
172
|
+
def default_ole_icon(prog_id)
|
|
173
|
+
name = prog_id.is_a?(Enum::PROG_ID::Member) ? prog_id.icon_filename : "generic-icon.emf"
|
|
174
|
+
File.expand_path("../templates/#{name}", __dir__)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The name PowerPoint would give a new placeholder: the base name for its
|
|
178
|
+
# type followed by id - 1, bumped until it is unique in the tree, and
|
|
179
|
+
# prefixed "Vertical " for a vertical placeholder.
|
|
180
|
+
def next_placeholder_name(ph_type, id, orient)
|
|
181
|
+
basename = placeholder_basename(ph_type)
|
|
182
|
+
basename = "Vertical #{basename}" if orient == Oxml::SimpleTypes::ST_Direction::VERT
|
|
183
|
+
|
|
184
|
+
taken = @sp_tree.xpath("//p:cNvPr/@name").map(&:value)
|
|
185
|
+
numpart = id - 1
|
|
186
|
+
numpart += 1 while taken.include?("#{basename} #{numpart}")
|
|
187
|
+
"#{basename} #{numpart}"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A shape collection you can add shapes to: a slide's tree, or a group
|
|
192
|
+
# within one.
|
|
193
|
+
#
|
|
194
|
+
# Adding to a group changes the group's extents, so every method here ends
|
|
195
|
+
# by recalculating them. On a slide that is a no-op.
|
|
196
|
+
class BaseGroupShapes < BaseShapes
|
|
197
|
+
# Add an auto shape.
|
|
198
|
+
#
|
|
199
|
+
# shapes.add_shape(:rounded_rectangle,
|
|
200
|
+
# at: [Pptx.inches(1), Pptx.inches(1)],
|
|
201
|
+
# size: [Pptx.inches(2), Pptx.inches(1)])
|
|
202
|
+
#
|
|
203
|
+
# @param shape_type [Pptx::Enum::MSO_SHAPE, Symbol, Integer]
|
|
204
|
+
# @param at [Array(Length, Length)] left and top
|
|
205
|
+
# @param size [Array(Length, Length)] width and height
|
|
206
|
+
# @return [Shape]
|
|
207
|
+
def add_shape(shape_type, at:, size:)
|
|
208
|
+
left, top = at
|
|
209
|
+
width, height = size
|
|
210
|
+
member = Enum::MSO_SHAPE.fetch(shape_type)
|
|
211
|
+
id = next_shape_id
|
|
212
|
+
name = "#{AutoShapeSpec.basename(member)} #{id - 1}"
|
|
213
|
+
sp = @sp_tree.add_autoshape(id, name, Enum::MSO_SHAPE.to_xml(member),
|
|
214
|
+
left, top, width, height)
|
|
215
|
+
recalculate_extents
|
|
216
|
+
shape_factory(sp)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Add a picture showing the image in +image_file+, which may be a path or
|
|
220
|
+
# an IO stream.
|
|
221
|
+
#
|
|
222
|
+
# Supplying neither +width+ nor +height+ uses the image's native size;
|
|
223
|
+
# supplying one scales the other to preserve the aspect ratio; supplying
|
|
224
|
+
# both stretches the image to fit.
|
|
225
|
+
#
|
|
226
|
+
# An SVG additionally needs +fallback+: a raster image PowerPoint shows to
|
|
227
|
+
# consumers that cannot draw the vector. This gem has no rasterizer, so
|
|
228
|
+
# the fallback has to be supplied rather than generated. The picture is
|
|
229
|
+
# sized from the fallback, since an SVG carries no pixel size of its own.
|
|
230
|
+
#
|
|
231
|
+
# shapes.add_picture("logo.svg", at: [x, y], fallback: "logo.png")
|
|
232
|
+
#
|
|
233
|
+
# @param at [Array(Length, Length)] left and top
|
|
234
|
+
# @param fallback [String, IO, nil] raster stand-in, required for an SVG
|
|
235
|
+
# @return [Picture]
|
|
236
|
+
def add_picture(image_file, at:, width: nil, height: nil, fallback: nil)
|
|
237
|
+
left, top = at
|
|
238
|
+
raster_file, svg_file = vector_or_raster(image_file, fallback)
|
|
239
|
+
|
|
240
|
+
image_part, r_id = part.get_or_add_image_part(raster_file)
|
|
241
|
+
scaled_width, scaled_height = image_part.scale(width, height)
|
|
242
|
+
id = next_shape_id
|
|
243
|
+
pic = @sp_tree.add_pic(id, "Picture #{id - 1}", image_part.desc, r_id,
|
|
244
|
+
left, top, scaled_width, scaled_height)
|
|
245
|
+
|
|
246
|
+
if svg_file
|
|
247
|
+
_svg_part, svg_r_id = part.get_or_add_image_part(svg_file)
|
|
248
|
+
pic.blip.add_svg_blip(svg_r_id)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
recalculate_extents
|
|
252
|
+
shape_factory(pic)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Add a movie showing the video in +movie_file+.
|
|
256
|
+
#
|
|
257
|
+
# The size must be given: unlike a picture, a video is not interrogated
|
|
258
|
+
# for its dimensions. Nor is it interrogated for its type, so say what it
|
|
259
|
+
# is with +content_type+; PowerPoint plays "video/unknown" anyway, which
|
|
260
|
+
# is why that is the default.
|
|
261
|
+
#
|
|
262
|
+
# +poster_frame+ is the still shown before the video plays. Without one,
|
|
263
|
+
# the loudspeaker image PowerPoint uses is supplied.
|
|
264
|
+
#
|
|
265
|
+
# @return [Movie]
|
|
266
|
+
def add_movie(movie_file, at:, size:, poster_frame: nil,
|
|
267
|
+
content_type: Video::UNKNOWN_CONTENT_TYPE)
|
|
268
|
+
left, top = at
|
|
269
|
+
width, height = size
|
|
270
|
+
video = Video.from_file(movie_file, content_type)
|
|
271
|
+
media_r_id, video_r_id = part.get_or_add_video_media_part(video)
|
|
272
|
+
_poster_part, poster_r_id = part.get_or_add_image_part(poster_frame || default_poster_frame)
|
|
273
|
+
|
|
274
|
+
id = next_shape_id
|
|
275
|
+
pic = @sp_tree.add_video_pic(id, video.filename, video_r_id, media_r_id, poster_r_id,
|
|
276
|
+
left, top, width, height)
|
|
277
|
+
register_video_timing(pic)
|
|
278
|
+
recalculate_extents
|
|
279
|
+
shape_factory(pic)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Add a chart of +chart_type+ depicting +chart_data+.
|
|
283
|
+
#
|
|
284
|
+
# @return [GraphicFrame] use its `#chart` to reach the chart itself
|
|
285
|
+
def add_chart(chart_type, chart_data, at:, size:)
|
|
286
|
+
left, top = at
|
|
287
|
+
width, height = size
|
|
288
|
+
r_id = part.add_chart_part(chart_type, chart_data)
|
|
289
|
+
id = next_shape_id
|
|
290
|
+
frame = @sp_tree.add_graphic_frame_chart(id, "Chart #{id - 1}", r_id,
|
|
291
|
+
left, top, width, height)
|
|
292
|
+
recalculate_extents
|
|
293
|
+
shape_factory(frame)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Add a chart drawing several plots over the same categories.
|
|
297
|
+
#
|
|
298
|
+
# shapes.add_combo_chart(data, at: [x, y], size: [w, h]) do |combo|
|
|
299
|
+
# combo.plot :column_clustered, series: "Revenue"
|
|
300
|
+
# combo.plot :line, series: "Margin", secondary_axis: true
|
|
301
|
+
# end
|
|
302
|
+
#
|
|
303
|
+
# All the series live in one ChartData, since they share a worksheet; each
|
|
304
|
+
# plot names the ones it draws. PowerPoint can do this and python-pptx
|
|
305
|
+
# cannot, so there is no reference implementation to compare against.
|
|
306
|
+
#
|
|
307
|
+
# @return [GraphicFrame] use its `#chart` to reach the chart itself
|
|
308
|
+
def add_combo_chart(chart_data, at:, size:)
|
|
309
|
+
builder = ComboChartBuilder.new(chart_data)
|
|
310
|
+
yield builder if block_given?
|
|
311
|
+
|
|
312
|
+
left, top = at
|
|
313
|
+
width, height = size
|
|
314
|
+
r_id = part.add_combo_chart_part(builder)
|
|
315
|
+
id = next_shape_id
|
|
316
|
+
frame = @sp_tree.add_graphic_frame_chart(id, "Chart #{id - 1}", r_id,
|
|
317
|
+
left, top, width, height)
|
|
318
|
+
recalculate_extents
|
|
319
|
+
shape_factory(frame)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Add a table of +rows+ by +cols+.
|
|
323
|
+
#
|
|
324
|
+
# @return [GraphicFrame] use its `#table` to reach the table itself
|
|
325
|
+
def add_table(rows, cols, at:, size:)
|
|
326
|
+
left, top = at
|
|
327
|
+
width, height = size
|
|
328
|
+
id = next_shape_id
|
|
329
|
+
frame = @sp_tree.add_graphic_frame_table(id, "Table #{id - 1}", rows, cols,
|
|
330
|
+
left, top, width, height)
|
|
331
|
+
recalculate_extents
|
|
332
|
+
shape_factory(frame)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Embed a file as an OLE object, shown on the slide as an icon.
|
|
336
|
+
#
|
|
337
|
+
# shapes.add_ole_object("budget.xlsx", prog_id: :xlsx, at: [x, y])
|
|
338
|
+
# shapes.add_ole_object("report.pdf", prog_id: "AcroExch.Document", at: [x, y],
|
|
339
|
+
# icon_file: "pdf-icon.png")
|
|
340
|
+
#
|
|
341
|
+
# +prog_id+ is :docx, :pptx or :xlsx for an Office document, which is
|
|
342
|
+
# stored as the document itself and gets Office's icon; any other type is
|
|
343
|
+
# named by its ProgID String and stored as an opaque object with a
|
|
344
|
+
# generic icon. Double-clicking the icon in PowerPoint opens the file in
|
|
345
|
+
# the program the ProgID names.
|
|
346
|
+
#
|
|
347
|
+
# @param size [Array(Length, Length), nil] the shape's size; defaults to
|
|
348
|
+
# the icon's
|
|
349
|
+
# @param icon_file [String, IO, nil] an image to show instead of the
|
|
350
|
+
# default icon
|
|
351
|
+
# @param icon_size [Array(Length, Length), nil] the icon image's own size
|
|
352
|
+
# @return [GraphicFrame]
|
|
353
|
+
def add_ole_object(object_file, prog_id:, at:, size: nil, icon_file: nil, icon_size: nil)
|
|
354
|
+
left, top = at
|
|
355
|
+
prog_id = Enum::PROG_ID.resolve(prog_id)
|
|
356
|
+
default_size = [Length.emu(965_200), Length.emu(609_600)]
|
|
357
|
+
width, height = size || (if prog_id.is_a?(Enum::PROG_ID::Member)
|
|
358
|
+
[prog_id.width,
|
|
359
|
+
prog_id.height]
|
|
360
|
+
else
|
|
361
|
+
default_size
|
|
362
|
+
end)
|
|
363
|
+
icon_width, icon_height = icon_size || default_size
|
|
364
|
+
ole_r_id = part.add_embedded_ole_object_part(prog_id, object_file)
|
|
365
|
+
_icon_part, icon_r_id = part.get_or_add_image_part(icon_file || default_ole_icon(prog_id))
|
|
366
|
+
id = next_shape_id
|
|
367
|
+
prog_id_text = prog_id.is_a?(Enum::PROG_ID::Member) ? prog_id.prog_id : prog_id
|
|
368
|
+
frame = @sp_tree.add_graphic_frame_ole_object(id, "Object #{id - 1}", ole_r_id, prog_id_text,
|
|
369
|
+
icon_r_id, left, top, width, height,
|
|
370
|
+
icon_width, icon_height)
|
|
371
|
+
recalculate_extents
|
|
372
|
+
shape_factory(frame)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Add an empty text box.
|
|
376
|
+
#
|
|
377
|
+
# @return [Shape]
|
|
378
|
+
def add_textbox(at:, size:)
|
|
379
|
+
left, top = at
|
|
380
|
+
width, height = size
|
|
381
|
+
id = next_shape_id
|
|
382
|
+
sp = @sp_tree.add_textbox(id, "TextBox #{id - 1}", left, top, width, height)
|
|
383
|
+
recalculate_extents
|
|
384
|
+
shape_factory(sp)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Add a connector between two points.
|
|
388
|
+
#
|
|
389
|
+
# A connector is stored as a bounding box with flip flags rather than as
|
|
390
|
+
# two points, so the end points are converted here.
|
|
391
|
+
#
|
|
392
|
+
# @return [Connector]
|
|
393
|
+
def add_connector(connector_type, begin_at:, end_at:)
|
|
394
|
+
member = Enum::MSO_CONNECTOR_TYPE.fetch(connector_type)
|
|
395
|
+
begin_x, begin_y = begin_at.map { |value| Length.coerce(value).emu }
|
|
396
|
+
end_x, end_y = end_at.map { |value| Length.coerce(value).emu }
|
|
397
|
+
|
|
398
|
+
id = next_shape_id
|
|
399
|
+
cxn_sp = @sp_tree.add_cxnSp(
|
|
400
|
+
id, "Connector #{id - 1}", Enum::MSO_CONNECTOR_TYPE.to_xml(member),
|
|
401
|
+
[begin_x, end_x].min, [begin_y, end_y].min,
|
|
402
|
+
(end_x - begin_x).abs, (end_y - begin_y).abs,
|
|
403
|
+
begin_x > end_x, begin_y > end_y
|
|
404
|
+
)
|
|
405
|
+
recalculate_extents
|
|
406
|
+
shape_factory(cxn_sp)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Start building a freeform shape.
|
|
410
|
+
#
|
|
411
|
+
# +scale+ says how many EMU one local coordinate unit is worth, so a shape
|
|
412
|
+
# can be described in convenient numbers; pass a pair for different
|
|
413
|
+
# horizontal and vertical scales.
|
|
414
|
+
#
|
|
415
|
+
# @return [FreeformBuilder]
|
|
416
|
+
def build_freeform(start_x: 0, start_y: 0, scale: 1.0)
|
|
417
|
+
FreeformBuilder.new_builder(self, start_x, start_y, scale)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Build a freeform shape and add it, in one call.
|
|
421
|
+
#
|
|
422
|
+
# shapes.add_freeform(at: [x, y], scale: Pptx.inches(1).emu / 100.0) do |f|
|
|
423
|
+
# f.line_to(100, 0)
|
|
424
|
+
# f.line_to(50, 100)
|
|
425
|
+
# end
|
|
426
|
+
#
|
|
427
|
+
# @return [Shape]
|
|
428
|
+
def add_freeform(at: [0, 0], start_x: 0, start_y: 0, scale: 1.0, close: true)
|
|
429
|
+
builder = build_freeform(start_x: start_x, start_y: start_y, scale: scale)
|
|
430
|
+
yield builder if block_given?
|
|
431
|
+
builder.close if close
|
|
432
|
+
builder.convert_to_shape(origin_at: at)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The loudspeaker still PowerPoint shows for a video with no poster frame.
|
|
436
|
+
def default_poster_frame
|
|
437
|
+
StringIO.new(File.binread(
|
|
438
|
+
File.expand_path("../templates/media-speaker.png", __dir__)
|
|
439
|
+
))
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Play controls appear only for a movie listed in the slide's timing tree.
|
|
443
|
+
def register_video_timing(pic)
|
|
444
|
+
slide_element = @sp_tree.xpath("/p:sld").first
|
|
445
|
+
return if slide_element.nil?
|
|
446
|
+
|
|
447
|
+
slide_element.get_or_add_child_time_node_list.add_video(pic.shape_id)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# @api private
|
|
451
|
+
# Used by FreeformBuilder, which needs to add the element and then draw
|
|
452
|
+
# into it.
|
|
453
|
+
def add_freeform_element(x, y, width, height)
|
|
454
|
+
@sp_tree.add_freeform_sp(x, y, width, height)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# @api private
|
|
458
|
+
def build_shape(shape_element)
|
|
459
|
+
recalculate_extents
|
|
460
|
+
shape_factory(shape_element)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Add a group, optionally moving +shapes+ into it.
|
|
464
|
+
#
|
|
465
|
+
# The group has no position or size of its own: both follow from what it
|
|
466
|
+
# contains, and are recomputed whenever its contents change.
|
|
467
|
+
#
|
|
468
|
+
# @return [GroupShape]
|
|
469
|
+
def add_group_shape(shapes = [])
|
|
470
|
+
grp_sp = @sp_tree.add_grpSp
|
|
471
|
+
shapes.each { |shape| grp_sp.insert_element_before(shape.element, "p:extLst") }
|
|
472
|
+
grp_sp.recalculate_extents unless shapes.empty?
|
|
473
|
+
recalculate_extents
|
|
474
|
+
shape_factory(grp_sp)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
private
|
|
478
|
+
|
|
479
|
+
# A group resizes itself around its contents; a slide does not move.
|
|
480
|
+
def recalculate_extents = nil
|
|
481
|
+
|
|
482
|
+
def shape_factory(shape_element) = ShapeFactory.build_for_slide(shape_element, self)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# The shapes on a slide.
|
|
486
|
+
class SlideShapes < BaseGroupShapes
|
|
487
|
+
# Copy the layout's placeholders onto this slide, preserving z-order.
|
|
488
|
+
#
|
|
489
|
+
# Latent placeholders -- date, footer and slide number -- are not cloned:
|
|
490
|
+
# PowerPoint shows them from the layout without materialising them on the
|
|
491
|
+
# slide.
|
|
492
|
+
def clone_layout_placeholders(slide_layout)
|
|
493
|
+
slide_layout.cloneable_placeholders.each { |ph| clone_placeholder(ph) }
|
|
494
|
+
self
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# The title placeholder, which is the one with idx 0.
|
|
498
|
+
#
|
|
499
|
+
# @return [SlidePlaceholder, nil]
|
|
500
|
+
def title
|
|
501
|
+
element = @sp_tree.placeholder_elements.find { |e| e.ph_idx.zero? }
|
|
502
|
+
element && shape_factory(element)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def placeholders = parent.placeholders
|
|
506
|
+
|
|
507
|
+
private
|
|
508
|
+
|
|
509
|
+
def shape_factory(shape_element) = ShapeFactory.build_for_slide(shape_element, self)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# The shapes inside a `p:grpSp`.
|
|
513
|
+
class GroupShapes < BaseGroupShapes
|
|
514
|
+
private
|
|
515
|
+
|
|
516
|
+
# Adding to a group changes where the group sits and how big it is.
|
|
517
|
+
def recalculate_extents
|
|
518
|
+
@sp_tree.recalculate_extents
|
|
519
|
+
nil
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# The shapes on a slide layout.
|
|
524
|
+
class LayoutShapes < BaseShapes
|
|
525
|
+
private
|
|
526
|
+
|
|
527
|
+
def shape_factory(shape_element) = ShapeFactory.build_for_layout(shape_element, self)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# The shapes on a slide master.
|
|
531
|
+
class MasterShapes < BaseShapes
|
|
532
|
+
private
|
|
533
|
+
|
|
534
|
+
def shape_factory(shape_element) = ShapeFactory.build_for_master(shape_element, self)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# The shapes on a notes page.
|
|
538
|
+
#
|
|
539
|
+
# A notes slide names its body placeholder "Notes Placeholder" and has a
|
|
540
|
+
# slide-image placeholder no other kind of slide carries, so it has its own
|
|
541
|
+
# naming table.
|
|
542
|
+
class NotesSlideShapes < BaseShapes
|
|
543
|
+
NOTES_PLACEHOLDER_BASENAMES = {
|
|
544
|
+
BODY: "Notes Placeholder", DATE: "Date Placeholder", FOOTER: "Footer Placeholder",
|
|
545
|
+
HEADER: "Header Placeholder", SLIDE_IMAGE: "Slide Image Placeholder",
|
|
546
|
+
SLIDE_NUMBER: "Slide Number Placeholder"
|
|
547
|
+
}.freeze
|
|
548
|
+
|
|
549
|
+
def placeholder_basename(ph_type)
|
|
550
|
+
NOTES_PLACEHOLDER_BASENAMES.fetch(ph_type.name) do
|
|
551
|
+
raise ArgumentError, "no notes placeholder base name for #{ph_type}"
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
private
|
|
556
|
+
|
|
557
|
+
def shape_factory(shape_element) = ShapeFactory.build_for_notes_slide(shape_element, self)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# The placeholders on a notes page.
|
|
561
|
+
class NotesSlidePlaceholders < NotesSlideShapes
|
|
562
|
+
# @return [NotesSlidePlaceholder, nil]
|
|
563
|
+
def by_type(ph_type) = find { |ph| ph.element.ph_type == ph_type }
|
|
564
|
+
|
|
565
|
+
private
|
|
566
|
+
|
|
567
|
+
def member?(shape_element) = shape_element.placeholder?
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# The placeholders of a slide layout, in `idx` order.
|
|
571
|
+
class LayoutPlaceholders < LayoutShapes
|
|
572
|
+
include PlaceholderAuthoring
|
|
573
|
+
|
|
574
|
+
# @return [LayoutPlaceholder, nil]
|
|
575
|
+
def by_idx(idx) = find { |ph| ph.element.ph_idx == idx }
|
|
576
|
+
|
|
577
|
+
private
|
|
578
|
+
|
|
579
|
+
def member?(shape_element) = shape_element.placeholder?
|
|
580
|
+
|
|
581
|
+
def member_elements = super.sort_by(&:ph_idx)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# The placeholders of a slide master, in `idx` order.
|
|
585
|
+
class MasterPlaceholders < MasterShapes
|
|
586
|
+
include PlaceholderAuthoring
|
|
587
|
+
|
|
588
|
+
# @return [MasterPlaceholder, nil]
|
|
589
|
+
def by_type(ph_type) = find { |ph| ph.element.ph_type == ph_type }
|
|
590
|
+
|
|
591
|
+
private
|
|
592
|
+
|
|
593
|
+
def member?(shape_element) = shape_element.placeholder?
|
|
594
|
+
|
|
595
|
+
def member_elements = super.sort_by(&:ph_idx)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# The placeholders of a slide.
|
|
599
|
+
#
|
|
600
|
+
# Ordered by `idx` and looked up by it: `placeholders[1]` is the placeholder
|
|
601
|
+
# whose idx is 1, not the second one in the collection.
|
|
602
|
+
class SlidePlaceholders < ParentedElementProxy
|
|
603
|
+
include Enumerable
|
|
604
|
+
|
|
605
|
+
def initialize(sp_tree, parent)
|
|
606
|
+
super
|
|
607
|
+
@sp_tree = sp_tree
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def each
|
|
611
|
+
return enum_for(:each) { size } unless block_given?
|
|
612
|
+
|
|
613
|
+
placeholder_elements.each { |e| yield ShapeFactory.build_for_slide(e, self) }
|
|
614
|
+
self
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def size = @sp_tree.placeholder_elements.count
|
|
618
|
+
alias length size
|
|
619
|
+
|
|
620
|
+
# The placeholder whose `idx` is +idx+, or nil.
|
|
621
|
+
def [](idx)
|
|
622
|
+
element = @sp_tree.placeholder_elements.find { |e| e.ph_idx == idx }
|
|
623
|
+
element && ShapeFactory.build_for_slide(element, self)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def fetch(idx)
|
|
627
|
+
self[idx] || raise(NotFoundError, "no placeholder on this slide with idx #{idx}")
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
private
|
|
631
|
+
|
|
632
|
+
def placeholder_elements = @sp_tree.placeholder_elements.sort_by(&:ph_idx)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pptx
|
|
4
|
+
# Array-like indexing for the collections that wrap their members lazily.
|
|
5
|
+
#
|
|
6
|
+
# Every collection in this library holds a list of XML elements and builds
|
|
7
|
+
# the Ruby object for one only when it is asked for. That makes `[]` a little
|
|
8
|
+
# more than a delegation: an integer index yields one wrapped member, while a
|
|
9
|
+
# range or a (start, length) pair yields an array of them.
|
|
10
|
+
#
|
|
11
|
+
# slides[2] #=> a Slide
|
|
12
|
+
# slides[-1] #=> the last Slide
|
|
13
|
+
# slides[1..3] #=> an Array of Slides
|
|
14
|
+
# slides[1, 2] #=> an Array of Slides
|
|
15
|
+
#
|
|
16
|
+
# Out of range gives nil, and a range that starts past the end gives nil
|
|
17
|
+
# rather than [], matching Array.
|
|
18
|
+
module Sliceable
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @param members [Array] the backing elements
|
|
22
|
+
# @yieldparam member [Object] one backing element, to be wrapped
|
|
23
|
+
# @return [Object, Array, nil]
|
|
24
|
+
def slice_members(members, index, length = nil, &)
|
|
25
|
+
if length.nil? && !index.is_a?(Range)
|
|
26
|
+
member = members[index]
|
|
27
|
+
return member && yield(member)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
selected = length.nil? ? members[index] : members[index, length]
|
|
31
|
+
selected&.map(&)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|