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,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/errors"
4
+ require "ruby_pptx/oxml/element"
5
+ require "ruby_pptx/opc/constants"
6
+ require "ruby_pptx/opc/oxml"
7
+ require "ruby_pptx/opc/pack_uri"
8
+ require "ruby_pptx/opc/serialized"
9
+
10
+ module Pptx
11
+ module Opc
12
+ # Relationship behaviour shared by the package and every part.
13
+ module Relatable
14
+ # The single part related to this one by +reltype+.
15
+ #
16
+ # @raise [NotFoundError] when there is no such relationship
17
+ # @raise [Error] when there is more than one
18
+ def part_related_by(reltype) = rels.part_with_reltype(reltype)
19
+
20
+ # Relate this part to +target+, returning the rId. An existing matching
21
+ # relationship is reused rather than duplicated.
22
+ #
23
+ # @param target [Part, String] a part, or a URL when +external:+
24
+ def relate_to(target, reltype, external: false)
25
+ if external
26
+ rels.get_or_add_external(reltype, target)
27
+ else
28
+ rels.get_or_add(reltype, target)
29
+ end
30
+ end
31
+
32
+ def related_part(r_id) = rels.fetch(r_id).target_part
33
+
34
+ def target_ref(r_id) = rels.fetch(r_id).target_ref
35
+
36
+ # @return [Relationships]
37
+ def rels = raise(NotImplementedError, "#{self.class} must implement #rels")
38
+ end
39
+
40
+ # An OPC package: the object graph behind a .pptx file.
41
+ class OpcPackage
42
+ include Relatable
43
+
44
+ # Open a package from a path, an IO stream, or a directory holding an
45
+ # unzipped package.
46
+ def self.open(pkg_file) = new(pkg_file).load
47
+
48
+ def initialize(pkg_file = nil)
49
+ @pkg_file = pkg_file
50
+ end
51
+
52
+ def rels = @rels ||= Relationships.new(PackURI::PACKAGE.base_uri)
53
+
54
+ def drop_rel(r_id) = rels.delete(r_id)
55
+
56
+ # The presentation part, which is the package's main document part.
57
+ def main_document_part = part_related_by(RELATIONSHIP_TYPE::OFFICE_DOCUMENT)
58
+
59
+ # Every part in the package, each yielded once.
60
+ def parts
61
+ return enum_for(:parts) unless block_given?
62
+
63
+ seen = {}.compare_by_identity
64
+ each_rel do |rel|
65
+ next if rel.external?
66
+
67
+ part = rel.target_part
68
+ next if seen.key?(part)
69
+
70
+ seen[part] = true
71
+ yield part
72
+ end
73
+ end
74
+
75
+ # Every relationship in the package, each yielded once, depth first.
76
+ def each_rel(&block)
77
+ return enum_for(:each_rel) unless block
78
+
79
+ visited = {}.compare_by_identity
80
+ walk = lambda do |relationships|
81
+ relationships.each_value do |rel|
82
+ block.call(rel)
83
+ next if rel.external?
84
+
85
+ part = rel.target_part
86
+ next if visited.key?(part)
87
+
88
+ visited[part] = true
89
+ walk.call(part.rels)
90
+ end
91
+ end
92
+ walk.call(rels)
93
+ end
94
+
95
+ # The next unused partname matching +template+, which contains a single
96
+ # "%d", e.g. "/ppt/slides/slide%d.xml".
97
+ def next_partname(template)
98
+ prefix = template.split("%d").first
99
+ taken = parts.each_with_object({}) do |part, names|
100
+ name = part.partname.to_s
101
+ names[name] = true if name.start_with?(prefix)
102
+ end
103
+
104
+ # Count down from the optimistic answer: in the common case every
105
+ # number from 1 is in use and the first candidate is the right one.
106
+ (taken.size + 1).downto(1) do |n|
107
+ candidate = format(template, n)
108
+ return PackURI.new(candidate) unless taken.key?(candidate)
109
+ end
110
+ raise Error, "ran out of candidate partnames for #{template}"
111
+ end
112
+
113
+ def save(pkg_file)
114
+ PackageWriter.write(pkg_file, rels, parts.to_a)
115
+ self
116
+ end
117
+
118
+ def load
119
+ xml_rels, parts_by_name = PackageLoader.load(@pkg_file, self)
120
+ rels.load_from_xml(PackURI::PACKAGE.base_uri, xml_rels, parts_by_name)
121
+ self
122
+ end
123
+ end
124
+
125
+ # Loads a package from its serialized form.
126
+ class PackageLoader
127
+ # @return [Array(Oxml::CT_Relationships, Hash{String=>Part})]
128
+ def self.load(pkg_file, package) = new(pkg_file, package).load
129
+
130
+ def initialize(pkg_file, package)
131
+ @pkg_file = pkg_file
132
+ @package = package
133
+ end
134
+
135
+ def load
136
+ parts = build_parts
137
+ parts.each { |partname, part| part.load_rels_from_xml(xml_rels[partname], parts) }
138
+ [xml_rels[PackURI::PACKAGE.to_s], parts]
139
+ end
140
+
141
+ private
142
+
143
+ def reader = @reader ||= PackageReader.new(@pkg_file)
144
+
145
+ def content_types
146
+ @content_types ||= ContentTypeMap.from_xml(reader[PackURI::CONTENT_TYPES])
147
+ end
148
+
149
+ # {partname => Part}, every part constructed but with relationships not
150
+ # yet resolved -- that needs the whole collection to exist first.
151
+ def build_parts
152
+ xml_rels.keys.each_with_object({}) do |partname, parts|
153
+ next if partname == PackURI::PACKAGE.to_s
154
+
155
+ uri = PackURI.new(partname)
156
+ # Some packages in the wild reference parts that are not present.
157
+ # Skip them rather than refusing to open the file at all.
158
+ next unless reader.key?(uri)
159
+
160
+ parts[partname] = PartFactory.build(uri, content_types[uri], @package, reader[uri])
161
+ end
162
+ end
163
+
164
+ # {partname => CT_Relationships} for the package and every part, found by
165
+ # walking the relationship graph depth first from the package itself.
166
+ def xml_rels
167
+ @xml_rels ||= begin
168
+ collected = {}
169
+ visit = lambda do |source_partname, rels_element|
170
+ collected[source_partname.to_s] = rels_element
171
+ base_uri = source_partname.base_uri
172
+
173
+ rels_element.relationship_list.each do |rel|
174
+ next if rel.external?
175
+
176
+ target = PackURI.from_rel_ref(base_uri, rel.target_ref)
177
+ next if collected.key?(target.to_s)
178
+
179
+ visit.call(target, xml_rels_for(target))
180
+ end
181
+ end
182
+ visit.call(PackURI::PACKAGE, xml_rels_for(PackURI::PACKAGE))
183
+ collected
184
+ end
185
+ end
186
+
187
+ # A part with no relationships gets an empty <Relationships> element
188
+ # rather than nil, so callers never branch on absence.
189
+ def xml_rels_for(partname)
190
+ rels_xml = reader.rels_xml_for(partname)
191
+ rels_xml.nil? ? Oxml::CT_Relationships.new_element : Pptx::Oxml::Element.parse(rels_xml)
192
+ end
193
+ end
194
+
195
+ # A part in the package. Subclassed to give particular content types their
196
+ # own behaviour; used directly for parts with none.
197
+ class Part
198
+ include Relatable
199
+
200
+ attr_reader :partname, :content_type, :package
201
+
202
+ def self.load(partname, content_type, package, blob) = new(partname, content_type, package, blob)
203
+
204
+ def initialize(partname, content_type, package, blob = nil)
205
+ @partname = partname
206
+ @content_type = content_type
207
+ @package = package
208
+ @blob = blob
209
+ end
210
+
211
+ def partname=(partname)
212
+ raise TypeError, "partname must be a PackURI, got #{partname.class}" unless partname.is_a?(PackURI)
213
+
214
+ @partname = partname
215
+ end
216
+
217
+ # @return [String] the bytes this part serializes to
218
+ def blob = @blob || ""
219
+
220
+ attr_writer :blob
221
+
222
+ def rels = @rels ||= Relationships.new(@partname.base_uri)
223
+
224
+ def load_rels_from_xml(xml_rels, parts)
225
+ rels.load_from_xml(@partname.base_uri, xml_rels, parts)
226
+ end
227
+
228
+ def drop_rel(r_id) = rels.delete(r_id)
229
+
230
+ def inspect = "#<#{self.class.name} #{@partname}>"
231
+ end
232
+
233
+ # A part whose payload is XML, which is most of them.
234
+ class XmlPart < Part
235
+ attr_reader :element
236
+
237
+ def self.load(partname, content_type, package, blob)
238
+ new(partname, content_type, package, Pptx::Oxml::Element.parse(blob))
239
+ end
240
+
241
+ def initialize(partname, content_type, package, element)
242
+ super(partname, content_type, package)
243
+ @element = element
244
+ end
245
+
246
+ def blob = Oxml.serialize_part_xml(@element)
247
+
248
+ # This part. Objects further down the tree delegate up to find the part
249
+ # they live in; the chain ends here.
250
+ def part = self
251
+
252
+ # Drop a relationship unless the XML still refers to it more than once.
253
+ #
254
+ # A reference count of 0 means an implicit relationship, which is safe to
255
+ # drop. Only XML parts can do this, since only they can be searched.
256
+ def drop_rel(r_id)
257
+ rels.delete(r_id) if rel_ref_count(r_id) < 2
258
+ end
259
+
260
+ private
261
+
262
+ def rel_ref_count(r_id)
263
+ @element.xpath("//@r:id").count { |attr| attr.value == r_id }
264
+ end
265
+ end
266
+
267
+ # Builds the {Part} subclass registered for a content type.
268
+ module PartFactory
269
+ @part_type_for = {}
270
+
271
+ class << self
272
+ # Register +part_class+ as the class for +content_type+.
273
+ def register(content_type, part_class)
274
+ @part_type_for[content_type] = part_class
275
+ end
276
+
277
+ def part_class_for(content_type) = @part_type_for.fetch(content_type, Part)
278
+
279
+ def registered = @part_type_for.dup
280
+
281
+ # Test seam: restore a previous registration table.
282
+ def reset!(to) = (@part_type_for = to)
283
+
284
+ def build(partname, content_type, package, blob)
285
+ part_class_for(content_type).load(partname, content_type, package, blob)
286
+ end
287
+ end
288
+ end
289
+
290
+ # Looks up the content type for a partname, per `[Content_Types].xml`.
291
+ class ContentTypeMap
292
+ def self.from_xml(content_types_xml)
293
+ types = Pptx::Oxml::Element.parse(content_types_xml)
294
+ overrides = types.override_list.to_h { |o| [o.partName.downcase, o.contentType] }
295
+ defaults = types.default_list.to_h { |d| [d.extension.downcase, d.contentType] }
296
+ new(overrides, defaults)
297
+ end
298
+
299
+ def initialize(overrides, defaults)
300
+ @overrides = overrides
301
+ @defaults = defaults
302
+ end
303
+
304
+ # Matching is case-insensitive: the spec says partnames and extensions
305
+ # compare without regard to case.
306
+ def [](partname)
307
+ unless partname.is_a?(PackURI)
308
+ raise TypeError, "content-type key must be a PackURI, got #{partname.class}"
309
+ end
310
+
311
+ @overrides[partname.to_s.downcase] ||
312
+ @defaults[partname.ext.downcase] ||
313
+ raise(KeyError, "no content-type for partname #{partname} in [Content_Types].xml")
314
+ end
315
+ end
316
+
317
+ # The relationships belonging to one part, or to the package.
318
+ #
319
+ # Keyed by rId, and Enumerable over the relationships themselves.
320
+ class Relationships
321
+ include Enumerable
322
+
323
+ def initialize(base_uri)
324
+ @base_uri = base_uri
325
+ @rels = {}
326
+ end
327
+
328
+ def each(&) = @rels.each_value(&)
329
+
330
+ def each_value(&) = @rels.each_value(&)
331
+
332
+ def [](r_id) = @rels[r_id]
333
+
334
+ def fetch(r_id)
335
+ @rels.fetch(r_id) { raise NotFoundError, "no relationship with key #{r_id.inspect}" }
336
+ end
337
+
338
+ def key?(r_id) = @rels.key?(r_id)
339
+ def keys = @rels.keys
340
+ def size = @rels.size
341
+ def empty? = @rels.empty?
342
+ def delete(r_id) = @rels.delete(r_id)
343
+
344
+ # The rId of a relationship of +reltype+ to +target_part+, adding one if
345
+ # no match exists.
346
+ def get_or_add(reltype, target_part)
347
+ matching(reltype, target_part, external: false) || add(reltype, target_part)
348
+ end
349
+
350
+ def get_or_add_external(reltype, target_ref)
351
+ matching(reltype, target_ref, external: true) ||
352
+ add(reltype, target_ref, external: true)
353
+ end
354
+
355
+ def part_with_reltype(reltype)
356
+ matches = select { |rel| rel.reltype == reltype }
357
+ raise NotFoundError, "no relationship of type #{reltype.inspect}" if matches.empty?
358
+
359
+ raise Error, "multiple relationships of type #{reltype.inspect} in collection" if matches.size > 1
360
+
361
+ matches.first.target_part
362
+ end
363
+
364
+ # Replace the contents of this collection with the relationships in
365
+ # +xml_rels+.
366
+ def load_from_xml(base_uri, xml_rels, parts)
367
+ @base_uri = base_uri
368
+ @rels.clear
369
+ xml_rels.relationship_list.each do |rel_elm|
370
+ # A plugin sometimes "removes" a relationship by voiding its target
371
+ # rather than deleting the element, leaving a link to something like
372
+ # "/ppt/slides/NULL". Skip anything pointing outside the package.
373
+ unless rel_elm.external?
374
+ partname = PackURI.from_rel_ref(base_uri, rel_elm.target_ref)
375
+ next unless parts.key?(partname.to_s)
376
+ end
377
+
378
+ rel = Relationship.from_xml(base_uri, rel_elm, parts)
379
+ @rels[rel.r_id] = rel
380
+ end
381
+ self
382
+ end
383
+
384
+ # @return [String] serialized `.rels` bytes for this collection
385
+ def xml
386
+ rels_elm = Oxml::CT_Relationships.new_element
387
+ in_numerical_order.each do |rel|
388
+ rels_elm.add_rel(rel.r_id, rel.reltype, rel.target_ref, is_external: rel.external?)
389
+ end
390
+ rels_elm.xml_file_bytes
391
+ end
392
+
393
+ private
394
+
395
+ # Emit <Relationship> elements in numerical rId order, so a saved package
396
+ # is reproducible and diffable.
397
+ def in_numerical_order
398
+ @rels.keys.sort_by { |r_id| [numeric_part(r_id), r_id] }.map { |r_id| @rels[r_id] }
399
+ end
400
+
401
+ def numeric_part(r_id)
402
+ match = /\ArId(\d+)\z/.match(r_id)
403
+ match ? match[1].to_i : 0
404
+ end
405
+
406
+ def matching(reltype, target, external:)
407
+ rel = find do |candidate|
408
+ next false unless candidate.reltype == reltype && candidate.external? == external
409
+
410
+ (external ? candidate.target_ref : candidate.target_part) == target
411
+ end
412
+ rel&.r_id
413
+ end
414
+
415
+ def add(reltype, target, external: false)
416
+ r_id = next_r_id
417
+ @rels[r_id] = Relationship.new(
418
+ @base_uri, r_id, reltype, target_mode(external), target
419
+ )
420
+ r_id
421
+ end
422
+
423
+ def target_mode(external)
424
+ external ? RELATIONSHIP_TARGET_MODE::EXTERNAL : RELATIONSHIP_TARGET_MODE::INTERNAL
425
+ end
426
+
427
+ # The first unused rId from "rId1", filling any gap in the numbering.
428
+ def next_r_id
429
+ (@rels.size + 1).downto(1) do |n|
430
+ candidate = "rId#{n}"
431
+ return candidate unless @rels.key?(candidate)
432
+ end
433
+ raise Error, "unable to allocate an rId"
434
+ end
435
+ end
436
+
437
+ # A link from a part (or the package) to another part or an external
438
+ # resource.
439
+ class Relationship
440
+ attr_reader :r_id, :reltype
441
+
442
+ def self.from_xml(base_uri, rel_elm, parts)
443
+ target =
444
+ if rel_elm.external?
445
+ rel_elm.target_ref
446
+ else
447
+ parts.fetch(PackURI.from_rel_ref(base_uri, rel_elm.target_ref).to_s)
448
+ end
449
+ new(base_uri, rel_elm.rId, rel_elm.reltype, rel_elm.targetMode, target)
450
+ end
451
+
452
+ def initialize(base_uri, r_id, reltype, target_mode, target)
453
+ @base_uri = base_uri
454
+ @r_id = r_id
455
+ @reltype = reltype
456
+ @target_mode = target_mode
457
+ @target = target
458
+ end
459
+
460
+ # True when the target is outside the package, such as a URL.
461
+ def external? = @target_mode == RELATIONSHIP_TARGET_MODE::EXTERNAL
462
+
463
+ def target_part
464
+ raise Error, "#target_part is undefined for an external relationship" if external?
465
+
466
+ @target
467
+ end
468
+
469
+ def target_partname
470
+ raise Error, "#target_partname is undefined for an external relationship" if external?
471
+
472
+ @target.partname
473
+ end
474
+
475
+ # The reference as written into the `.rels` item: a relative partname
476
+ # internally, or the URL for an external relationship.
477
+ def target_ref = external? ? @target : target_partname.relative_ref(@base_uri)
478
+
479
+ def inspect = "#<Pptx::Opc::Relationship #{@r_id} #{@reltype}>"
480
+ end
481
+ end
482
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "stringio"
5
+ require "ruby_pptx/errors"
6
+ require "ruby_pptx/opc/constants"
7
+ require "ruby_pptx/opc/oxml"
8
+ require "ruby_pptx/opc/pack_uri"
9
+ require "ruby_pptx/opc/spec"
10
+
11
+ module Pptx
12
+ module Opc
13
+ # Reads package items out of a serialized package.
14
+ #
15
+ # The package may be a .pptx file, an IO stream containing one, or a
16
+ # directory holding an unzipped package.
17
+ class PackageReader
18
+ def initialize(pkg_file)
19
+ @reader = PhysicalReader.for(pkg_file)
20
+ end
21
+
22
+ # @return [Boolean] whether the package holds an item at +pack_uri+
23
+ def key?(pack_uri) = @reader.key?(pack_uri)
24
+
25
+ # @return [String] binary contents of the item at +pack_uri+
26
+ # @raise [KeyError] when there is no such item
27
+ def [](pack_uri) = @reader[pack_uri]
28
+
29
+ # The XML of the `.rels` item belonging to +partname+, or nil when that
30
+ # part has no relationships.
31
+ def rels_xml_for(partname)
32
+ uri = partname.rels_uri
33
+ key?(uri) ? self[uri] : nil
34
+ end
35
+ end
36
+
37
+ # Writes a zip-format package.
38
+ class PackageWriter
39
+ # Write a .pptx containing +parts+, their rels items, the package rels
40
+ # item and a content-types stream derived from the parts.
41
+ def self.write(pkg_file, pkg_rels, parts)
42
+ new(pkg_file, pkg_rels, parts).write
43
+ end
44
+
45
+ def initialize(pkg_file, pkg_rels, parts)
46
+ @pkg_file = pkg_file
47
+ @pkg_rels = pkg_rels
48
+ @parts = parts
49
+ end
50
+
51
+ def write
52
+ PhysicalWriter.open(@pkg_file) do |writer|
53
+ writer.write(PackURI::CONTENT_TYPES, ContentTypesItem.xml_for(@parts))
54
+ writer.write(PackURI::PACKAGE.rels_uri, @pkg_rels.xml)
55
+ @parts.each do |part|
56
+ writer.write(part.partname, part.blob)
57
+ writer.write(part.partname.rels_uri, part.rels.xml) unless part.rels.empty?
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ # Reads items from a physical package. {PhysicalReader.for} picks the
64
+ # implementation matching what it is handed.
65
+ class PhysicalReader
66
+ def self.for(pkg_file)
67
+ return ZipReader.new(pkg_file) unless pkg_file.is_a?(String)
68
+ return DirectoryReader.new(pkg_file) if File.directory?(pkg_file)
69
+ return ZipReader.new(pkg_file) if File.file?(pkg_file) && zip?(pkg_file)
70
+
71
+ raise PackageNotFoundError, "package not found at #{pkg_file.inspect}"
72
+ end
73
+
74
+ def self.zip?(path)
75
+ File.binread(path, 4) == "PK\x03\x04".b
76
+ rescue SystemCallError
77
+ false
78
+ end
79
+ private_class_method :zip?
80
+ end
81
+
82
+ # Reads a package that has been unzipped into a directory.
83
+ class DirectoryReader < PhysicalReader
84
+ def initialize(path)
85
+ super()
86
+ @path = File.expand_path(path)
87
+ end
88
+
89
+ def key?(pack_uri) = File.file?(path_for(pack_uri))
90
+
91
+ def [](pack_uri)
92
+ File.binread(path_for(pack_uri))
93
+ rescue SystemCallError
94
+ raise KeyError, "no member #{pack_uri} in package"
95
+ end
96
+
97
+ private
98
+
99
+ # Part names are package-internal and must not be able to escape the
100
+ # directory, so a name that resolves outside it is refused.
101
+ def path_for(pack_uri)
102
+ candidate = File.expand_path(File.join(@path, pack_uri.member_name))
103
+ unless candidate == @path || candidate.start_with?("#{@path}/")
104
+ raise ArgumentError, "part name escapes the package directory: #{pack_uri}"
105
+ end
106
+
107
+ candidate
108
+ end
109
+ end
110
+
111
+ # Reads a zip-format package, given a path or an IO stream.
112
+ class ZipReader < PhysicalReader
113
+ def initialize(pkg_file)
114
+ super()
115
+ @pkg_file = pkg_file
116
+ end
117
+
118
+ def key?(pack_uri) = blobs.key?(pack_uri.to_s)
119
+
120
+ def [](pack_uri)
121
+ blobs.fetch(pack_uri.to_s) { raise KeyError, "no member #{pack_uri} in package" }
122
+ end
123
+
124
+ private
125
+
126
+ # Read every member up front: a package is traversed by relationship, not
127
+ # sequentially, and the whole thing is needed anyway.
128
+ def blobs
129
+ @blobs ||= each_entry.to_h { |name, data| ["/#{name}", data] }
130
+ end
131
+
132
+ def each_entry(&block)
133
+ return enum_for(:each_entry) unless block
134
+
135
+ open_zip do |zip|
136
+ zip.each do |entry|
137
+ block.call(entry.name, entry.get_input_stream.read) if entry.file?
138
+ end
139
+ end
140
+ end
141
+
142
+ def open_zip(&)
143
+ if @pkg_file.is_a?(String)
144
+ Zip::File.open(@pkg_file, &)
145
+ else
146
+ @pkg_file.rewind if @pkg_file.respond_to?(:rewind)
147
+ Zip::File.open_buffer(@pkg_file, &)
148
+ end
149
+ rescue Zip::Error => e
150
+ raise PackageNotFoundError, "not a readable OPC package: #{e.message}"
151
+ end
152
+ end
153
+
154
+ # Writes a zip-format package to a path or an IO stream.
155
+ class PhysicalWriter
156
+ def self.open(pkg_file, &)
157
+ ZipWriter.open(pkg_file, &)
158
+ end
159
+ end
160
+
161
+ class ZipWriter < PhysicalWriter
162
+ def self.open(pkg_file)
163
+ if pkg_file.is_a?(String)
164
+ Zip::OutputStream.open(pkg_file) { |zos| yield new(zos) }
165
+ else
166
+ buffer = Zip::OutputStream.write_buffer(StringIO.new) { |zos| yield new(zos) }
167
+ buffer.rewind
168
+ IO.copy_stream(buffer, pkg_file)
169
+ end
170
+ end
171
+
172
+ def initialize(zip_output_stream)
173
+ super()
174
+ @zos = zip_output_stream
175
+ end
176
+
177
+ def write(pack_uri, blob)
178
+ @zos.put_next_entry(pack_uri.member_name)
179
+ @zos.write(blob)
180
+ end
181
+ end
182
+
183
+ # Composes `[Content_Types].xml` for a collection of parts.
184
+ class ContentTypesItem
185
+ # @return [String] serialized content-types XML covering every part
186
+ def self.xml_for(parts)
187
+ Oxml.serialize_part_xml(new(parts).element)
188
+ end
189
+
190
+ def initialize(parts)
191
+ @parts = parts
192
+ end
193
+
194
+ # Defaults are sorted by extension and overrides by partname. The order
195
+ # carries no meaning in the spec, but a deterministic one makes packages
196
+ # diffable and testable.
197
+ def element
198
+ defaults, overrides = defaults_and_overrides
199
+ types = Oxml::CT_Types.new_element
200
+ defaults.sort.each { |ext, content_type| types.add_default_for(ext, content_type) }
201
+ overrides.sort.each { |partname, content_type| types.add_override_for(partname, content_type) }
202
+ types
203
+ end
204
+
205
+ private
206
+
207
+ # A part gets a <Default> when its (extension, content-type) pair is one
208
+ # the spec allows to be defaulted, and an <Override> otherwise.
209
+ def defaults_and_overrides
210
+ defaults = {
211
+ "rels" => CONTENT_TYPE::OPC_RELATIONSHIPS,
212
+ "xml" => CONTENT_TYPE::XML
213
+ }
214
+ overrides = {}
215
+
216
+ @parts.each do |part|
217
+ ext = part.partname.ext
218
+ if DEFAULT_CONTENT_TYPES.include?([ext.downcase, part.content_type])
219
+ defaults[ext.downcase] = part.content_type
220
+ else
221
+ overrides[part.partname.to_s] = part.content_type
222
+ end
223
+ end
224
+
225
+ [defaults, overrides]
226
+ end
227
+ end
228
+ end
229
+ end