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,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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
62
+ end
63
+ end
64
+ end
65
+ end