rbpptx 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4494da8ee976e807536010f1ffe12a0dbaecd1d13e4b91b22fc9874c6bf8cc60
4
+ data.tar.gz: f8b93b7f227deb25e04f1c1841210ece0f0806298e1b1ce9f910c23bb343544d
5
+ SHA512:
6
+ metadata.gz: 4db6d5cf34753a2ffc93472f08629fe515d28eb69053cb59dbcacb0138cd972eb214a7f78678b824f8f8b5f15cb81d58ec6a971ddd948e2d1e4ee4c39c5e6519
7
+ data.tar.gz: 1a988ba99c8a250f70a6edda334574701492e4b99482ff2055f65c10a7a02d37346753a7fb97a69f49c0bc3ca0d4d1a26d024a42546f93b20bdf8e57e0a4062f
data/CHANGELOG.md ADDED
@@ -0,0 +1,78 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-04-26
10
+
11
+ - Initial public release.
12
+
13
+ ### Added
14
+
15
+ - `Rbpptx.open(path)` opens a `.pptx` package and returns a
16
+ `Rbpptx::Presentation`. Block form auto-closes the underlying ZIP on
17
+ return or exception, matching the `File.open` / `Zip::File.open` idiom.
18
+ - `Rbpptx::Presentation#slides` enumerates slides in presentation order
19
+ (i.e. the order of `<p:sldId>` entries in `ppt/presentation.xml`,
20
+ resolved via the presentation's own rels), parsing slide XML lazily.
21
+ - `Rbpptx::Slide#shapes` returns each `<p:sp>` child of the slide's
22
+ `<p:spTree>` wrapped as `Rbpptx::Shape`, with its own dirty tracking so
23
+ the slide knows whether its XML needs to be re-serialized on save.
24
+ - `Rbpptx::Shape#text` returns the shape's concatenated text body —
25
+ paragraphs joined with `"\n"`, soft line breaks (`<a:br>`) also rendered
26
+ as `"\n"`.
27
+ - `Rbpptx::Shape#text=` replaces the shape's text body. Lines (separated by
28
+ `"\n"`) become individual `<a:p>` paragraphs. Run properties from the
29
+ first existing `<a:rPr>` (font, size, color, language) are cloned onto
30
+ each new run so template formatting carries through. Shapes without a
31
+ `<p:txBody>` raise `Rbpptx::Error` rather than silently failing.
32
+ - `Rbpptx::Shape#placeholder_type` returns the placeholder role as a
33
+ Symbol (`:ctrTitle`, `:subTitle`, `:title`, `:body`, `:obj`, `:ftr`,
34
+ `:dt`, `:sldNum`, `:pic`, ...) by reading
35
+ `<p:nvSpPr>/<p:nvPr>/<p:ph type="...">`. Returns `:body` when the
36
+ placeholder element is present without a `type` attribute (matches the
37
+ OOXML default), and `nil` for non-placeholder shapes.
38
+ - `Rbpptx::Shape#paragraphs` and `Rbpptx::Shape#runs` expose the OOXML
39
+ paragraph and run granularity below the shape: `paragraphs` returns the
40
+ shape's `<a:p>` children; `runs` flattens runs across paragraphs in
41
+ document order.
42
+ - `Rbpptx::Paragraph` and `Rbpptx::Run` wrappers; `Run#text=` is a
43
+ surgical text setter that rewrites only the run's `<a:t>` content.
44
+ `<a:rPr>`, `<a:br>` soft breaks, sibling runs, paragraph properties,
45
+ `<a:endParaRPr>`, and unknown OOXML extensions on the shape are all
46
+ preserved. Use this instead of `Shape#text=` when the template's
47
+ per-run formatting matters (e.g. replacing a `{{slot}}` marker inside a
48
+ multi-run paragraph).
49
+ - `Rbpptx::Presentation#save(path)` writes the presentation to disk via a
50
+ sibling temp file and atomic rename, so `pres.save(original_path)` is
51
+ safe. Only slides whose shapes have been mutated are re-serialized;
52
+ every other ZIP entry (themes, masters, layouts, media, fonts, rels,
53
+ PowerPoint-extension XML) streams through with its already-deflated
54
+ bytes via `Zip::OutputStream#copy_raw_entry` — no inflate, no
55
+ re-deflate. The bytes of unmodified parts are bit-identical to the
56
+ source ZIP.
57
+ - `Rbpptx::UnsupportedFormatError` raised by `Rbpptx.open` when the file
58
+ is empty, not a ZIP container, or a legacy `.ppt` (BIFF/CFB) file.
59
+ Legacy inputs are detected by the OLE compound-file magic and reported
60
+ with a `libreoffice --headless --convert-to pptx` hint rather than
61
+ surfacing an opaque `Zip::Error` from rubyzip.
62
+ - `Rbpptx::PresentationFormatError`, `Rbpptx::SlideFormatError`,
63
+ `Rbpptx::SlideNotFoundError`, `Rbpptx::ClosedPresentationError` for
64
+ malformed-input and use-after-close cases. All error messages carry the
65
+ presentation path and (where relevant) the offending part name.
66
+ - RBS signatures for the public surface (`sig/rbpptx.rbs`).
67
+ - `benchmark/compare.rb` drives a three-way comparison against
68
+ python-pptx (via `uv run --with python-pptx`) and docxtemplater +
69
+ PizZip (via `node`). Three scenarios — read (open + walk all slides +
70
+ concatenate text), fill_one (replace one shape + save), fill_many
71
+ (replace 10 shapes + save) — configurable via `RBPPTX_BENCH_SAMPLE`,
72
+ `RBPPTX_BENCH_WARMUP`, `RBPPTX_BENCH_ITERATIONS`. On the 18 MB sample
73
+ deck rbpptx is 4.7x / 16.8x / 13.0x faster than python-pptx and
74
+ 2.0x / 4.5x / 3.4x faster than docxtemplater across the three
75
+ scenarios.
76
+ - `benchmark/fixture_gen.py` synthesizes a sized deck with `{{slot_*}}`
77
+ markers via python-pptx, so save-path benchmarks can isolate the cost
78
+ of the XML rewrite from the cost of re-deflating embedded media.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taro KOBAYASHI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # rbpptx
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/rbpptx.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/rbpptx)
4
+
5
+ Read-and-edit Ruby gem for `.pptx` (PowerPoint, OOXML) files, modeled after
6
+ [python-pptx](https://github.com/scanny/python-pptx).
7
+
8
+ `rbpptx` is built around a single design promise: **what we don't understand,
9
+ we don't touch**. The presentation is opened as a ZIP package, only the
10
+ parts you mutate are re-serialized, and every other entry — slide layouts,
11
+ masters, themes, embedded fonts, images, animations, hyperlink colors,
12
+ PowerPoint-extension XML — round-trips byte-for-byte. Inside a slide that
13
+ you do edit, only the `<a:p>` children of the targeted `<p:txBody>` are
14
+ rewritten; surrounding `<p:nvSpPr>`, `<p:spPr>`, `<a:bodyPr>`, `<a:lstStyle>`,
15
+ and any unknown OOXML extensions remain in place. The first existing
16
+ `<a:rPr>` (font, size, color, language) is cloned onto each new run, so
17
+ template formatting carries through to the replacement text.
18
+
19
+ Supported:
20
+
21
+ - open an existing `.pptx`, enumerate slides in presentation order
22
+ - read every shape's name, concatenated text, and placeholder type
23
+ (`:ctrTitle`, `:subTitle`, `:body`, ...)
24
+ - walk into paragraphs and runs (`shape.paragraphs`, `shape.runs`,
25
+ `paragraph.runs`)
26
+ - **bulldozer setter**: `shape.text = "..."` replaces the entire text body,
27
+ cloning the first existing `<a:rPr>` onto each new run so template
28
+ formatting carries through
29
+ - **surgical setter**: `run.text = "..."` rewrites only the run's `<a:t>`
30
+ content, preserving `<a:rPr>`, soft line breaks, sibling runs, paragraph
31
+ properties, and unknown OOXML extensions
32
+ - save back to a new path, or overwrite the original via temp-file + atomic
33
+ rename — unmodified entries stream through untouched
34
+
35
+ Out of scope:
36
+
37
+ - creating a presentation from scratch — `Rbpptx.new` is reserved for a
38
+ future release and currently raises `NotImplementedError`
39
+ - inserting / deleting / reordering / duplicating slides
40
+ - editing media (images, embedded fonts) or adding new shapes
41
+ - placeholder-aware API (`slide.title`, `slide.placeholders[:body]`)
42
+ - notes pages, comments, charts, SmartArt
43
+ - legacy `.ppt` (BIFF/CFB) input — `rbpptx` reads OOXML `.pptx` only.
44
+ Convert first, e.g. `libreoffice --headless --convert-to pptx file.ppt`.
45
+ `Rbpptx.open` detects the OLE compound-file magic and raises
46
+ `Rbpptx::UnsupportedFormatError` with the conversion hint rather than
47
+ surfacing an opaque ZIP parse error from rubyzip.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ gem install rbpptx
53
+ ```
54
+
55
+ Or in a `Gemfile`:
56
+
57
+ ```ruby
58
+ gem "rbpptx"
59
+ ```
60
+
61
+ Requires Ruby 3.2+, `nokogiri`, and `rubyzip`.
62
+
63
+ ## Usage
64
+
65
+ ### Reading
66
+
67
+ ```ruby
68
+ require "rbpptx"
69
+
70
+ Rbpptx.open("deck.pptx") do |pres|
71
+ pres.slides.each do |slide|
72
+ puts slide.name # => "slide1", "slide2", ...
73
+ slide.shapes.each do |shape|
74
+ puts " #{shape.name}: #{shape.text.inspect}"
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
80
+ `Rbpptx.open` yields a `Rbpptx::Presentation` and auto-closes the underlying
81
+ ZIP when the block returns (or raises). Without a block it returns the
82
+ presentation; call `pres.close` when done.
83
+
84
+ Slides are enumerated in the order they appear inside the presentation —
85
+ i.e. the order of `<p:sldId>` entries in `ppt/presentation.xml`, which is
86
+ not necessarily the slide-part filename order.
87
+
88
+ ### Editing text and saving
89
+
90
+ The bulldozer setter replaces a shape's entire text body. Lines (separated
91
+ by `"\n"`) become individual `<a:p>` paragraphs:
92
+
93
+ ```ruby
94
+ require "rbpptx"
95
+
96
+ Rbpptx.open("template.pptx") do |pres|
97
+ pres.slides.first.shapes.first.text = "差し替えタイトル"
98
+ pres.slides[1].shapes.find { |s| s.text.include?("会社名") }.text = "Acme Inc."
99
+ pres.save("filled.pptx")
100
+ end
101
+ ```
102
+
103
+ The surgical setter rewrites only the targeted run's `<a:t>`, preserving
104
+ all other formatting and structural XML. Use it for `{{slot}}`-style
105
+ template fills where a marker run sits inside a multi-run paragraph:
106
+
107
+ ```ruby
108
+ Rbpptx.open("template.pptx") do |pres|
109
+ pres.slides.each do |slide|
110
+ slide.shapes.flat_map(&:runs).each do |run|
111
+ run.text = run.text.gsub("{{name}}", "Acme")
112
+ end
113
+ end
114
+ pres.save("filled.pptx")
115
+ end
116
+ ```
117
+
118
+ Or target by placeholder role:
119
+
120
+ ```ruby
121
+ Rbpptx.open("template.pptx") do |pres|
122
+ pres.slides.first.shapes.find { |s| s.placeholder_type == :ctrTitle }.text = "Q2 Review"
123
+ pres.slides.first.shapes.find { |s| s.placeholder_type == :subTitle }.text = "Acme Inc."
124
+ pres.save("filled.pptx")
125
+ end
126
+ ```
127
+
128
+ ### Same-path overwrite
129
+
130
+ `Presentation#save` writes to a temp file in the target directory and
131
+ atomically renames it into place, so `pres.save(original_path)` is safe:
132
+
133
+ ```ruby
134
+ Rbpptx.open("deck.pptx") do |pres|
135
+ pres.slides.first.shapes.first.text = "Updated"
136
+ pres.save("deck.pptx") # overwrite in place
137
+ end
138
+ ```
139
+
140
+ If the rename fails, the temp file is removed before the error propagates.
141
+
142
+ ### Inspecting without parsing every slide
143
+
144
+ Slide XML is parsed lazily: enumerating slides, reading their `name`, or
145
+ checking `dirty?` does not pay the per-slide parse cost. The first call to
146
+ `slide.shapes` (or `slide.to_xml`) is what triggers the parse, and the
147
+ result is cached.
148
+
149
+ ```ruby
150
+ Rbpptx.open("deck.pptx") do |pres|
151
+ pres.slides.length # cheap
152
+ pres.slides.map(&:name) # cheap
153
+ pres.slides.first.shapes.first.text # parses only slide 1
154
+ end
155
+ ```
156
+
157
+ ### Error handling
158
+
159
+ All `rbpptx` exceptions inherit from `Rbpptx::Error` and the messages carry
160
+ the presentation path and (where relevant) the offending part name:
161
+
162
+ | Error | Raised when |
163
+ | --- | --- |
164
+ | `Rbpptx::UnsupportedFormatError` | file is empty, not a ZIP, or a legacy `.ppt` (BIFF/CFB) container |
165
+ | `Rbpptx::PresentationFormatError` | `_rels/.rels`, `ppt/presentation.xml`, or its rels are malformed |
166
+ | `Rbpptx::SlideFormatError` | a slide part is missing or its XML cannot be parsed |
167
+ | `Rbpptx::SlideNotFoundError` | reserved for future index-based slide accessors |
168
+ | `Rbpptx::ClosedPresentationError` | `slides` / `save` called after `close` |
169
+
170
+ ```ruby
171
+ begin
172
+ Rbpptx.open("deck.pptx") { |pres| pres.slides.each { |s| process(s) } }
173
+ rescue Rbpptx::UnsupportedFormatError => e
174
+ warn "not a pptx: #{e.message}"
175
+ rescue Rbpptx::PresentationFormatError, Rbpptx::SlideFormatError => e
176
+ warn "malformed: #{e.message}"
177
+ end
178
+ ```
179
+
180
+ ## Design Notes
181
+
182
+ - **Fidelity over feature coverage.** `rbpptx` is a small, surgical editor,
183
+ not a PowerPoint object model. The bytes it doesn't understand are the
184
+ bytes it doesn't touch — including PowerPoint-version-specific extensions
185
+ (`p14:`, `p15:`, `ahyp:`, `mc:AlternateContent`) that a full object model
186
+ would have to know about to round-trip safely.
187
+ - **Lazy parse, eager write.** Slides parse on first access; saves
188
+ re-serialize only the slides whose shapes have been mutated, and stream
189
+ every other ZIP entry through with its already-deflated bytes
190
+ (`Zip::OutputStream#copy_raw_entry`) — no decompress, no re-deflate.
191
+ Bit-identical preservation, not just content-identical.
192
+ - **rPr cloning, not rebuilding.** When a shape's text is replaced, the
193
+ first existing `<a:rPr>` is deep-cloned onto each new run. A template's
194
+ font, size, color, and `lang` attribute carry through without `rbpptx`
195
+ needing to model those properties.
196
+ - **Atomic same-path save.** `save` writes to a sibling temp file and
197
+ renames it into place, so a crash mid-write cannot leave a half-written
198
+ `.pptx` at the target path.
199
+
200
+ ## Benchmarks
201
+
202
+ The selling-point story for `rbpptx` is on real-world decks where the
203
+ package carries embedded media — themes, slide masters, slide layouts,
204
+ images, fonts. `python-pptx` parses these eagerly at `Presentation(path)`
205
+ time; `rbpptx` defers everything past `presentation.xml` and its rels, so
206
+ the open-and-read path stays cheap regardless of how much binary mass the
207
+ deck carries.
208
+
209
+ Real-world deck (18 MB, 21 slides, embedded fonts and images), 5 iterations:
210
+
211
+ | benchmark | rbpptx (s) | python-pptx (s) | docxtemplater (s) | rbpptx vs python-pptx | rbpptx vs docxtemplater |
212
+ | --- | ---: | ---: | ---: | ---: | ---: |
213
+ | read (walk all text) | **0.019** | 0.089 | 0.037 | **4.7x faster** | **2.0x faster** |
214
+ | fill_one (1 + save) | **0.033** | 0.555 | 0.149 | **16.8x faster** | **4.5x faster** |
215
+ | fill_many (10 + save) | **0.042** | 0.545 | 0.144 | **13.0x faster** | **3.4x faster** |
216
+
217
+ Memory (RSS delta) on the read scenario:
218
+
219
+ | library | rss Δ (kB) |
220
+ | --- | ---: |
221
+ | rbpptx | **2,956** |
222
+ | python-pptx | 28,416 |
223
+ | docxtemplater | 34,816 |
224
+
225
+ The fill scenarios used to tie when each library re-deflated every ZIP
226
+ entry on save. `rbpptx` now streams unmodified entries through with
227
+ `Zip::OutputStream#copy_raw_entry`, so the deflate cost is paid only on
228
+ the slide XML that actually changed — which is what unlocks the 13–17x
229
+ gap against python-pptx and the 3–4.5x gap against docxtemplater on
230
+ read-modify-write. The advantage is real and reproducible:
231
+
232
+ ```bash
233
+ cd benchmark && npm install && cd ..
234
+ RBPPTX_SAMPLE=/path/to/your/deck.pptx bundle exec ruby benchmark/compare.rb
235
+ ```
236
+
237
+ `uv` is required for the python-pptx subprocess; the comparison script
238
+ invokes `uv run --with python-pptx python3 benchmark/python_pptx_compare.py`
239
+ so no system-level Python install is mutated. The Node.js side runs from
240
+ `benchmark/node_modules` (gitignored); rerun `npm install` after pulling
241
+ benchmark changes. Use any `.pptx` you have on hand — the table above
242
+ was measured against an 18 MB deck with 21 slides and embedded fonts.
243
+
244
+ To benchmark against a synthetic deck with no embedded media (isolates the
245
+ XML-rewrite cost from binary deflate), generate a fixture first:
246
+
247
+ ```bash
248
+ uv run --with python-pptx python3 benchmark/fixture_gen.py 50 /tmp/bench_50.pptx
249
+ RBPPTX_SAMPLE=/tmp/bench_50.pptx bundle exec ruby benchmark/compare.rb
250
+ ```
251
+
252
+ ## Development
253
+
254
+ Development in this repository assumes Ruby 3.4.8 (`.ruby-version`).
255
+
256
+ ```bash
257
+ bundle install
258
+
259
+ # Run tests
260
+ bundle exec ruby -Ilib -Itest test/rbpptx_test.rb
261
+
262
+ # Generate API docs
263
+ bundle exec rake rdoc
264
+ ```
265
+
266
+ The bulk of the test suite runs against a small in-tree fixture
267
+ (~3 KB) built from scratch by `test/fixture_builder.rb` using only
268
+ `rubyzip` and hand-coded OOXML. No external sample is required for a
269
+ fresh checkout to get meaningful coverage of read, edit, save, and
270
+ fidelity behavior. A handful of dog-fooding tests (`test_sample_*`) opt
271
+ into any real-world `.pptx` you point at via the `RBPPTX_SAMPLE`
272
+ environment variable, and skip cleanly when it is unset:
273
+
274
+ ```bash
275
+ RBPPTX_SAMPLE=/path/to/your/deck.pptx bundle exec ruby -Ilib -Itest test/rbpptx_test.rb
276
+ ```
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_helper"
4
+ require "rdoc/task"
5
+
6
+ Bundler::GemHelper.install_tasks
7
+
8
+ RDoc::Task.new(:rdoc) do |rdoc|
9
+ rdoc.main = "README.md"
10
+ rdoc.rdoc_files.include("README.md", "lib/**/*.rb")
11
+ end
@@ -0,0 +1,28 @@
1
+ module Rbpptx
2
+ # Base class for all errors raised by Rbpptx. Rescue this class to catch any
3
+ # library-specific failure without catching unrelated +StandardError+
4
+ # subclasses from the caller's code.
5
+ class Error < StandardError; end
6
+
7
+ # Raised by {Rbpptx.open} when the file is not a valid +.pptx+ container.
8
+ # Most commonly fires on legacy +.ppt+ (BIFF/CFB) files — the message names
9
+ # the detected format and suggests a conversion path rather than letting the
10
+ # underlying ZIP parser surface an opaque error.
11
+ class UnsupportedFormatError < Error; end
12
+
13
+ # Raised when presentation-level XML is malformed or internally inconsistent,
14
+ # for example when +ppt/presentation.xml+ cannot be parsed or references a
15
+ # missing relationship target.
16
+ class PresentationFormatError < Error; end
17
+
18
+ # Raised when a slide XML entry cannot be parsed.
19
+ class SlideFormatError < Error; end
20
+
21
+ # Raised by {Rbpptx::Presentation#slide} when the requested slide index is
22
+ # out of range.
23
+ class SlideNotFoundError < Error; end
24
+
25
+ # Raised when an operation is attempted against a presentation whose
26
+ # underlying ZIP resources have already been released via +close+.
27
+ class ClosedPresentationError < Error; end
28
+ end
@@ -0,0 +1,44 @@
1
+ module Rbpptx
2
+ # A paragraph (+<a:p>+) inside a shape's text body.
3
+ #
4
+ # Paragraphs hold a sequence of {Run}s separated optionally by soft line
5
+ # breaks (+<a:br>+). The {#text} accessor mirrors {Shape#text} restricted
6
+ # to this paragraph: runs joined as-is, breaks rendered as +"\n"+. Since
7
+ # paragraphs map 1:1 to +<a:p>+ elements, paragraph boundaries are *not*
8
+ # rendered as +"\n"+ here — that is {Shape#text}'s job.
9
+ class Paragraph
10
+ A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
11
+
12
+ # @param node [Nokogiri::XML::Element] the +<a:p>+ element
13
+ # @param slide [Slide, nil] back-reference propagated to runs so their
14
+ # setters can mark the slide dirty
15
+ def initialize(node:, slide: nil)
16
+ @node = node
17
+ @slide = slide
18
+ end
19
+
20
+ # @return [Array<Run>] +<a:r>+ children in document order. +<a:fld>+
21
+ # (field) and +<a:br>+ (line break) elements are not included.
22
+ def runs
23
+ @node.xpath("./a:r", "a" => A_NS).map { |r| Run.new(node: r, slide: @slide) }
24
+ end
25
+
26
+ # @return [String] concatenated text. Run text appears verbatim; +<a:br>+
27
+ # between runs is rendered as +"\n"+.
28
+ def text
29
+ buf = +""
30
+ @node.children.each do |child|
31
+ next unless child.namespace&.href == A_NS
32
+
33
+ case child.name
34
+ when "r"
35
+ t = child.at_xpath("./a:t", "a" => A_NS)
36
+ buf << t.text if t
37
+ when "br"
38
+ buf << "\n"
39
+ end
40
+ end
41
+ buf
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,187 @@
1
+ module Rbpptx
2
+ # A PowerPoint presentation loaded from a +.pptx+ file.
3
+ #
4
+ # Reading is the only mode wired up at present: the package is opened, the
5
+ # presentation part is located via +_rels/.rels+, the slide id list is read
6
+ # in document order, and slides are resolved through the presentation's own
7
+ # rels. Each {Slide} retains a handle on the underlying ZIP and parses its
8
+ # XML lazily.
9
+ class Presentation
10
+ P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main".freeze
11
+ A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
12
+ R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships".freeze
13
+ PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships".freeze
14
+ OFFICE_DOC_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument".freeze
15
+ SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide".freeze
16
+
17
+ LEGACY_PPT_SIGNATURE = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b.freeze
18
+ ZIP_SIGNATURE = "PK\x03\x04".b.freeze
19
+
20
+ # @return [String] absolute path the presentation was loaded from
21
+ attr_reader :path
22
+
23
+ # Opens an existing presentation. See {Rbpptx.open} for the public entry
24
+ # point that supports the block-with-auto-close idiom.
25
+ def self.open(path)
26
+ new(path)
27
+ end
28
+
29
+ def initialize(path)
30
+ @path = path.to_s
31
+ @closed = false
32
+ validate_signature!
33
+ begin
34
+ @zip = Zip::File.open(@path)
35
+ rescue Zip::Error => e
36
+ raise UnsupportedFormatError, "#{@path}: not a valid .pptx (#{e.message})"
37
+ end
38
+ end
39
+
40
+ # @return [Array<Slide>] slides in presentation order (matches the order of
41
+ # +<p:sldId>+ entries inside +ppt/presentation.xml+)
42
+ def slides
43
+ ensure_open!
44
+ @slides ||= build_slides
45
+ end
46
+
47
+ # Writes the presentation out to +path+, preserving every part that has
48
+ # not been mutated byte-for-byte. Slides whose shapes have been edited are
49
+ # re-serialized from their in-memory Nokogiri document; all other entries
50
+ # (themes, masters, layouts, media, fonts, rels) are streamed straight
51
+ # from the source ZIP without re-parsing, so unknown PowerPoint features
52
+ # round-trip unchanged.
53
+ #
54
+ # +path+ may equal the original load path; the new file is written to a
55
+ # temp file in the same directory and atomically renamed into place. On
56
+ # success, dirty flags on each slide are cleared.
57
+ #
58
+ # @param path [String, #to_path]
59
+ # @return [String] the path that was written
60
+ def save(path)
61
+ ensure_open!
62
+ out_path = path.to_s
63
+ overrides = (@slides || []).each_with_object({}) do |slide, h|
64
+ h[slide.part_name] = slide.to_xml if slide.dirty?
65
+ end
66
+
67
+ tmp_path = "#{out_path}.rbpptx-tmp.#{Process.pid}.#{rand(1 << 32).to_s(16)}"
68
+ begin
69
+ Zip::OutputStream.open(tmp_path) do |out|
70
+ @zip.each do |entry|
71
+ next if entry.directory?
72
+
73
+ if (override_xml = overrides[entry.name])
74
+ out.put_next_entry(entry.name)
75
+ out.write(override_xml)
76
+ else
77
+ out.copy_raw_entry(entry)
78
+ end
79
+ end
80
+ end
81
+ File.rename(tmp_path, out_path)
82
+ rescue StandardError
83
+ File.unlink(tmp_path) if File.exist?(tmp_path)
84
+ raise
85
+ end
86
+
87
+ (@slides || []).each(&:clear_dirty!)
88
+ out_path
89
+ end
90
+
91
+ # Releases the underlying ZIP file. Idempotent.
92
+ #
93
+ # @return [Boolean] +true+ on the first call, +false+ on subsequent calls
94
+ def close
95
+ return false if @closed
96
+
97
+ @zip&.close
98
+ @zip = nil
99
+ @closed = true
100
+ true
101
+ end
102
+
103
+ # @return [Boolean]
104
+ def closed?
105
+ @closed
106
+ end
107
+
108
+ private
109
+
110
+ def ensure_open!
111
+ raise ClosedPresentationError, "#{@path}: presentation is closed" if @closed
112
+ end
113
+
114
+ def validate_signature!
115
+ head = File.binread(@path, 8) rescue nil
116
+ raise UnsupportedFormatError, "#{@path}: file is empty or unreadable" if head.nil? || head.empty?
117
+
118
+ if head.start_with?(LEGACY_PPT_SIGNATURE)
119
+ raise UnsupportedFormatError,
120
+ "#{@path}: looks like a legacy .ppt (BIFF/CFB) file. " \
121
+ "Convert to .pptx first, e.g. `libreoffice --headless --convert-to pptx`."
122
+ end
123
+
124
+ unless head.start_with?(ZIP_SIGNATURE)
125
+ raise UnsupportedFormatError, "#{@path}: missing ZIP signature; not a .pptx package"
126
+ end
127
+ end
128
+
129
+ def build_slides
130
+ pres_part = locate_presentation_part
131
+ pres_rels = parse_rels(rels_path_for(pres_part))
132
+ pres_dir = File.dirname(pres_part)
133
+
134
+ doc = parse_xml(pres_part)
135
+ sld_ids = doc.xpath("/p:presentation/p:sldIdLst/p:sldId", "p" => P_NS)
136
+
137
+ sld_ids.map do |sld_id|
138
+ rid = sld_id.attribute_with_ns("id", R_NS)&.value
139
+ target = pres_rels[rid] or raise PresentationFormatError, "#{@path}: rId #{rid.inspect} not found in presentation rels"
140
+ Slide.new(zip: @zip, part_name: resolve_relative(pres_dir, target))
141
+ end
142
+ end
143
+
144
+ def locate_presentation_part
145
+ doc = parse_xml("_rels/.rels")
146
+ rel = doc.at_xpath(
147
+ "/pkg:Relationships/pkg:Relationship[@Type=$type]",
148
+ { "pkg" => PKG_REL_NS },
149
+ { "type" => OFFICE_DOC_REL_TYPE }
150
+ )
151
+ raise PresentationFormatError, "#{@path}: officeDocument relationship missing from _rels/.rels" unless rel
152
+
153
+ target = rel["Target"] or raise PresentationFormatError, "#{@path}: officeDocument relationship has no Target"
154
+ target.sub(%r{\A/}, "")
155
+ end
156
+
157
+ def parse_rels(rels_part)
158
+ entry = @zip.find_entry(rels_part)
159
+ return {} unless entry
160
+
161
+ doc = Nokogiri::XML(entry.get_input_stream.read)
162
+ doc.xpath("/pkg:Relationships/pkg:Relationship", "pkg" => PKG_REL_NS).each_with_object({}) do |r, h|
163
+ h[r["Id"]] = r["Target"]
164
+ end
165
+ end
166
+
167
+ def parse_xml(part_name)
168
+ entry = @zip.find_entry(part_name) or raise PresentationFormatError, "#{@path}: missing part #{part_name}"
169
+ doc = Nokogiri::XML(entry.get_input_stream.read)
170
+ raise PresentationFormatError, "#{@path}: #{part_name}: #{doc.errors.first}" unless doc.errors.empty?
171
+
172
+ doc
173
+ end
174
+
175
+ def rels_path_for(part_name)
176
+ dir = File.dirname(part_name)
177
+ base = File.basename(part_name)
178
+ dir == "." ? "_rels/#{base}.rels" : "#{dir}/_rels/#{base}.rels"
179
+ end
180
+
181
+ def resolve_relative(base_dir, target)
182
+ return target.sub(%r{\A/}, "") if target.start_with?("/")
183
+
184
+ File.expand_path(target, "/#{base_dir}").sub(%r{\A/}, "")
185
+ end
186
+ end
187
+ end
data/lib/rbpptx/run.rb ADDED
@@ -0,0 +1,47 @@
1
+ require "cgi"
2
+
3
+ module Rbpptx
4
+ # A single run (+<a:r>+) inside a paragraph.
5
+ #
6
+ # Runs are the smallest unit of text that shares a single set of run
7
+ # properties (+<a:rPr>+: font, size, color, language, hyperlink, ...). The
8
+ # text setter replaces only the content of the run's +<a:t>+ element, so
9
+ # every other property of the run survives the edit untouched — the
10
+ # surgical counterpart to the bulldozer-style {Shape#text=} setter.
11
+ class Run
12
+ A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
13
+
14
+ # @param node [Nokogiri::XML::Element] the +<a:r>+ element
15
+ # @param slide [Slide, nil] back-reference used to mark the slide dirty
16
+ # when this run is mutated
17
+ def initialize(node:, slide: nil)
18
+ @node = node
19
+ @slide = slide
20
+ end
21
+
22
+ # @return [String] the run's text content (the inner text of +<a:t>+),
23
+ # or +""+ if the run has no +<a:t>+ element
24
+ def text
25
+ t = @node.at_xpath("./a:t", "a" => A_NS)
26
+ t ? t.text : ""
27
+ end
28
+
29
+ # Replaces the run's text content. Only the +<a:t>+ element's character
30
+ # data is rewritten; +<a:rPr>+ and any other children of the run are left
31
+ # in place. If the run has no +<a:t>+ element, one is appended.
32
+ #
33
+ # @param new_text [String]
34
+ # @return [String]
35
+ def text=(new_text)
36
+ value = new_text.to_s
37
+ t = @node.at_xpath("./a:t", "a" => A_NS)
38
+ if t
39
+ t.content = value
40
+ else
41
+ @node.add_child("<a:t>#{CGI.escapeHTML(value)}</a:t>")
42
+ end
43
+ @slide&.mark_dirty!
44
+ value
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,127 @@
1
+ require "cgi"
2
+
3
+ module Rbpptx
4
+ # A single shape (+<p:sp>+) on a slide.
5
+ #
6
+ # Two layers of text access are exposed:
7
+ #
8
+ # * {#text} / {#text=} — bulldozer-level: read the entire concatenated
9
+ # text body, or replace it wholesale. The setter cleans the text body's
10
+ # +<a:p>+ children and rebuilds them, cloning the first existing
11
+ # +<a:rPr>+ onto each new run so template formatting carries through.
12
+ # * {#paragraphs} / {#runs} — surgical: walk into individual {Paragraph}
13
+ # and {Run} wrappers and mutate run text via {Run#text=}, which only
14
+ # rewrites +<a:t>+ content. Run properties, hyperlinks, soft line
15
+ # breaks, paragraph properties, and any unknown OOXML extensions on the
16
+ # shape are preserved.
17
+ #
18
+ # In addition, {#placeholder_type} reads the shape's placeholder role from
19
+ # +<p:nvSpPr>/<p:nvPr>/<p:ph type="...">+ so callers can target shapes by
20
+ # semantic role (e.g. +:ctrTitle+, +:subTitle+, +:body+) instead of by
21
+ # index.
22
+ class Shape
23
+ P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main".freeze
24
+ A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main".freeze
25
+
26
+ # @param node [Nokogiri::XML::Element] the +<p:sp>+ element
27
+ # @param slide [Slide, nil] back-reference used to mark the slide dirty
28
+ # when this shape is mutated
29
+ def initialize(node:, slide: nil)
30
+ @node = node
31
+ @slide = slide
32
+ end
33
+
34
+ # @return [String, nil] value of +<p:cNvPr name="...">+, the human-readable
35
+ # shape name set by the authoring tool
36
+ def name
37
+ @node.at_xpath("./p:nvSpPr/p:cNvPr/@name", "p" => P_NS)&.value
38
+ end
39
+
40
+ # @return [Symbol, nil] the placeholder type as a Symbol (e.g.
41
+ # +:ctrTitle+, +:subTitle+, +:title+, +:body+, +:obj+, +:ftr+,
42
+ # +:dt+, +:sldNum+, +:pic+), or +nil+ if the shape is not a
43
+ # placeholder. Returns +:body+ when the shape has a +<p:ph>+ element
44
+ # with no +type+ attribute, matching the OOXML default.
45
+ def placeholder_type
46
+ ph = @node.at_xpath("./p:nvSpPr/p:nvPr/p:ph", "p" => P_NS)
47
+ return nil unless ph
48
+
49
+ type = ph["type"]
50
+ type ? type.to_sym : :body
51
+ end
52
+
53
+ # @return [Array<Paragraph>] paragraphs in the shape's text body, in
54
+ # document order. Returns +[]+ if the shape has no +<p:txBody>+ (e.g.
55
+ # picture or group shapes).
56
+ def paragraphs
57
+ txbody = @node.at_xpath("./p:txBody", "p" => P_NS)
58
+ return [] unless txbody
59
+
60
+ txbody.xpath("./a:p", "a" => A_NS).map { |p| Paragraph.new(node: p, slide: @slide) }
61
+ end
62
+
63
+ # @return [Array<Run>] all runs in the shape's text body, flattened
64
+ # across paragraphs in document order
65
+ def runs
66
+ paragraphs.flat_map(&:runs)
67
+ end
68
+
69
+ # Concatenated text of the shape's text body. Paragraphs are joined with
70
+ # +"\n"+; soft line breaks (+<a:br>+) inside a paragraph are also
71
+ # rendered as +"\n"+.
72
+ #
73
+ # @return [String]
74
+ def text
75
+ paragraphs.map(&:text).join("\n")
76
+ end
77
+
78
+ # Replaces the shape's text body. Lines (separated by +"\n"+) become
79
+ # individual +<a:p>+ paragraphs. Run properties from the first existing
80
+ # +<a:rPr>+ (font, size, color, language) are cloned onto each new run
81
+ # so template formatting carries through.
82
+ #
83
+ # This is the bulldozer-level setter; for edits that need to preserve
84
+ # individual run formatting (e.g. replacing a placeholder inside a
85
+ # multi-run paragraph), use {#runs} or {#paragraphs} and {Run#text=}
86
+ # instead.
87
+ #
88
+ # Shapes without a +<p:txBody>+ (e.g. picture or group shapes) cannot
89
+ # accept text and will raise.
90
+ #
91
+ # @param new_text [String]
92
+ # @return [String] the value that was set
93
+ # @raise [Rbpptx::Error] if the shape has no text body
94
+ def text=(new_text)
95
+ txbody = @node.at_xpath("./p:txBody", "p" => P_NS)
96
+ raise Error, "shape has no <p:txBody>; cannot set text" unless txbody
97
+
98
+ template_rpr = txbody.at_xpath(".//a:r/a:rPr", "a" => A_NS)
99
+ txbody.xpath("./a:p", "a" => A_NS).each(&:remove)
100
+
101
+ lines = new_text.to_s.empty? ? [""] : new_text.to_s.split("\n", -1)
102
+ txbody.add_child(build_paragraphs_xml(lines))
103
+
104
+ if template_rpr
105
+ txbody.xpath("./a:p/a:r", "a" => A_NS).each do |run|
106
+ first_child = run.children.first
107
+ first_child ? first_child.add_previous_sibling(template_rpr.dup) : run.add_child(template_rpr.dup)
108
+ end
109
+ end
110
+
111
+ @slide&.mark_dirty!
112
+ new_text
113
+ end
114
+
115
+ private
116
+
117
+ def build_paragraphs_xml(lines)
118
+ lines.map do |line|
119
+ if line.empty?
120
+ "<a:p/>"
121
+ else
122
+ "<a:p><a:r><a:t>#{CGI.escapeHTML(line)}</a:t></a:r></a:p>"
123
+ end
124
+ end.join
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,78 @@
1
+ module Rbpptx
2
+ # A single slide loaded from a presentation's ZIP container.
3
+ #
4
+ # Parsing of the slide's XML payload is deferred until {#shapes} is first
5
+ # called and then cached, so simply enumerating slides via
6
+ # {Presentation#slides} does not pay the per-slide parse cost.
7
+ class Slide
8
+ P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main".freeze
9
+
10
+ # @return [String] the part name of the slide inside the package, e.g.
11
+ # +"ppt/slides/slide1.xml"+
12
+ attr_reader :part_name
13
+
14
+ # @param zip [Zip::File] the open package
15
+ # @param part_name [String] absolute part name (no leading slash)
16
+ def initialize(zip:, part_name:)
17
+ @zip = zip
18
+ @part_name = part_name
19
+ @dirty = false
20
+ end
21
+
22
+ # @return [String] the slide's basename without extension, e.g. +"slide1"+
23
+ def name
24
+ File.basename(@part_name, ".xml")
25
+ end
26
+
27
+ # @return [Array<Shape>] +<p:sp>+ children of the slide's shape tree, in
28
+ # document order
29
+ def shapes
30
+ @shapes ||= build_shapes
31
+ end
32
+
33
+ # @return [Boolean] whether any shape on this slide has been mutated since
34
+ # load
35
+ def dirty?
36
+ @dirty
37
+ end
38
+
39
+ # Marks the slide as dirty. Called by {Shape} setters; not part of the
40
+ # public API.
41
+ #
42
+ # @api private
43
+ def mark_dirty!
44
+ @dirty = true
45
+ end
46
+
47
+ # @return [String] the slide's XML, reflecting any in-memory mutations.
48
+ # The XML declaration is preserved.
49
+ def to_xml
50
+ doc.to_xml
51
+ end
52
+
53
+ # @api private
54
+ def clear_dirty!
55
+ @dirty = false
56
+ end
57
+
58
+ private
59
+
60
+ def doc
61
+ @doc ||= parse_doc
62
+ end
63
+
64
+ def parse_doc
65
+ entry = @zip.find_entry(@part_name)
66
+ raise SlideFormatError, "missing slide part #{@part_name}" unless entry
67
+
68
+ parsed = Nokogiri::XML(entry.get_input_stream.read)
69
+ raise SlideFormatError, "#{@part_name}: #{parsed.errors.first}" unless parsed.errors.empty?
70
+
71
+ parsed
72
+ end
73
+
74
+ def build_shapes
75
+ doc.xpath("/p:sld/p:cSld/p:spTree/p:sp", "p" => P_NS).map { |sp| Shape.new(node: sp, slide: self) }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,4 @@
1
+ module Rbpptx
2
+ # Gem version string, tracked with semantic versioning.
3
+ VERSION = "0.1.0"
4
+ end
data/lib/rbpptx.rb ADDED
@@ -0,0 +1,60 @@
1
+ require "nokogiri"
2
+ require "zip"
3
+
4
+ require_relative "rbpptx/errors"
5
+ require_relative "rbpptx/run"
6
+ require_relative "rbpptx/paragraph"
7
+ require_relative "rbpptx/shape"
8
+ require_relative "rbpptx/slide"
9
+ require_relative "rbpptx/presentation"
10
+ require_relative "rbpptx/version"
11
+
12
+ # PowerPoint (+.pptx+) reader/writer for Ruby, modeled after +python-pptx+.
13
+ #
14
+ # Rbpptx exposes a single, unified +Presentation+ object that can be loaded
15
+ # from disk, mutated in memory, and written back out:
16
+ #
17
+ # require "rbpptx"
18
+ #
19
+ # pres = Rbpptx.open("deck.pptx")
20
+ # pres.slides.each { |slide| puts slide.shapes.map(&:text).join(" / ") }
21
+ # pres.close
22
+ #
23
+ # Block form auto-closes the underlying ZIP:
24
+ #
25
+ # Rbpptx.open("deck.pptx") do |pres|
26
+ # pres.slides.first.shapes.each { |s| puts s.text }
27
+ # end
28
+ module Rbpptx
29
+ class << self
30
+ # Opens an existing +.pptx+ presentation.
31
+ #
32
+ # When a block is given, the presentation is yielded and automatically
33
+ # closed when the block returns (or raises), mirroring the +File.open+ and
34
+ # +Zip::File.open+ idiom.
35
+ #
36
+ # @param path [String, #to_path] filesystem path to a +.pptx+ file
37
+ # @yieldparam pres [Rbpptx::Presentation] opened presentation; auto-closed
38
+ # when the block returns
39
+ # @return [Rbpptx::Presentation, Object] the presentation when no block is
40
+ # given, otherwise the block's return value
41
+ def open(path)
42
+ pres = Presentation.open(path)
43
+ return pres unless block_given?
44
+
45
+ begin
46
+ yield pres
47
+ ensure
48
+ pres.close
49
+ end
50
+ end
51
+
52
+ # Creates a new empty presentation. Not yet implemented; tracked for
53
+ # future write-from-scratch support.
54
+ #
55
+ # @return [Rbpptx::Presentation]
56
+ def new
57
+ raise NotImplementedError, "Rbpptx.new (blank presentation creation) is not implemented yet"
58
+ end
59
+ end
60
+ end
data/sig/rbpptx.rbs ADDED
@@ -0,0 +1,88 @@
1
+ module Rbpptx
2
+ VERSION: String
3
+
4
+ type pathish = String | Pathname
5
+
6
+ def self.open: (pathish path) -> Presentation
7
+ | [T] (pathish path) { (Presentation) -> T } -> T
8
+ def self.new: () -> Presentation
9
+
10
+ class Error < StandardError
11
+ end
12
+
13
+ class UnsupportedFormatError < Error
14
+ end
15
+
16
+ class PresentationFormatError < Error
17
+ end
18
+
19
+ class SlideFormatError < Error
20
+ end
21
+
22
+ class SlideNotFoundError < Error
23
+ end
24
+
25
+ class ClosedPresentationError < Error
26
+ end
27
+
28
+ class Presentation
29
+ P_NS: String
30
+ A_NS: String
31
+ R_NS: String
32
+ PKG_REL_NS: String
33
+ OFFICE_DOC_REL_TYPE: String
34
+ SLIDE_REL_TYPE: String
35
+
36
+ attr_reader path: String
37
+
38
+ def self.open: (pathish path) -> Presentation
39
+ def initialize: (pathish path) -> void
40
+ def slides: () -> Array[Slide]
41
+ def save: (pathish path) -> String
42
+ def close: () -> bool
43
+ def closed?: () -> bool
44
+ end
45
+
46
+ class Slide
47
+ P_NS: String
48
+
49
+ attr_reader part_name: String
50
+
51
+ def initialize: (zip: untyped, part_name: String) -> void
52
+ def name: () -> String
53
+ def shapes: () -> Array[Shape]
54
+ def dirty?: () -> bool
55
+ def mark_dirty!: () -> bool
56
+ def clear_dirty!: () -> bool
57
+ def to_xml: () -> String
58
+ end
59
+
60
+ class Shape
61
+ P_NS: String
62
+ A_NS: String
63
+
64
+ def initialize: (node: untyped, ?slide: Slide?) -> void
65
+ def name: () -> String?
66
+ def placeholder_type: () -> Symbol?
67
+ def paragraphs: () -> Array[Paragraph]
68
+ def runs: () -> Array[Run]
69
+ def text: () -> String
70
+ def text=: (String new_text) -> String
71
+ end
72
+
73
+ class Paragraph
74
+ A_NS: String
75
+
76
+ def initialize: (node: untyped, ?slide: Slide?) -> void
77
+ def runs: () -> Array[Run]
78
+ def text: () -> String
79
+ end
80
+
81
+ class Run
82
+ A_NS: String
83
+
84
+ def initialize: (node: untyped, ?slide: Slide?) -> void
85
+ def text: () -> String
86
+ def text=: (String new_text) -> String
87
+ end
88
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rbpptx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Taro KOBAYASHI
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rubyzip
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: nokogiri
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.19'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '1.19'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ description: rbpptx is a Ruby gem for reading and writing PowerPoint .pptx files,
47
+ modeled after python-pptx.
48
+ email:
49
+ - taro@matzlika.co.jp
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - CHANGELOG.md
55
+ - LICENSE.txt
56
+ - README.md
57
+ - Rakefile
58
+ - lib/rbpptx.rb
59
+ - lib/rbpptx/errors.rb
60
+ - lib/rbpptx/paragraph.rb
61
+ - lib/rbpptx/presentation.rb
62
+ - lib/rbpptx/run.rb
63
+ - lib/rbpptx/shape.rb
64
+ - lib/rbpptx/slide.rb
65
+ - lib/rbpptx/version.rb
66
+ - sig/rbpptx.rbs
67
+ homepage: https://github.com/matzlika/rbpptx
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ source_code_uri: https://github.com/matzlika/rbpptx
72
+ bug_tracker_uri: https://github.com/matzlika/rbpptx/issues
73
+ changelog_uri: https://github.com/matzlika/rbpptx/blob/main/CHANGELOG.md
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.2'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.3
89
+ specification_version: 4
90
+ summary: PowerPoint (PPTX) processing for Ruby.
91
+ test_files: []