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,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