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,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "ruby_pptx/errors"
|
|
5
|
+
require "ruby_pptx/oxml/ns"
|
|
6
|
+
require "ruby_pptx/oxml/simple_types"
|
|
7
|
+
|
|
8
|
+
module Pptx
|
|
9
|
+
module Oxml
|
|
10
|
+
# Maps a Clark-notation tag name to the {Element} subclass that models it.
|
|
11
|
+
#
|
|
12
|
+
# python-pptx gets this for free from lxml, whose parser instantiates a
|
|
13
|
+
# custom class per tag. Nokogiri has no such hook, so element classes
|
|
14
|
+
# register themselves here and {Element.wrap} does the dispatch.
|
|
15
|
+
module Registry
|
|
16
|
+
@classes = {}
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# A tag belongs to exactly one class. Registering a second one used to
|
|
20
|
+
# replace the first silently, so whichever file loaded last won --
|
|
21
|
+
# `c:tx` and `a:ext` each had two classes that way.
|
|
22
|
+
def register(clark_name, element_class)
|
|
23
|
+
existing = @classes[clark_name]
|
|
24
|
+
if existing && existing != element_class
|
|
25
|
+
raise ArgumentError, "#{clark_name} is already registered to #{existing}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@classes[clark_name] = element_class
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Class] the registered class, or {Element} for an unmodelled tag
|
|
32
|
+
def class_for(clark_name) = @classes.fetch(clark_name, Element)
|
|
33
|
+
|
|
34
|
+
def registered = @classes.dup
|
|
35
|
+
|
|
36
|
+
# Test seam: forget registrations made by a spec.
|
|
37
|
+
def reset!(to) = (@classes = to)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Wraps a `Nokogiri::XML::Node` and gives it the schema-aware behaviour of
|
|
42
|
+
# one OOXML element type.
|
|
43
|
+
#
|
|
44
|
+
# Subclasses declare their content model with class macros, which generate
|
|
45
|
+
# the accessors that the rest of the library uses:
|
|
46
|
+
#
|
|
47
|
+
# class CT_Shape < Element
|
|
48
|
+
# tag "p:sp"
|
|
49
|
+
# one_and_only_one "p:nvSpPr"
|
|
50
|
+
# zero_or_one "p:spPr", successors: %w[p:style p:txBody]
|
|
51
|
+
# zero_or_more "p:ext"
|
|
52
|
+
# optional_attr "macro", type: SimpleTypes::XsdString
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# Local tag names are kept verbatim, camel case and all, so a method name
|
|
56
|
+
# can be grepped straight from the ECMA-376 schema. The snake_case Ruby
|
|
57
|
+
# idiom belongs to the public API layer built on top of this one.
|
|
58
|
+
#
|
|
59
|
+
# ## Identity
|
|
60
|
+
#
|
|
61
|
+
# Two wrappers around the same underlying node are `==` and hash alike, so
|
|
62
|
+
# correctness never depends on the wrapper cache. The cache exists to avoid
|
|
63
|
+
# re-allocating wrappers, not to make comparisons work.
|
|
64
|
+
class Element
|
|
65
|
+
# Declares the tag or tags this class models and registers them for
|
|
66
|
+
# dispatch. A few element types appear under more than one tag -- a
|
|
67
|
+
# transform is `a:xfrm` on a shape but `p:xfrm` on a graphic frame --
|
|
68
|
+
# and are the same class in both places.
|
|
69
|
+
def self.tag(*prefixed_tags)
|
|
70
|
+
@nsptags = prefixed_tags
|
|
71
|
+
prefixed_tags.each { |t| Registry.register(Ns.qn(t), self) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.nsptag = @nsptags&.first
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
attr_reader :nsptags
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Wrap +node+ in the class registered for its tag.
|
|
81
|
+
#
|
|
82
|
+
# @param node [Nokogiri::XML::Node]
|
|
83
|
+
# @return [Element, nil] nil when +node+ is nil
|
|
84
|
+
def self.wrap(node)
|
|
85
|
+
return nil if node.nil?
|
|
86
|
+
return node if node.is_a?(Element)
|
|
87
|
+
|
|
88
|
+
cache = cache_for(node.document)
|
|
89
|
+
cache[node] ||= Registry.class_for(Ns.clark_name_of(node)).new(node)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Parse +xml+ into a standalone element tree.
|
|
93
|
+
def self.parse(xml)
|
|
94
|
+
doc = Nokogiri::XML(xml) { |config| config.noblanks.strict }
|
|
95
|
+
raise InvalidXmlError, doc.errors.first.to_s if doc.errors.any?
|
|
96
|
+
|
|
97
|
+
wrap(doc.root)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Per-document wrapper cache, keyed on node identity. Lives on the
|
|
101
|
+
# document so it dies with it.
|
|
102
|
+
def self.cache_for(document)
|
|
103
|
+
document.instance_variable_get(:@pptx_wrappers) ||
|
|
104
|
+
document.instance_variable_set(:@pptx_wrappers, {}.compare_by_identity)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :cache_for
|
|
107
|
+
|
|
108
|
+
# @return [Nokogiri::XML::Node] the wrapped node
|
|
109
|
+
attr_reader :node
|
|
110
|
+
|
|
111
|
+
def initialize(node)
|
|
112
|
+
@node = node
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# -- tree navigation -------------------------------------------------
|
|
116
|
+
|
|
117
|
+
# @return [String] this element's prefixed tag, e.g. "p:sp"
|
|
118
|
+
def nsptag = Ns.prefixed_tag(Ns.clark_name_of(@node))
|
|
119
|
+
|
|
120
|
+
def document = @node.document
|
|
121
|
+
|
|
122
|
+
def parent = Element.wrap(@node.parent.is_a?(Nokogiri::XML::Element) ? @node.parent : nil)
|
|
123
|
+
|
|
124
|
+
# @return [Array<Element>] every child element, in document order
|
|
125
|
+
def element_children = @node.element_children.map { |child| Element.wrap(child) }
|
|
126
|
+
|
|
127
|
+
# First child element with +nsptag+, or nil.
|
|
128
|
+
def find(nsptag) = Element.wrap(raw_find(nsptag))
|
|
129
|
+
|
|
130
|
+
# Every child element with +nsptag+, in document order.
|
|
131
|
+
def find_all(nsptag)
|
|
132
|
+
pfx, local = Ns.split_tag(nsptag)
|
|
133
|
+
@node.element_children
|
|
134
|
+
.select { |c| c.name == local && c.namespace&.href == Ns.nsuri(pfx) }
|
|
135
|
+
.map { |c| Element.wrap(c) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The first child matching any of +nsptags+, in the order given -- not in
|
|
139
|
+
# document order. Used to find an element's successor when inserting.
|
|
140
|
+
def first_child_found_in(*nsptags)
|
|
141
|
+
nsptags.each do |nsptag|
|
|
142
|
+
child = raw_find(nsptag)
|
|
143
|
+
return Element.wrap(child) if child
|
|
144
|
+
end
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Run an XPath expression with the full OOXML namespace map bound.
|
|
149
|
+
def xpath(expression)
|
|
150
|
+
@node.xpath(expression, Ns::NSMAP).map do |result|
|
|
151
|
+
result.is_a?(Nokogiri::XML::Element) ? Element.wrap(result) : result
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# -- mutation --------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# Insert +element+ before the first of +successor_tags+ present, or
|
|
158
|
+
# append it when none is. This is what keeps children in schema order.
|
|
159
|
+
def insert_element_before(element, *successor_tags)
|
|
160
|
+
successor = first_child_found_in(*successor_tags)
|
|
161
|
+
if successor
|
|
162
|
+
successor.node.add_previous_sibling(adopt(element))
|
|
163
|
+
else
|
|
164
|
+
@node.add_child(adopt(element))
|
|
165
|
+
end
|
|
166
|
+
element
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def append(element)
|
|
170
|
+
@node.add_child(adopt(element))
|
|
171
|
+
element
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Remove every child element whose tag is in +nsptags+.
|
|
175
|
+
def remove_all(*nsptags)
|
|
176
|
+
nsptags.each { |nsptag| find_all(nsptag).each { |child| child.node.unlink } }
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def remove(element)
|
|
181
|
+
element.node.unlink
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Create a new, empty element with +nsptag+, in *this* element's
|
|
186
|
+
# document.
|
|
187
|
+
#
|
|
188
|
+
# Creating it anywhere else would be a bug: Nokogiri re-creates a node
|
|
189
|
+
# adopted from another document, so the object you built is not the
|
|
190
|
+
# object that ends up in the tree, and every wrapper held on it goes
|
|
191
|
+
# stale. Staying in one document keeps node identity intact.
|
|
192
|
+
def build(nsptag)
|
|
193
|
+
pfx, local = Ns.split_tag(nsptag)
|
|
194
|
+
uri = Ns.nsuri(pfx)
|
|
195
|
+
child = Nokogiri::XML::Node.new(local, @node.document)
|
|
196
|
+
# Resolve against this element's scope, not the detached child's, so
|
|
197
|
+
# the new element joins an existing declaration instead of introducing
|
|
198
|
+
# a second one. Where the parent holds the namespace as its *default*
|
|
199
|
+
# -- as .rels and [Content_Types].xml do -- the child is created
|
|
200
|
+
# unprefixed to match, which matters because C14N preserves prefixes.
|
|
201
|
+
child.namespace = inherited_namespace(pfx, uri) ||
|
|
202
|
+
child.add_namespace_definition(pfx, uri)
|
|
203
|
+
Element.wrap(child)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Parse an XML literal into this element's document.
|
|
207
|
+
def build_from_xml(xml)
|
|
208
|
+
# noblanks matches Element.parse, so an indented XML literal does not
|
|
209
|
+
# smuggle whitespace text nodes into the tree.
|
|
210
|
+
fragment = @node.parse(xml, &:noblanks)
|
|
211
|
+
raise InvalidXmlError, "fragment produced no element: #{xml.inspect}" if fragment.first.nil?
|
|
212
|
+
|
|
213
|
+
Element.wrap(fragment.first)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# -- text content ----------------------------------------------------
|
|
217
|
+
|
|
218
|
+
# @return [String] the element's text content
|
|
219
|
+
def text = @node.text
|
|
220
|
+
|
|
221
|
+
def text=(value)
|
|
222
|
+
@node.content = value.to_s
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Declare +prefix+ on this element even if nothing uses it yet.
|
|
226
|
+
#
|
|
227
|
+
# Needed where a descendant carries a namespaced attribute but the
|
|
228
|
+
# declaration belongs on the root, which is where PowerPoint puts it.
|
|
229
|
+
def declare_namespace(prefix)
|
|
230
|
+
namespace_for(@node, prefix)
|
|
231
|
+
self
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# -- attributes ------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
# Attribute names may be prefixed ("r:embed") or not ("cstate").
|
|
237
|
+
#
|
|
238
|
+
# A prefixed name is resolved by namespace URI rather than by the literal
|
|
239
|
+
# prefix, because a document is free to bind that namespace to any prefix
|
|
240
|
+
# it likes. This matches the Clark-name semantics python-pptx inherits
|
|
241
|
+
# from lxml.
|
|
242
|
+
def get(attr_name)
|
|
243
|
+
pfx, local = prefixed_attr_parts(attr_name)
|
|
244
|
+
return @node[attr_name.to_s] unless pfx
|
|
245
|
+
|
|
246
|
+
@node.attribute_with_ns(local, Ns.nsuri(pfx))&.value
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def set(attr_name, value)
|
|
250
|
+
pfx, local = prefixed_attr_parts(attr_name)
|
|
251
|
+
return @node[attr_name.to_s] = value.to_s unless pfx
|
|
252
|
+
|
|
253
|
+
uri = Ns.nsuri(pfx)
|
|
254
|
+
if (existing = @node.attribute_with_ns(local, uri))
|
|
255
|
+
existing.value = value.to_s
|
|
256
|
+
else
|
|
257
|
+
namespace_for(@node, pfx)
|
|
258
|
+
@node["#{pfx}:#{local}"] = value.to_s
|
|
259
|
+
end
|
|
260
|
+
value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def delete_attribute(attr_name)
|
|
264
|
+
pfx, local = prefixed_attr_parts(attr_name)
|
|
265
|
+
return @node.remove_attribute(attr_name.to_s) unless pfx
|
|
266
|
+
|
|
267
|
+
@node.attribute_with_ns(local, Ns.nsuri(pfx))&.unlink
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def attribute?(attr_name) = !get(attr_name).nil?
|
|
272
|
+
|
|
273
|
+
# -- serialization and equality --------------------------------------
|
|
274
|
+
|
|
275
|
+
# Pretty-printed XML for this element, without a declaration. For
|
|
276
|
+
# debugging and specs.
|
|
277
|
+
def xml = @node.to_xml(indent: 2)
|
|
278
|
+
|
|
279
|
+
def to_s = xml
|
|
280
|
+
|
|
281
|
+
def ==(other) = other.is_a?(Element) && other.node.equal?(@node)
|
|
282
|
+
alias eql? ==
|
|
283
|
+
|
|
284
|
+
def hash = @node.object_id.hash
|
|
285
|
+
|
|
286
|
+
def inspect = "#<#{self.class.name} <#{nsptag}> children=#{@node.element_children.size}>"
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def raw_find(nsptag)
|
|
291
|
+
pfx, local = Ns.split_tag(nsptag)
|
|
292
|
+
uri = Ns.nsuri(pfx)
|
|
293
|
+
@node.element_children.find { |c| c.name == local && c.namespace&.href == uri }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Guard against the cross-document trap described on {#build}.
|
|
297
|
+
def adopt(element)
|
|
298
|
+
node = element.is_a?(Element) ? element.node : element
|
|
299
|
+
return node if node.document.equal?(@node.document)
|
|
300
|
+
|
|
301
|
+
raise ArgumentError,
|
|
302
|
+
"cannot insert an element created in a different document; " \
|
|
303
|
+
"build it with #build on an element of this tree"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def prefixed_attr_parts(attr_name)
|
|
307
|
+
return nil unless attr_name.to_s.include?(":")
|
|
308
|
+
|
|
309
|
+
Ns.split_tag(attr_name.to_s)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# An in-scope declaration for +uri+, preferring one bound to +prefix+ but
|
|
313
|
+
# accepting any -- including a default (unprefixed) declaration.
|
|
314
|
+
def inherited_namespace(prefix, uri)
|
|
315
|
+
scopes = @node.namespace_scopes
|
|
316
|
+
scopes.find { |ns| ns.href == uri && ns.prefix == prefix } ||
|
|
317
|
+
scopes.find { |ns| ns.href == uri }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Reuse an in-scope namespace declaration when there is one, so the
|
|
321
|
+
# output does not sprout a redundant xmlns on every new element.
|
|
322
|
+
def namespace_for(node, prefix)
|
|
323
|
+
uri = Ns.nsuri(prefix)
|
|
324
|
+
existing = node.namespace_scopes.find { |ns| ns.href == uri && ns.prefix == prefix }
|
|
325
|
+
existing || node.add_namespace_definition(prefix, uri)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pptx
|
|
4
|
+
module Oxml
|
|
5
|
+
# The XML namespaces used across PresentationML, DrawingML and the OPC
|
|
6
|
+
# package format, plus helpers for moving between the three tag notations
|
|
7
|
+
# we need:
|
|
8
|
+
#
|
|
9
|
+
# - prefixed, `"p:cSld"` -- how the spec and this code talk
|
|
10
|
+
# - Clark, `"{http://...}cSld"` -- unambiguous, used as registry keys
|
|
11
|
+
# - Nokogiri, name + namespace object -- what the parser hands back
|
|
12
|
+
module Ns
|
|
13
|
+
NSMAP = {
|
|
14
|
+
"a" => "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
15
|
+
"c" => "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
|
16
|
+
"cp" => "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
|
|
17
|
+
"ct" => "http://schemas.openxmlformats.org/package/2006/content-types",
|
|
18
|
+
"dc" => "http://purl.org/dc/elements/1.1/",
|
|
19
|
+
"dcmitype" => "http://purl.org/dc/dcmitype/",
|
|
20
|
+
"dcterms" => "http://purl.org/dc/terms/",
|
|
21
|
+
"ep" => "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",
|
|
22
|
+
"i" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
|
|
23
|
+
"m" => "http://schemas.openxmlformats.org/officeDocument/2006/math",
|
|
24
|
+
"mo" => "http://schemas.microsoft.com/office/mac/office/2008/main",
|
|
25
|
+
"mv" => "urn:schemas-microsoft-com:mac:vml",
|
|
26
|
+
"o" => "urn:schemas-microsoft-com:office:office",
|
|
27
|
+
"p" => "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
28
|
+
# PowerPoint 2010 extensions, which is where slide sections live.
|
|
29
|
+
"p14" => "http://schemas.microsoft.com/office/powerpoint/2010/main",
|
|
30
|
+
# Office 2016 SVG extension, which carries an SVG beside its raster
|
|
31
|
+
# fallback in a picture fill.
|
|
32
|
+
"asvg" => "http://schemas.microsoft.com/office/drawing/2016/SVG/main",
|
|
33
|
+
"pd" => "http://schemas.openxmlformats.org/drawingml/2006/presentationDrawing",
|
|
34
|
+
"pic" => "http://schemas.openxmlformats.org/drawingml/2006/picture",
|
|
35
|
+
"pr" => "http://schemas.openxmlformats.org/package/2006/relationships",
|
|
36
|
+
"r" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
37
|
+
"sl" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
|
|
38
|
+
"v" => "urn:schemas-microsoft-com:vml",
|
|
39
|
+
"ve" => "http://schemas.openxmlformats.org/markup-compatibility/2006",
|
|
40
|
+
"w" => "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
41
|
+
"w10" => "urn:schemas-microsoft-com:office:word",
|
|
42
|
+
"wne" => "http://schemas.microsoft.com/office/word/2006/wordml",
|
|
43
|
+
"wp" => "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
|
44
|
+
"xsi" => "http://www.w3.org/2001/XMLSchema-instance"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Namespace URI => preferred prefix. Built from NSMAP, so a URI that
|
|
48
|
+
# appears twice resolves to the last prefix declared for it.
|
|
49
|
+
PFXMAP = NSMAP.each_with_object({}) { |(pfx, uri), map| map[uri] = pfx }.freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# @param nspfx [String] e.g. "p"
|
|
54
|
+
# @return [String] the namespace URI for +nspfx+
|
|
55
|
+
def nsuri(nspfx)
|
|
56
|
+
NSMAP.fetch(nspfx) { raise KeyError, "unknown namespace prefix #{nspfx.inspect}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Clark-notation name for a prefixed tag.
|
|
60
|
+
#
|
|
61
|
+
# qn("p:cSld") #=> "{http://schemas.openxmlformats.org/presentationml/2006/main}cSld"
|
|
62
|
+
def qn(prefixed_tag)
|
|
63
|
+
pfx, local = split_tag(prefixed_tag)
|
|
64
|
+
"{#{nsuri(pfx)}}#{local}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The inverse of {qn}.
|
|
68
|
+
#
|
|
69
|
+
# prefixed_tag("{http://...presentationml/2006/main}cSld") #=> "p:cSld"
|
|
70
|
+
def prefixed_tag(clark_name)
|
|
71
|
+
unless clark_name.start_with?("{") && (close = clark_name.index("}"))
|
|
72
|
+
raise ArgumentError, "not a Clark-notation name: #{clark_name.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
uri = clark_name[1...close]
|
|
76
|
+
local = clark_name[(close + 1)..]
|
|
77
|
+
pfx = PFXMAP.fetch(uri) { raise KeyError, "unknown namespace URI #{uri.inspect}" }
|
|
78
|
+
"#{pfx}:#{local}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The subset of NSMAP for +prefixes+, in the shape Nokogiri's #xpath wants.
|
|
82
|
+
#
|
|
83
|
+
# namespaces("a", "p") #=> {"a" => "...", "p" => "..."}
|
|
84
|
+
def namespaces(*prefixes)
|
|
85
|
+
prefixes.to_h { |pfx| [pfx, nsuri(pfx)] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Namespace declarations as literal XML attribute text, for building
|
|
89
|
+
# fragments by hand.
|
|
90
|
+
#
|
|
91
|
+
# nsdecls("a") #=> 'xmlns:a="http://...drawingml/2006/main"'
|
|
92
|
+
def nsdecls(*prefixes)
|
|
93
|
+
prefixes.map { |pfx| %(xmlns:#{pfx}="#{nsuri(pfx)}") }.join(" ")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The Clark name of a Nokogiri node, suitable as an element-registry key.
|
|
97
|
+
#
|
|
98
|
+
# @param node [Nokogiri::XML::Node]
|
|
99
|
+
def clark_name_of(node)
|
|
100
|
+
href = node.namespace&.href
|
|
101
|
+
href ? "{#{href}}#{node.name}" : node.name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ["p", "cSld"] for "p:cSld".
|
|
105
|
+
def split_tag(prefixed_tag)
|
|
106
|
+
pfx, local, extra = prefixed_tag.split(":", 3)
|
|
107
|
+
if local.nil? || local.empty? || !extra.nil?
|
|
108
|
+
raise ArgumentError, "expected a prefixed tag like 'p:cSld', got #{prefixed_tag.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[pfx, local]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
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/section"
|
|
7
|
+
require "ruby_pptx/oxml/ns"
|
|
8
|
+
|
|
9
|
+
module Pptx
|
|
10
|
+
module Oxml
|
|
11
|
+
ST = SimpleTypes
|
|
12
|
+
|
|
13
|
+
# `p:presentation`, the root of `/ppt/presentation.xml`.
|
|
14
|
+
class CT_Presentation < Element
|
|
15
|
+
tag "p:presentation"
|
|
16
|
+
zero_or_one "p:sldMasterIdLst",
|
|
17
|
+
successors: %w[p:notesMasterIdLst p:handoutMasterIdLst p:sldIdLst p:sldSz p:notesSz]
|
|
18
|
+
zero_or_one "p:sldIdLst", successors: %w[p:sldSz p:notesSz]
|
|
19
|
+
zero_or_one "p:sldSz", successors: %w[p:notesSz]
|
|
20
|
+
zero_or_one "p:extLst", successors: []
|
|
21
|
+
|
|
22
|
+
# The URI PowerPoint uses to mark the section-list extension.
|
|
23
|
+
SECTION_LIST_EXT_URI = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}"
|
|
24
|
+
|
|
25
|
+
# The `p14:sectionLst` element, or nil when the presentation has no
|
|
26
|
+
# sections.
|
|
27
|
+
def section_list
|
|
28
|
+
xpath("./p:extLst/p:ext/p14:sectionLst").first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The `p14:sectionLst`, created inside its extension if absent.
|
|
32
|
+
def get_or_add_section_list
|
|
33
|
+
section_list || begin
|
|
34
|
+
ext = get_or_add_extLst.add_ext(uri: SECTION_LIST_EXT_URI)
|
|
35
|
+
ext.append(ext.build_from_xml(%(<p14:sectionLst #{Ns.nsdecls("p14")}/>)))
|
|
36
|
+
section_list
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def remove_section_list
|
|
41
|
+
extension = xpath("./p:extLst/p:ext").find { |e| e.get("uri") == SECTION_LIST_EXT_URI }
|
|
42
|
+
return if extension.nil?
|
|
43
|
+
|
|
44
|
+
extLst.remove(extension)
|
|
45
|
+
remove_extLst if extLst.ext_list.empty?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `p:sldId`, a reference from the presentation to one slide.
|
|
50
|
+
class CT_SlideId < Element
|
|
51
|
+
tag "p:sldId"
|
|
52
|
+
required_attr "id", type: ST::ST_SlideId
|
|
53
|
+
required_attr "r:id", type: ST::XsdString, as: :rId
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `p:sldIdLst`, the ordered list of slides in the presentation.
|
|
57
|
+
class CT_SlideIdList < Element
|
|
58
|
+
tag "p:sldIdLst"
|
|
59
|
+
zero_or_more "p:sldId", as: :sldId
|
|
60
|
+
|
|
61
|
+
MIN_SLIDE_ID = 256
|
|
62
|
+
MAX_SLIDE_ID = 2_147_483_647
|
|
63
|
+
|
|
64
|
+
def add_slide_id(r_id) = add_sldId(id: next_id, rId: r_id)
|
|
65
|
+
|
|
66
|
+
def size = sldId_list.size
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# One greater than the highest id in use, which avoids reusing the id of
|
|
71
|
+
# a deleted slide. Only if that would overflow do we hunt for a gap.
|
|
72
|
+
def next_id
|
|
73
|
+
used = sldId_list.map(&:id)
|
|
74
|
+
simple_next = ([MIN_SLIDE_ID - 1] + used).max + 1
|
|
75
|
+
return simple_next if simple_next <= MAX_SLIDE_ID
|
|
76
|
+
|
|
77
|
+
valid = used.grep(MIN_SLIDE_ID..MAX_SLIDE_ID).sort
|
|
78
|
+
return MIN_SLIDE_ID if valid.empty?
|
|
79
|
+
|
|
80
|
+
valid.each_with_index do |used_id, i|
|
|
81
|
+
candidate = MIN_SLIDE_ID + i
|
|
82
|
+
return candidate if candidate != used_id
|
|
83
|
+
end
|
|
84
|
+
MIN_SLIDE_ID + valid.size
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `p:sldMasterIdLst`, the slide masters belonging to the presentation.
|
|
89
|
+
class CT_SlideMasterIdList < Element
|
|
90
|
+
tag "p:sldMasterIdLst"
|
|
91
|
+
zero_or_more "p:sldMasterId", as: :sldMasterId
|
|
92
|
+
|
|
93
|
+
def size = sldMasterId_list.size
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `p:sldMasterId`, a reference to one slide master.
|
|
97
|
+
class CT_SlideMasterIdListEntry < Element
|
|
98
|
+
tag "p:sldMasterId"
|
|
99
|
+
required_attr "r:id", type: ST::XsdString, as: :rId
|
|
100
|
+
optional_attr "id", type: ST::ST_SlideMasterId
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `p:sldSz`, the slide dimensions for the whole presentation.
|
|
104
|
+
class CT_SlideSize < Element
|
|
105
|
+
tag "p:sldSz"
|
|
106
|
+
required_attr "cx", type: ST::ST_SlideSizeCoordinate
|
|
107
|
+
required_attr "cy", type: ST::ST_SlideSizeCoordinate
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "ruby_pptx/oxml/element"
|
|
5
|
+
require "ruby_pptx/oxml/content_model"
|
|
6
|
+
require "ruby_pptx/oxml/simple_types"
|
|
7
|
+
|
|
8
|
+
module Pptx
|
|
9
|
+
module Oxml
|
|
10
|
+
# `p:extLst`, the extension list on `p:presentation`.
|
|
11
|
+
class CT_PresentationExtensionList < Element
|
|
12
|
+
tag "p:extLst"
|
|
13
|
+
zero_or_more "p:ext", as: :ext
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# `p:ext`, one extension, identified by its URI.
|
|
17
|
+
class CT_PresentationExtension < Element
|
|
18
|
+
tag "p:ext"
|
|
19
|
+
required_attr "uri", type: SimpleTypes::XsdString
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `p14:sldId`, a slide's membership in a section.
|
|
23
|
+
#
|
|
24
|
+
# Note this refers to the slide by its presentation-wide `p:sldId/@id`,
|
|
25
|
+
# not by a relationship.
|
|
26
|
+
class CT_SectionSlideId < Element
|
|
27
|
+
tag "p14:sldId"
|
|
28
|
+
required_attr "id", type: SimpleTypes::ST_SlideId
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `p14:sldIdLst`, the slides belonging to one section.
|
|
32
|
+
class CT_SectionSlideIdList < Element
|
|
33
|
+
tag "p14:sldIdLst"
|
|
34
|
+
zero_or_more "p14:sldId", as: :sldId
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `p14:section`, one named section of a presentation.
|
|
38
|
+
class CT_Section < Element
|
|
39
|
+
tag "p14:section"
|
|
40
|
+
required_attr "name", type: SimpleTypes::XsdString
|
|
41
|
+
required_attr "id", type: SimpleTypes::XsdString
|
|
42
|
+
one_and_only_one "p14:sldIdLst"
|
|
43
|
+
|
|
44
|
+
# PowerPoint identifies a section by a brace-wrapped uppercase GUID.
|
|
45
|
+
def self.new_id = "{#{SecureRandom.uuid.upcase}}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `p14:sectionLst`, the sections of a presentation.
|
|
49
|
+
class CT_SectionList < Element
|
|
50
|
+
tag "p14:sectionLst"
|
|
51
|
+
zero_or_more "p14:section", as: :section
|
|
52
|
+
|
|
53
|
+
def add_section(name, id = CT_Section.new_id)
|
|
54
|
+
build_from_xml(
|
|
55
|
+
%(<p14:section #{Ns.nsdecls("p14")} name="#{escape(name)}" id="#{id}">) \
|
|
56
|
+
"<p14:sldIdLst/></p14:section>"
|
|
57
|
+
).tap { |element| append(element) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def escape(text)
|
|
61
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|