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,563 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/errors"
4
+ require "ruby_pptx/length"
5
+
6
+ module Pptx
7
+ module Oxml
8
+ # Scalar types that appear as XML attribute values.
9
+ #
10
+ # Each is a class exposing `from_xml` / `to_xml`, mirroring the simple
11
+ # types in the ECMA-376 schema. `to_xml` validates before converting, so an
12
+ # out-of-range value raises here rather than producing a file PowerPoint
13
+ # refuses to open.
14
+ module SimpleTypes
15
+ # Base for every simple type. Subclasses override `convert_from_xml`,
16
+ # `convert_to_xml` and `validate`; the class-method inheritance mirrors
17
+ # the Python original exactly.
18
+ class BaseSimpleType
19
+ class << self
20
+ def from_xml(xml_value) = convert_from_xml(xml_value)
21
+
22
+ def to_xml(value)
23
+ value = normalize(value)
24
+ validate(value)
25
+ convert_to_xml(value)
26
+ end
27
+
28
+ # python-pptx's `Emu` is an `int` subclass, so a length could be
29
+ # passed anywhere an integer was expected. Our {Pptx::Length} is a
30
+ # separate class, so unwrap it here and every type gains the same
31
+ # affordance: `shape.width = Pptx.inches(1)` works.
32
+ def normalize(value) = value.is_a?(Pptx::Length) ? value.emu : value
33
+
34
+ # Python's `round()` is round-half-to-even; Ruby's `Float#round`
35
+ # defaults to half-up. Every rounding conversion here goes through
36
+ # this helper so values landing exactly on .5 match python-pptx.
37
+ def round_half_even(value) = value.round(half: :even)
38
+
39
+ def validate_float(value)
40
+ return if value.is_a?(Numeric) && !value.is_a?(Complex)
41
+
42
+ raise TypeError, "value must be a number, got #{value.class}"
43
+ end
44
+
45
+ def validate_int(value)
46
+ return if value.is_a?(Integer)
47
+
48
+ raise TypeError, "value must be an integral type, got #{value.class}"
49
+ end
50
+
51
+ def validate_float_in_range(value, min_inclusive, max_inclusive)
52
+ validate_float(value)
53
+ return if value.between?(min_inclusive, max_inclusive)
54
+
55
+ raise RangeError,
56
+ "value must be in range #{min_inclusive} to #{max_inclusive} inclusive, " \
57
+ "got #{value}"
58
+ end
59
+
60
+ def validate_int_in_range(value, min_inclusive, max_inclusive)
61
+ validate_int(value)
62
+ return if value.between?(min_inclusive, max_inclusive)
63
+
64
+ raise RangeError,
65
+ "value must be in range #{min_inclusive} to #{max_inclusive} inclusive, " \
66
+ "got #{value}"
67
+ end
68
+
69
+ def validate_string(value)
70
+ return value if value.is_a?(String)
71
+
72
+ raise TypeError, "value must be a string, got #{value.class}"
73
+ end
74
+ end
75
+ end
76
+
77
+ class BaseFloatType < BaseSimpleType
78
+ class << self
79
+ def convert_from_xml(str_value) = Float(str_value)
80
+ def convert_to_xml(value) = Float(value).to_s
81
+ def validate(value) = validate_float(value)
82
+ end
83
+ end
84
+
85
+ class BaseIntType < BaseSimpleType
86
+ class << self
87
+ def convert_from_percent_literal(str_value) = Integer(str_value.delete("%"), 10)
88
+ def convert_from_xml(str_value) = Integer(str_value, 10)
89
+ def convert_to_xml(value) = value.to_i.to_s
90
+ def validate(value) = validate_int(value)
91
+ end
92
+ end
93
+
94
+ class BaseStringType < BaseSimpleType
95
+ class << self
96
+ def convert_from_xml(str_value) = str_value
97
+ def convert_to_xml(value) = value
98
+ def validate(value) = validate_string(value)
99
+ end
100
+ end
101
+
102
+ # A string type restricted to an enumerated set of values.
103
+ class BaseStringEnumerationType < BaseStringType
104
+ class << self
105
+ # Declare the permitted values, each also defined as a constant.
106
+ #
107
+ # values BAR: "bar", COL: "col"
108
+ def values(**mapping)
109
+ mapping.each { |name, value| const_set(name, value) }
110
+ @members = mapping.values.freeze
111
+ end
112
+
113
+ def members = @members || superclass.members
114
+
115
+ def validate(value)
116
+ validate_string(value)
117
+ return if members.include?(value)
118
+
119
+ raise ArgumentError, "must be one of #{members.inspect}, got #{value.inspect}"
120
+ end
121
+ end
122
+ end
123
+
124
+ # -- xsd built-ins ---------------------------------------------------
125
+
126
+ # Not validated against the URI grammar; the payoff does not justify it.
127
+ class XsdAnyUri < BaseStringType; end
128
+
129
+ class XsdBoolean < BaseSimpleType
130
+ class << self
131
+ def convert_from_xml(str_value)
132
+ unless %w[1 0 true false].include?(str_value)
133
+ raise InvalidXmlError,
134
+ "value must be one of '1', '0', 'true' or 'false', got #{str_value.inspect}"
135
+ end
136
+
137
+ %w[1 true].include?(str_value)
138
+ end
139
+
140
+ def convert_to_xml(value) = value ? "1" : "0"
141
+
142
+ def validate(value)
143
+ return if [true, false].include?(value)
144
+
145
+ raise TypeError, "only true or false may be assigned, got #{value.inspect}"
146
+ end
147
+ end
148
+ end
149
+
150
+ class XsdDouble < BaseFloatType; end
151
+
152
+ # Must start with a letter or underscore and contain no colon. Not fully
153
+ # validated because it is not reachable from the public API.
154
+ class XsdId < BaseStringType; end
155
+
156
+ class XsdInt < BaseIntType
157
+ def self.validate(value) = validate_int_in_range(value, -2_147_483_648, 2_147_483_647)
158
+ end
159
+
160
+ class XsdLong < BaseIntType
161
+ def self.validate(value)
162
+ validate_int_in_range(value, -9_223_372_036_854_775_808, 9_223_372_036_854_775_807)
163
+ end
164
+ end
165
+
166
+ class XsdString < BaseStringType; end
167
+ class XsdStringEnumeration < BaseStringEnumerationType; end
168
+
169
+ # xsd:string with whitespace collapsed.
170
+ class XsdToken < BaseStringType; end
171
+ class XsdTokenEnumeration < BaseStringEnumerationType; end
172
+
173
+ class XsdUnsignedByte < BaseIntType
174
+ def self.validate(value) = validate_int_in_range(value, 0, 255)
175
+ end
176
+
177
+ class XsdUnsignedInt < BaseIntType
178
+ def self.validate(value) = validate_int_in_range(value, 0, 4_294_967_295)
179
+ end
180
+
181
+ class XsdUnsignedShort < BaseIntType
182
+ def self.validate(value) = validate_int_in_range(value, 0, 65_535)
183
+ end
184
+
185
+ # -- ST_* types from the ECMA-376 schema ------------------------------
186
+
187
+ # `rot` on `<a:xfrm>`: 60000ths of a degree, exposed as float degrees.
188
+ class ST_Angle < XsdInt
189
+ DEGREE_INCREMENTS = 60_000
190
+ THREE_SIXTY = 360 * DEGREE_INCREMENTS
191
+
192
+ class << self
193
+ def convert_from_xml(str_value)
194
+ (Integer(str_value, 10) % THREE_SIXTY).to_f / DEGREE_INCREMENTS
195
+ end
196
+
197
+ # Normalized to a positive value; the modulo absorbs negative and
198
+ # greater-than-360 inputs.
199
+ def convert_to_xml(value)
200
+ (round_half_even(value * DEGREE_INCREMENTS) % THREE_SIXTY).to_s
201
+ end
202
+
203
+ def validate(value) = BaseFloatType.validate(value)
204
+ end
205
+ end
206
+
207
+ # `val` on c:majorUnit and friends.
208
+ class ST_AxisUnit < XsdDouble
209
+ def self.validate(value)
210
+ super
211
+ raise RangeError, "must be positive numeric value, got #{value}" if value <= 0.0
212
+ end
213
+ end
214
+
215
+ class ST_BarDir < XsdStringEnumeration
216
+ values BAR: "bar", COL: "col"
217
+ end
218
+
219
+ # Integer percent in 0..300, optionally written with a '%' suffix.
220
+ class ST_BubbleScale < BaseIntType
221
+ class << self
222
+ def convert_from_xml(str_value)
223
+ str_value.include?("%") ? convert_from_percent_literal(str_value) : super
224
+ end
225
+
226
+ def validate(value) = validate_int_in_range(value, 0, 300)
227
+ end
228
+ end
229
+
230
+ # The schema gives a fierce regular expression; matching it would catch
231
+ # programming errors only, so it is not enforced.
232
+ class ST_ContentType < XsdString; end
233
+
234
+ # xsd:union of ST_CoordinateUnqualified and ST_UniversalMeasure.
235
+ class ST_Coordinate < BaseSimpleType
236
+ class << self
237
+ def convert_from_xml(str_value)
238
+ if str_value.match?(/[imp]/)
239
+ ST_UniversalMeasure.convert_from_xml(str_value)
240
+ else
241
+ Pptx::Length.emu(Integer(str_value, 10))
242
+ end
243
+ end
244
+
245
+ def convert_to_xml(value) = value.to_i.to_s
246
+ def validate(value) = ST_CoordinateUnqualified.validate(value)
247
+ end
248
+ end
249
+
250
+ # xsd:union of ST_Coordinate32Unqualified and ST_UniversalMeasure.
251
+ class ST_Coordinate32 < BaseSimpleType
252
+ class << self
253
+ def convert_from_xml(str_value)
254
+ if str_value.match?(/[imp]/)
255
+ ST_UniversalMeasure.convert_from_xml(str_value)
256
+ else
257
+ ST_Coordinate32Unqualified.convert_from_xml(str_value)
258
+ end
259
+ end
260
+
261
+ def convert_to_xml(value) = ST_Coordinate32Unqualified.convert_to_xml(value)
262
+ def validate(value) = ST_Coordinate32Unqualified.validate(value)
263
+ end
264
+ end
265
+
266
+ class ST_Coordinate32Unqualified < XsdInt
267
+ def self.convert_from_xml(str_value) = Pptx::Length.emu(Integer(str_value, 10))
268
+ end
269
+
270
+ class ST_CoordinateUnqualified < XsdLong
271
+ def self.validate(value) = validate_int_in_range(value, -27_273_042_329_600, 27_273_042_316_900)
272
+ end
273
+
274
+ # `orient` on `<p:ph>`.
275
+ class ST_Direction < XsdTokenEnumeration
276
+ values HORZ: "horz", VERT: "vert"
277
+ end
278
+
279
+ class ST_DrawingElementId < XsdUnsignedInt; end
280
+
281
+ class ST_Extension < XsdString; end
282
+
283
+ # Integer percent in 0..500, optionally written with a '%' suffix.
284
+ class ST_GapAmount < BaseIntType
285
+ class << self
286
+ def convert_from_xml(str_value)
287
+ str_value.include?("%") ? convert_from_percent_literal(str_value) : super
288
+ end
289
+
290
+ def validate(value) = validate_int_in_range(value, 0, 500)
291
+ end
292
+ end
293
+
294
+ # `val` on <c:grouping>; doubles as ST_BarGrouping, which shares the tag.
295
+ class ST_Grouping < XsdStringEnumeration
296
+ values CLUSTERED: "clustered", PERCENT_STACKED: "percentStacked",
297
+ STACKED: "stacked", STANDARD: "standard"
298
+ end
299
+
300
+ class ST_HexColorRGB < BaseStringType
301
+ class << self
302
+ # Upper-cased purely for consistency of output.
303
+ def convert_to_xml(value) = value.upcase
304
+
305
+ def validate(value)
306
+ str_value = validate_string(value)
307
+ unless str_value.length == 6
308
+ raise ArgumentError, "RGB string must be six characters long, got #{str_value.inspect}"
309
+ end
310
+ return if str_value.match?(/\A\h{6}\z/)
311
+
312
+ raise ArgumentError, "RGB string must be a valid hex string, got #{str_value.inspect}"
313
+ end
314
+ end
315
+ end
316
+
317
+ # `val` on c:xMode and other CT_LayoutMode elements.
318
+ class ST_LayoutMode < XsdStringEnumeration
319
+ values EDGE: "edge", FACTOR: "factor"
320
+ end
321
+
322
+ # 0..1000 inclusive, optionally written with a '%' suffix.
323
+ class ST_LblOffset < XsdUnsignedShort
324
+ class << self
325
+ def convert_from_xml(str_value)
326
+ str_value.end_with?("%") ? convert_from_percent_literal(str_value) : Integer(str_value, 10)
327
+ end
328
+
329
+ def validate(value) = validate_int_in_range(value, 0, 1000)
330
+ end
331
+ end
332
+
333
+ class ST_LineWidth < XsdInt
334
+ class << self
335
+ def convert_from_xml(str_value) = Pptx::Length.emu(Integer(str_value, 10))
336
+
337
+ def validate(value)
338
+ super
339
+ return if value.between?(0, 20_116_800)
340
+
341
+ raise RangeError,
342
+ "value must be in range 0-20116800 inclusive (0-1584 points), got #{value}"
343
+ end
344
+ end
345
+ end
346
+
347
+ class ST_MarkerSize < XsdUnsignedByte
348
+ def self.validate(value) = validate_int_in_range(value, 2, 72)
349
+ end
350
+
351
+ # `val` on c:orientation (CT_Orientation).
352
+ class ST_Orientation < XsdStringEnumeration
353
+ values MAX_MIN: "maxMin", MIN_MAX: "minMax"
354
+ end
355
+
356
+ # Integer percent in -100..100, optionally written with a '%' suffix.
357
+ class ST_Overlap < BaseIntType
358
+ class << self
359
+ def convert_from_xml(str_value)
360
+ str_value.include?("%") ? convert_from_percent_literal(str_value) : super
361
+ end
362
+
363
+ def validate(value) = validate_int_in_range(value, -100, 100)
364
+ end
365
+ end
366
+
367
+ # Either an integer literal in 1000ths of a percent ("42000"), or a float
368
+ # with a '%' suffix ("42.0%"). Exposed as a float fraction, so 0.42.
369
+ class ST_Percentage < BaseIntType
370
+ class << self
371
+ def convert_from_xml(str_value)
372
+ return convert_from_percent_literal_float(str_value) if str_value.include?("%")
373
+
374
+ Integer(str_value, 10) / 100_000.0
375
+ end
376
+
377
+ def convert_to_xml(value) = round_half_even(value * 100_000.0).to_s
378
+
379
+ def validate(value) = validate_float_in_range(value, -21_474.83648, 21_474.83647)
380
+
381
+ def convert_from_percent_literal_float(str_value)
382
+ Float(str_value.delete_suffix("%")) / 100.0
383
+ end
384
+ end
385
+ end
386
+
387
+ # `sz` on <p:ph>.
388
+ class ST_PlaceholderSize < XsdTokenEnumeration
389
+ values FULL: "full", HALF: "half", QUARTER: "quarter"
390
+ end
391
+
392
+ class ST_PositiveCoordinate < XsdLong
393
+ class << self
394
+ def convert_from_xml(str_value) = Pptx::Length.emu(super)
395
+ def validate(value) = validate_int_in_range(value, 0, 27_273_042_316_900)
396
+ end
397
+ end
398
+
399
+ # `a:lin@ang`: positive angles less than 360 degrees.
400
+ class ST_PositiveFixedAngle < ST_Angle
401
+ def self.convert_to_xml(degrees)
402
+ if degrees < 0.0
403
+ degrees = (degrees % -360) + 360
404
+ elsif degrees > 0.0
405
+ degrees %= 360
406
+ end
407
+ round_half_even(degrees * DEGREE_INCREMENTS).to_s
408
+ end
409
+ end
410
+
411
+ # ST_Percentage constrained to 0.0..1.0.
412
+ class ST_PositiveFixedPercentage < ST_Percentage
413
+ def self.validate(value) = validate_float_in_range(value, 0.0, 1.0)
414
+ end
415
+
416
+ class ST_RelationshipId < XsdString; end
417
+
418
+ class ST_SlideId < XsdUnsignedInt
419
+ def self.validate(value) = validate_int_in_range(value, 256, 2_147_483_647)
420
+ end
421
+
422
+ # The 36 layout kinds ST_SlideLayoutType allows. PowerPoint uses this to
423
+ # decide which layout to offer for a given command, so an unrecognised
424
+ # value is rejected rather than written through.
425
+ class ST_SlideLayoutType < XsdString
426
+ VALUES = %w[
427
+ title tx twoColTx tbl txAndChart chartAndTx dgm chart txAndClipArt clipArtAndTx
428
+ titleOnly blank txAndObj objAndTx objOnly obj txAndMedia mediaAndTx objOverTx txOverObj
429
+ txAndTwoObj twoObjAndTx twoObjOverTx fourObj vertTx clipArtAndVertTx vertTitleAndTx
430
+ vertTitleAndTxOverChart twoObj objAndTwoObj twoObjAndObj cust secHead twoTxTwoObj objTx
431
+ picTx
432
+ ].freeze
433
+
434
+ def self.validate(value)
435
+ super
436
+ return if VALUES.include?(value)
437
+
438
+ raise ArgumentError, "#{value.inspect} is not a slide layout type"
439
+ end
440
+ end
441
+
442
+ # Slide-master and slide-layout ids share one range, which starts above
443
+ # the signed 32-bit maximum -- PowerPoint numbers them from 2147483648
444
+ # upwards precisely so they cannot be confused with slide ids.
445
+ class ST_SlideMasterId < XsdUnsignedInt
446
+ def self.validate(value) = validate_int_in_range(value, 2_147_483_648, 4_294_967_295)
447
+ end
448
+
449
+ class ST_SlideLayoutId < ST_SlideMasterId; end
450
+
451
+ class ST_SlideSizeCoordinate < BaseIntType
452
+ class << self
453
+ def convert_from_xml(str_value) = Pptx::Length.emu(Integer(str_value, 10))
454
+
455
+ def validate(value)
456
+ validate_int(value)
457
+ return if value.between?(914_400, 51_206_400)
458
+
459
+ raise RangeError,
460
+ "value must be in range 914400 to 51206400 (1-56 inches), got #{value}"
461
+ end
462
+ end
463
+ end
464
+
465
+ class ST_Style < XsdUnsignedByte
466
+ def self.validate(value) = validate_int_in_range(value, 1, 48)
467
+ end
468
+
469
+ # `TargetMode` on a Relationship element.
470
+ class ST_TargetMode < XsdString
471
+ EXTERNAL = "External"
472
+ INTERNAL = "Internal"
473
+
474
+ def self.validate(value)
475
+ validate_string(value)
476
+ return if [EXTERNAL, INTERNAL].include?(value)
477
+
478
+ raise ArgumentError, "must be one of 'Internal' or 'External', got #{value.inspect}"
479
+ end
480
+ end
481
+
482
+ # `fontScale` on <a:normAutofit>, as a float percent.
483
+ class ST_TextFontScalePercentOrPercentString < BaseFloatType
484
+ class << self
485
+ def convert_from_xml(str_value)
486
+ return Float(str_value.delete_suffix("%")) if str_value.end_with?("%")
487
+
488
+ Integer(str_value, 10) / 1000.0
489
+ end
490
+
491
+ def convert_to_xml(value) = (value * 1000.0).to_i.to_s
492
+
493
+ def validate(value)
494
+ BaseFloatType.validate(value)
495
+ return if value.between?(1.0, 100.0)
496
+
497
+ raise RangeError, "value must be in range 1.0..100.0 (percent), got #{value}"
498
+ end
499
+ end
500
+ end
501
+
502
+ class ST_TextFontSize < BaseIntType
503
+ def self.validate(value) = validate_int_in_range(value, 100, 400_000)
504
+ end
505
+
506
+ class ST_TextIndentLevelType < BaseIntType
507
+ def self.validate(value) = validate_int_in_range(value, 0, 8)
508
+ end
509
+
510
+ # Line spacing as a multiple of line height, so 1.75 <-> "175000".
511
+ class ST_TextSpacingPercentOrPercentString < BaseFloatType
512
+ class << self
513
+ def convert_from_xml(str_value)
514
+ return Float(str_value.delete_suffix("%")) / 100.0 if str_value.end_with?("%")
515
+
516
+ Integer(str_value, 10) / 100_000.0
517
+ end
518
+
519
+ def convert_to_xml(value) = round_half_even(value * 100_000.0).to_s
520
+
521
+ def validate(value) = validate_float_in_range(value, 0.0, 132.0)
522
+ end
523
+ end
524
+
525
+ # Reads centipoints, exposes a {Pptx::Length}.
526
+ class ST_TextSpacingPoint < BaseIntType
527
+ class << self
528
+ def convert_from_xml(str_value) = Pptx::Length.centipoints(Integer(str_value, 10))
529
+ def convert_to_xml(value) = Pptx::Length.emu(value).centipoints.to_s
530
+ def validate(value) = validate_int_in_range(value, 0, 20_116_800)
531
+ end
532
+ end
533
+
534
+ class ST_TextTypeface < XsdString; end
535
+
536
+ # `wrap` on <a:bodyPr>.
537
+ class ST_TextWrappingType < XsdTokenEnumeration
538
+ values NONE: "none", SQUARE: "square"
539
+ end
540
+
541
+ # A float with a two-character unit suffix, e.g. "1.5in".
542
+ class ST_UniversalMeasure < BaseSimpleType
543
+ MULTIPLIERS = {
544
+ "mm" => 36_000, "cm" => 360_000, "in" => 914_400,
545
+ "pt" => 12_700, "pc" => 152_400, "pi" => 152_400
546
+ }.freeze
547
+
548
+ class << self
549
+ def convert_from_xml(str_value)
550
+ units = str_value[-2..]
551
+ multiplier = MULTIPLIERS.fetch(units) do
552
+ raise InvalidXmlError, "unknown measurement unit #{units.inspect} in #{str_value.inspect}"
553
+ end
554
+ Pptx::Length.emu(round_half_even(Float(str_value[0...-2]) * multiplier))
555
+ end
556
+
557
+ def convert_to_xml(value) = value.to_i.to_s
558
+ def validate(value) = validate_int(value)
559
+ end
560
+ end
561
+ end
562
+ end
563
+ end