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 +7 -0
- data/CHANGELOG.md +78 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/Rakefile +11 -0
- data/lib/rbpptx/errors.rb +28 -0
- data/lib/rbpptx/paragraph.rb +44 -0
- data/lib/rbpptx/presentation.rb +187 -0
- data/lib/rbpptx/run.rb +47 -0
- data/lib/rbpptx/shape.rb +127 -0
- data/lib/rbpptx/slide.rb +78 -0
- data/lib/rbpptx/version.rb +4 -0
- data/lib/rbpptx.rb +60 -0
- data/sig/rbpptx.rbs +88 -0
- metadata +91 -0
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
|
+
[](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,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
|
data/lib/rbpptx/shape.rb
ADDED
|
@@ -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
|
data/lib/rbpptx/slide.rb
ADDED
|
@@ -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
|
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: []
|