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