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,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_pptx/oxml/element"
|
|
4
|
+
require "ruby_pptx/oxml/content_model"
|
|
5
|
+
require "ruby_pptx/oxml/simple_types"
|
|
6
|
+
require "ruby_pptx/oxml/ns"
|
|
7
|
+
|
|
8
|
+
module Pptx
|
|
9
|
+
module Oxml
|
|
10
|
+
# Reads one of the XML templates shipped with the gem, e.g. "notes".
|
|
11
|
+
def self.parse_from_template(name)
|
|
12
|
+
path = File.expand_path("../templates/#{name}.xml", __dir__)
|
|
13
|
+
Element.parse(File.read(path))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Behaviour common to the six slide-like root elements.
|
|
17
|
+
module BaseSlideElement
|
|
18
|
+
# The `p:cSld/p:spTree` grandchild every slide type has.
|
|
19
|
+
def spTree = cSld.spTree
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `p:cSld`, the common slide data holding the shape tree.
|
|
23
|
+
class CT_CommonSlideData < Element
|
|
24
|
+
tag "p:cSld"
|
|
25
|
+
zero_or_one "p:bg", successors: %w[p:spTree p:custDataLst p:controls p:extLst]
|
|
26
|
+
one_and_only_one "p:spTree"
|
|
27
|
+
optional_attr "name", type: SimpleTypes::XsdString, default: ""
|
|
28
|
+
|
|
29
|
+
# The `p:bg/p:bgPr` grandchild, establishing a no-fill background first
|
|
30
|
+
# if there is not already an explicit one.
|
|
31
|
+
#
|
|
32
|
+
# This is destructive by nature: a background given by a style reference
|
|
33
|
+
# (`p:bgRef`) is replaced, and an inherited background stops being
|
|
34
|
+
# inherited. python-pptx behaves the same way.
|
|
35
|
+
def get_or_add_bgPr
|
|
36
|
+
background = bg
|
|
37
|
+
background = change_to_no_fill_bg if background.nil? || background.bgPr.nil?
|
|
38
|
+
background.bgPr
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def change_to_no_fill_bg
|
|
44
|
+
remove_bg
|
|
45
|
+
get_or_add_bg.tap(&:add_no_fill_bgPr)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `p:bg`, a slide background.
|
|
50
|
+
class CT_Background < Element
|
|
51
|
+
tag "p:bg"
|
|
52
|
+
# Strictly a choice rather than a sequence, but treating it as two
|
|
53
|
+
# optional children is simpler and behaves the same here.
|
|
54
|
+
zero_or_one "p:bgPr", successors: []
|
|
55
|
+
zero_or_one "p:bgRef", successors: []
|
|
56
|
+
|
|
57
|
+
def add_no_fill_bgPr
|
|
58
|
+
bgPr = build_from_xml(
|
|
59
|
+
%(<p:bgPr #{Ns.nsdecls("a", "p")}><a:noFill/><a:effectLst/></p:bgPr>)
|
|
60
|
+
)
|
|
61
|
+
insert_bgPr(bgPr)
|
|
62
|
+
bgPr
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `p:bgPr`, explicit background fill properties.
|
|
67
|
+
class CT_BackgroundProperties < Element
|
|
68
|
+
tag "p:bgPr"
|
|
69
|
+
FILL_SUCCESSORS = %w[a:effectLst a:effectDag a:extLst].freeze
|
|
70
|
+
|
|
71
|
+
zero_or_one_choice [choice("a:noFill"), choice("a:solidFill"), choice("a:gradFill"),
|
|
72
|
+
choice("a:blipFill"), choice("a:pattFill"), choice("a:grpFill")],
|
|
73
|
+
successors: FILL_SUCCESSORS, as: :eg_fillProperties
|
|
74
|
+
|
|
75
|
+
# A new gradient starts from PowerPoint's default rather than empty; an
|
|
76
|
+
# `a:gradFill` with no stops draws nothing.
|
|
77
|
+
def new_gradFill = CT_GradientFillProperties.new_grad_fill(self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `p:sld`, the root of a slide part.
|
|
81
|
+
class CT_Slide < Element
|
|
82
|
+
include BaseSlideElement
|
|
83
|
+
|
|
84
|
+
tag "p:sld"
|
|
85
|
+
one_and_only_one "p:cSld"
|
|
86
|
+
zero_or_one "p:clrMapOvr", successors: %w[p:transition p:timing p:extLst]
|
|
87
|
+
zero_or_one "p:timing", successors: %w[p:extLst]
|
|
88
|
+
|
|
89
|
+
# A new, empty `p:sld` with the minimum structure PowerPoint requires.
|
|
90
|
+
def self.new_element = Element.parse(SLD_XML)
|
|
91
|
+
|
|
92
|
+
def bg = cSld.bg
|
|
93
|
+
|
|
94
|
+
# The node list a `p:video` entry hangs off, which is what makes
|
|
95
|
+
# PowerPoint show play controls under a movie.
|
|
96
|
+
#
|
|
97
|
+
# The precondition varies -- a slide may have no timing at all, or one
|
|
98
|
+
# already -- so an awkward case is replaced wholesale rather than
|
|
99
|
+
# patched. That can in principle discard existing timing information;
|
|
100
|
+
# python-pptx takes the same view.
|
|
101
|
+
def get_or_add_child_time_node_list
|
|
102
|
+
existing = xpath("./p:timing/p:tnLst/p:par/p:cTn/p:childTnLst").first
|
|
103
|
+
return existing if existing
|
|
104
|
+
|
|
105
|
+
remove(get_or_add_timing)
|
|
106
|
+
timing = build_from_xml(TIMING_XML)
|
|
107
|
+
insert_timing(timing)
|
|
108
|
+
timing.xpath("./p:tnLst/p:par/p:cTn/p:childTnLst").first
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
TIMING_XML = <<~XML.freeze
|
|
112
|
+
<p:timing #{Ns.nsdecls("p")}>
|
|
113
|
+
<p:tnLst>
|
|
114
|
+
<p:par>
|
|
115
|
+
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
|
|
116
|
+
<p:childTnLst/>
|
|
117
|
+
</p:cTn>
|
|
118
|
+
</p:par>
|
|
119
|
+
</p:tnLst>
|
|
120
|
+
</p:timing>
|
|
121
|
+
XML
|
|
122
|
+
|
|
123
|
+
SLD_XML = <<~XML.freeze
|
|
124
|
+
<p:sld #{Ns.nsdecls("a", "p", "r")}>
|
|
125
|
+
<p:cSld>
|
|
126
|
+
<p:spTree>
|
|
127
|
+
<p:nvGrpSpPr>
|
|
128
|
+
<p:cNvPr id="1" name=""/>
|
|
129
|
+
<p:cNvGrpSpPr/>
|
|
130
|
+
<p:nvPr/>
|
|
131
|
+
</p:nvGrpSpPr>
|
|
132
|
+
<p:grpSpPr/>
|
|
133
|
+
</p:spTree>
|
|
134
|
+
</p:cSld>
|
|
135
|
+
<p:clrMapOvr>
|
|
136
|
+
<a:masterClrMapping/>
|
|
137
|
+
</p:clrMapOvr>
|
|
138
|
+
</p:sld>
|
|
139
|
+
XML
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `p:sldLayout`, the root of a slide-layout part.
|
|
143
|
+
class CT_SlideLayout < Element
|
|
144
|
+
include BaseSlideElement
|
|
145
|
+
|
|
146
|
+
tag "p:sldLayout"
|
|
147
|
+
one_and_only_one "p:cSld"
|
|
148
|
+
zero_or_one "p:clrMapOvr", successors: %w[p:transition p:timing p:hf p:extLst]
|
|
149
|
+
optional_attr "type", type: SimpleTypes::ST_SlideLayoutType
|
|
150
|
+
optional_attr "preserve", type: SimpleTypes::XsdBoolean
|
|
151
|
+
|
|
152
|
+
# A new `p:sldLayout` named +name+, inheriting its colour map from the
|
|
153
|
+
# master. `preserve` is set because a layout with no slides using it is
|
|
154
|
+
# otherwise discarded by PowerPoint when it tidies up.
|
|
155
|
+
def self.new_element(name, layout_type)
|
|
156
|
+
layout = Element.parse(SLD_LAYOUT_XML)
|
|
157
|
+
layout.cSld.name = name
|
|
158
|
+
layout.type = layout_type unless layout_type.nil?
|
|
159
|
+
layout
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
SLD_LAYOUT_XML = <<~XML.freeze
|
|
163
|
+
<p:sldLayout #{Ns.nsdecls("a", "p", "r")} preserve="1">
|
|
164
|
+
<p:cSld>
|
|
165
|
+
<p:spTree>
|
|
166
|
+
<p:nvGrpSpPr>
|
|
167
|
+
<p:cNvPr id="1" name=""/>
|
|
168
|
+
<p:cNvGrpSpPr/>
|
|
169
|
+
<p:nvPr/>
|
|
170
|
+
</p:nvGrpSpPr>
|
|
171
|
+
<p:grpSpPr/>
|
|
172
|
+
</p:spTree>
|
|
173
|
+
</p:cSld>
|
|
174
|
+
<p:clrMapOvr>
|
|
175
|
+
<a:masterClrMapping/>
|
|
176
|
+
</p:clrMapOvr>
|
|
177
|
+
</p:sldLayout>
|
|
178
|
+
XML
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# `p:sldMaster`, the root of a slide-master part.
|
|
182
|
+
class CT_SlideMaster < Element
|
|
183
|
+
include BaseSlideElement
|
|
184
|
+
|
|
185
|
+
tag "p:sldMaster"
|
|
186
|
+
one_and_only_one "p:cSld"
|
|
187
|
+
one_and_only_one "p:clrMap"
|
|
188
|
+
zero_or_one "p:sldLayoutIdLst",
|
|
189
|
+
successors: %w[p:transition p:timing p:hf p:txStyles p:extLst]
|
|
190
|
+
zero_or_one "p:txStyles", successors: %w[p:extLst]
|
|
191
|
+
|
|
192
|
+
# A new `p:sldMaster` carrying the colour map and text styles a master
|
|
193
|
+
# cannot do without, an empty shape tree and no layouts.
|
|
194
|
+
def self.new_element = Oxml.parse_from_template("slideMaster")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `p:clrMap`, which binds each of a slide's colour roles to a theme colour.
|
|
198
|
+
class CT_ColorMapping < Element
|
|
199
|
+
tag "p:clrMap"
|
|
200
|
+
ROLES = %w[bg1 tx1 bg2 tx2 accent1 accent2 accent3 accent4 accent5
|
|
201
|
+
accent6 hlink folHlink].freeze
|
|
202
|
+
ROLES.each { |role| required_attr role, type: SimpleTypes::XsdString }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# `p:txStyles`, the master's default text styling per outline level.
|
|
206
|
+
class CT_SlideMasterTextStyles < Element
|
|
207
|
+
tag "p:txStyles"
|
|
208
|
+
zero_or_one "p:titleStyle", successors: %w[p:bodyStyle p:otherStyle p:extLst]
|
|
209
|
+
zero_or_one "p:bodyStyle", successors: %w[p:otherStyle p:extLst]
|
|
210
|
+
zero_or_one "p:otherStyle", successors: %w[p:extLst]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# `p:sldLayoutIdLst`, the layouts inheriting from a slide master.
|
|
214
|
+
class CT_SlideLayoutIdList < Element
|
|
215
|
+
tag "p:sldLayoutIdLst"
|
|
216
|
+
zero_or_more "p:sldLayoutId", as: :sldLayoutId
|
|
217
|
+
|
|
218
|
+
def size = sldLayoutId_list.size
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# `p:sldLayoutId`, a reference to one slide layout.
|
|
222
|
+
class CT_SlideLayoutIdListEntry < Element
|
|
223
|
+
tag "p:sldLayoutId"
|
|
224
|
+
required_attr "r:id", type: SimpleTypes::XsdString, as: :rId
|
|
225
|
+
optional_attr "id", type: SimpleTypes::ST_SlideLayoutId
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# `p:notesMaster`, the root of the notes-master part.
|
|
229
|
+
class CT_NotesMaster < Element
|
|
230
|
+
include BaseSlideElement
|
|
231
|
+
|
|
232
|
+
tag "p:notesMaster"
|
|
233
|
+
one_and_only_one "p:cSld"
|
|
234
|
+
|
|
235
|
+
def self.new_default = Oxml.parse_from_template("notesMaster")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# `p:notes`, the root of a notes-slide part.
|
|
239
|
+
#
|
|
240
|
+
# The template carries no placeholders; those are cloned from the notes
|
|
241
|
+
# master once the shape layer exists.
|
|
242
|
+
class CT_NotesSlide < Element
|
|
243
|
+
include BaseSlideElement
|
|
244
|
+
|
|
245
|
+
tag "p:notes"
|
|
246
|
+
one_and_only_one "p:cSld"
|
|
247
|
+
|
|
248
|
+
def self.new_element = Oxml.parse_from_template("notes")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# `p:timing`, animation and timing information.
|
|
252
|
+
class CT_SlideTiming < Element
|
|
253
|
+
tag "p:timing"
|
|
254
|
+
zero_or_one "p:tnLst", successors: %w[p:bldLst p:extLst]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# `p:tnLst` and `p:childTnLst`, lists of timing nodes.
|
|
258
|
+
class CT_TimeNodeList < Element
|
|
259
|
+
tag "p:tnLst", "p:childTnLst"
|
|
260
|
+
|
|
261
|
+
# Register the movie with shape id +shape_id+ so its play controls
|
|
262
|
+
# appear.
|
|
263
|
+
def add_video(shape_id)
|
|
264
|
+
append(build_from_xml(<<~XML))
|
|
265
|
+
<p:video #{Ns.nsdecls("p")}>
|
|
266
|
+
<p:cMediaNode vol="80000">
|
|
267
|
+
<p:cTn id="#{next_time_node_id}" fill="hold" display="0">
|
|
268
|
+
<p:stCondLst>
|
|
269
|
+
<p:cond delay="indefinite"/>
|
|
270
|
+
</p:stCondLst>
|
|
271
|
+
</p:cTn>
|
|
272
|
+
<p:tgtEl>
|
|
273
|
+
<p:spTgt spid="#{shape_id}"/>
|
|
274
|
+
</p:tgtEl>
|
|
275
|
+
</p:cMediaNode>
|
|
276
|
+
</p:video>
|
|
277
|
+
XML
|
|
278
|
+
self
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
# Timing node ids are scoped to the slide, so every existing one counts.
|
|
284
|
+
def next_time_node_id
|
|
285
|
+
used = xpath("/p:sld/p:timing//p:cTn/@id").map { |attr| attr.value.to_i }
|
|
286
|
+
(used.max || 0) + 1
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_pptx/oxml/element"
|
|
4
|
+
require "ruby_pptx/oxml/content_model"
|
|
5
|
+
require "ruby_pptx/oxml/simple_types"
|
|
6
|
+
require "ruby_pptx/oxml/text"
|
|
7
|
+
require "ruby_pptx/enum/text"
|
|
8
|
+
|
|
9
|
+
module Pptx
|
|
10
|
+
module Oxml
|
|
11
|
+
# `a:gridCol`, one column of the table grid.
|
|
12
|
+
class CT_TableCol < Element
|
|
13
|
+
tag "a:gridCol"
|
|
14
|
+
required_attr "w", type: SimpleTypes::ST_Coordinate
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `a:tblGrid`, the column widths of a table.
|
|
18
|
+
class CT_TableGrid < Element
|
|
19
|
+
tag "a:tblGrid"
|
|
20
|
+
zero_or_more "a:gridCol", as: :gridCol
|
|
21
|
+
|
|
22
|
+
def add_grid_col(width) = add_gridCol(w: width)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `a:tblPr`, whole-table properties such as banding and header rows.
|
|
26
|
+
class CT_TableProperties < Element
|
|
27
|
+
tag "a:tblPr"
|
|
28
|
+
%w[bandRow bandCol firstRow firstCol lastRow lastCol].each do |name|
|
|
29
|
+
optional_attr name, type: SimpleTypes::XsdBoolean, default: false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `a:tcPr`, the properties of one cell.
|
|
34
|
+
class CT_TableCellProperties < Element
|
|
35
|
+
tag "a:tcPr"
|
|
36
|
+
FILL_SUCCESSORS = %w[a:headers a:extLst].freeze
|
|
37
|
+
|
|
38
|
+
zero_or_one_choice Oxml::FILL_CHOICES.map { |t| choice(t) },
|
|
39
|
+
successors: FILL_SUCCESSORS, as: :eg_fillProperties
|
|
40
|
+
optional_attr "anchor", type: Enum::MSO_VERTICAL_ANCHOR
|
|
41
|
+
optional_attr "marL", type: SimpleTypes::ST_Coordinate32, default: Pptx::Length.emu(91_440)
|
|
42
|
+
optional_attr "marR", type: SimpleTypes::ST_Coordinate32, default: Pptx::Length.emu(91_440)
|
|
43
|
+
optional_attr "marT", type: SimpleTypes::ST_Coordinate32, default: Pptx::Length.emu(45_720)
|
|
44
|
+
optional_attr "marB", type: SimpleTypes::ST_Coordinate32, default: Pptx::Length.emu(45_720)
|
|
45
|
+
|
|
46
|
+
# A new gradient starts from PowerPoint's default rather than empty; an
|
|
47
|
+
# `a:gradFill` with no stops draws nothing.
|
|
48
|
+
def new_gradFill = CT_GradientFillProperties.new_grad_fill(self)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `a:tc`, one cell of a table.
|
|
52
|
+
class CT_TableCell < Element
|
|
53
|
+
tag "a:tc"
|
|
54
|
+
zero_or_one "a:txBody", successors: %w[a:tcPr a:extLst]
|
|
55
|
+
zero_or_one "a:tcPr", successors: %w[a:extLst]
|
|
56
|
+
optional_attr "gridSpan", type: SimpleTypes::XsdInt, default: 1
|
|
57
|
+
optional_attr "rowSpan", type: SimpleTypes::XsdInt, default: 1
|
|
58
|
+
optional_attr "hMerge", type: SimpleTypes::XsdBoolean, default: false
|
|
59
|
+
optional_attr "vMerge", type: SimpleTypes::XsdBoolean, default: false
|
|
60
|
+
|
|
61
|
+
CELL_XML = <<~XML.freeze
|
|
62
|
+
<a:tc #{Ns.nsdecls("a")}>
|
|
63
|
+
<a:txBody>
|
|
64
|
+
<a:bodyPr/>
|
|
65
|
+
<a:lstStyle/>
|
|
66
|
+
<a:p/>
|
|
67
|
+
</a:txBody>
|
|
68
|
+
<a:tcPr/>
|
|
69
|
+
</a:tc>
|
|
70
|
+
XML
|
|
71
|
+
|
|
72
|
+
# A cell always has a text body, so a new one is created with it rather
|
|
73
|
+
# than empty.
|
|
74
|
+
def self.new_cell(context) = context.build_from_xml(CELL_XML)
|
|
75
|
+
|
|
76
|
+
# The `a:tr` this cell sits in.
|
|
77
|
+
def tr = parent
|
|
78
|
+
|
|
79
|
+
# The `a:tbl` this cell belongs to.
|
|
80
|
+
def tbl = tr.parent
|
|
81
|
+
|
|
82
|
+
def col_idx = tr.tc_list.index(self)
|
|
83
|
+
|
|
84
|
+
def row_idx = tbl.tr_list.index(tr)
|
|
85
|
+
|
|
86
|
+
# True when this is the top-left cell of a merged range.
|
|
87
|
+
#
|
|
88
|
+
# Only the origin carries the full span; the cells it covers are marked
|
|
89
|
+
# hMerge or vMerge and keep spans of 1.
|
|
90
|
+
def merge_origin?
|
|
91
|
+
return true if gridSpan > 1 && !vMerge
|
|
92
|
+
|
|
93
|
+
rowSpan > 1 && !hMerge
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when this cell is covered by a merge rather than being its origin.
|
|
97
|
+
def spanned? = hMerge || vMerge
|
|
98
|
+
|
|
99
|
+
# Take the paragraphs from +other+ into this cell's text body.
|
|
100
|
+
#
|
|
101
|
+
# An empty source contributes nothing; a single empty paragraph in the
|
|
102
|
+
# target is replaced rather than appended to; and the source is left with
|
|
103
|
+
# one empty paragraph, since the schema requires at least one.
|
|
104
|
+
def append_paragraphs_from(other)
|
|
105
|
+
source = other.get_or_add_txBody
|
|
106
|
+
target = get_or_add_txBody
|
|
107
|
+
return self if source.empty?
|
|
108
|
+
|
|
109
|
+
target.clear_content if target.empty?
|
|
110
|
+
source.p_list.each { |paragraph| target.append(paragraph) }
|
|
111
|
+
source.unclear_content
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# `a:tr`, one row of a table.
|
|
117
|
+
class CT_TableRow < Element
|
|
118
|
+
tag "a:tr"
|
|
119
|
+
zero_or_more "a:tc", successors: %w[a:extLst], as: :tc
|
|
120
|
+
required_attr "h", type: SimpleTypes::ST_Coordinate
|
|
121
|
+
|
|
122
|
+
def new_tc = CT_TableCell.new_cell(self)
|
|
123
|
+
|
|
124
|
+
def add_cell = add_tc
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `a:tbl`, a table.
|
|
128
|
+
class CT_Table < Element
|
|
129
|
+
tag "a:tbl"
|
|
130
|
+
zero_or_one "a:tblPr", successors: %w[a:tblGrid a:tr]
|
|
131
|
+
one_and_only_one "a:tblGrid"
|
|
132
|
+
zero_or_more "a:tr", successors: [], as: :tr
|
|
133
|
+
|
|
134
|
+
# The style PowerPoint applies to a table created through the UI.
|
|
135
|
+
DEFAULT_TABLE_STYLE_ID = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
|
|
136
|
+
|
|
137
|
+
def self.tbl_xml(table_style_id)
|
|
138
|
+
<<~XML
|
|
139
|
+
<a:tbl #{Ns.nsdecls("a")}>
|
|
140
|
+
<a:tblPr firstRow="1" bandRow="1">
|
|
141
|
+
<a:tableStyleId>#{table_style_id}</a:tableStyleId>
|
|
142
|
+
</a:tblPr>
|
|
143
|
+
<a:tblGrid/>
|
|
144
|
+
</a:tbl>
|
|
145
|
+
XML
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A new `a:tbl` of +rows+ by +cols+ filling the given width and height.
|
|
149
|
+
#
|
|
150
|
+
# The width is divided evenly between columns and the height between
|
|
151
|
+
# rows; the last of each absorbs the rounding remainder so the totals
|
|
152
|
+
# come out exactly.
|
|
153
|
+
def self.new_tbl(context, rows, cols, width, height, table_style_id = DEFAULT_TABLE_STYLE_ID)
|
|
154
|
+
width = Pptx::Length.coerce(width).emu
|
|
155
|
+
height = Pptx::Length.coerce(height).emu
|
|
156
|
+
tbl = context.build_from_xml(tbl_xml(table_style_id))
|
|
157
|
+
|
|
158
|
+
col_width = width / cols
|
|
159
|
+
cols.times do |col|
|
|
160
|
+
this_width = col == cols - 1 ? width - ((cols - 1) * col_width) : col_width
|
|
161
|
+
tbl.tblGrid.add_grid_col(Pptx::Length.emu(this_width))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
row_height = height / rows
|
|
165
|
+
rows.times do |row|
|
|
166
|
+
this_height = row == rows - 1 ? height - ((rows - 1) * row_height) : row_height
|
|
167
|
+
tr = tbl.add_tr(h: Pptx::Length.emu(this_height))
|
|
168
|
+
cols.times { tr.add_cell }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
tbl
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The cell at +row_idx+, +col_idx+.
|
|
175
|
+
def tc(row_idx, col_idx) = tr_list[row_idx].tc_list[col_idx]
|
|
176
|
+
|
|
177
|
+
# The rectangle of cells spanned by the two opposite corners +a+ and +b+.
|
|
178
|
+
#
|
|
179
|
+
# The corners may be given in either order and along either diagonal, so
|
|
180
|
+
# the extents are normalised here.
|
|
181
|
+
def cell_range(a, b)
|
|
182
|
+
top, bottom = [a.row_idx, b.row_idx].minmax
|
|
183
|
+
left, right = [a.col_idx, b.col_idx].minmax
|
|
184
|
+
CellRange.new(self, top, left, bottom, right)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
%w[firstRow firstCol lastRow lastCol bandRow bandCol].each do |name|
|
|
188
|
+
define_method(name) { tblPr ? tblPr.public_send(name) : false }
|
|
189
|
+
|
|
190
|
+
define_method("#{name}=") do |value|
|
|
191
|
+
get_or_add_tblPr.public_send("#{name}=", value)
|
|
192
|
+
value
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A rectangular block of `a:tc` elements, used when merging and splitting.
|
|
198
|
+
#
|
|
199
|
+
# It assumes the table's structure does not change while it is alive, so
|
|
200
|
+
# create one, use it, and let it go.
|
|
201
|
+
class CellRange
|
|
202
|
+
attr_reader :row_count, :column_count
|
|
203
|
+
|
|
204
|
+
def initialize(tbl, top, left, bottom, right)
|
|
205
|
+
@tbl = tbl
|
|
206
|
+
@top = top
|
|
207
|
+
@left = left
|
|
208
|
+
@bottom = bottom
|
|
209
|
+
@right = right
|
|
210
|
+
@row_count = bottom - top + 1
|
|
211
|
+
@column_count = right - left + 1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The range a merge-origin cell already covers.
|
|
215
|
+
def self.from_merge_origin(tc)
|
|
216
|
+
tc.tbl.cell_range(tc, tc.tbl.tc(tc.row_idx + tc.rowSpan - 1,
|
|
217
|
+
tc.col_idx + tc.gridSpan - 1))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Every cell in the range, left to right then top to bottom.
|
|
221
|
+
def cells
|
|
222
|
+
(@top..@bottom).flat_map { |row| (@left..@right).map { |col| @tbl.tc(row, col) } }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def top_row_cells = (@left..@right).map { |col| @tbl.tc(@top, col) }
|
|
226
|
+
|
|
227
|
+
def left_column_cells = (@top..@bottom).map { |row| @tbl.tc(row, @left) }
|
|
228
|
+
|
|
229
|
+
def cells_except_left_column
|
|
230
|
+
(@top..@bottom).flat_map { |row| ((@left + 1)..@right).map { |col| @tbl.tc(row, col) } }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def cells_except_top_row
|
|
234
|
+
((@top + 1)..@bottom).flat_map { |row| (@left..@right).map { |col| @tbl.tc(row, col) } }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# True when any cell in the range is already part of a merge. Merging
|
|
238
|
+
# over an existing merge would produce a table PowerPoint cannot lay out.
|
|
239
|
+
def contains_merged_cell?
|
|
240
|
+
cells.any? { |tc| tc.gridSpan > 1 || tc.rowSpan > 1 || tc.hMerge || tc.vMerge }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Gather the text of the whole range into its top-left cell, which is
|
|
244
|
+
# the one that stays visible.
|
|
245
|
+
def move_content_to_origin
|
|
246
|
+
all = cells
|
|
247
|
+
origin = all.first
|
|
248
|
+
all.drop(1).each { |spanned| origin.append_paragraphs_from(spanned) }
|
|
249
|
+
origin
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|