rbpptx 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4494da8ee976e807536010f1ffe12a0dbaecd1d13e4b91b22fc9874c6bf8cc60
4
- data.tar.gz: f8b93b7f227deb25e04f1c1841210ece0f0806298e1b1ce9f910c23bb343544d
3
+ metadata.gz: c99ecf7e3e63fbcaa1de703c43ef4b922b91e7b28d66dccfda044031ec2a0032
4
+ data.tar.gz: b87c7117ae034d6c414999223ccb96e460b5ca0535463c32793dfd4af8183c30
5
5
  SHA512:
6
- metadata.gz: 4db6d5cf34753a2ffc93472f08629fe515d28eb69053cb59dbcacb0138cd972eb214a7f78678b824f8f8b5f15cb81d58ec6a971ddd948e2d1e4ee4c39c5e6519
7
- data.tar.gz: 1a988ba99c8a250f70a6edda334574701492e4b99482ff2055f65c10a7a02d37346753a7fb97a69f49c0bc3ca0d4d1a26d024a42546f93b20bdf8e57e0a4062f
6
+ metadata.gz: d60c2d4f46dfde855f3ca344789d3c44a2e1c058b2c4e223e0933b48c14cd2526678117006b21fdef481fb90a81792fd305530a50a3ff6fa8d804a9173d74179
7
+ data.tar.gz: da943c3e815292ab9828868d747c3808dbd4dbe971a9a297b4bd57c290780d7945ae0867a50edda626f5f9aaea70193b8fa7289aeff292dae0306559b46f3fec
data/CHANGELOG.md CHANGED
@@ -6,6 +6,54 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-11-10
10
+
11
+ ### Added
12
+
13
+ - Placeholder-aware API: `Slide#title` (the `:title` / `:ctrTitle`
14
+ placeholder), `Slide#placeholders` returning `Rbpptx::Placeholders`
15
+ (`placeholders[:body]` by type, `placeholders[1]` by `<p:ph idx>`),
16
+ `Shape#placeholder?` and `Shape#placeholder_idx`.
17
+ - Speaker notes: `Slide#notes` / `Slide#notes=`, `Slide#notes_slide`
18
+ returning `Rbpptx::NotesSlide`. Setting notes on a slide without a notes
19
+ page creates one from the notes master; raises `Rbpptx::Error` when the
20
+ presentation has no notes master.
21
+ - Group shapes and tables: `Slide#groups` (`Rbpptx::GroupShape`, nestable),
22
+ `Slide#all_shapes` (every `<p:sp>`, descending into groups),
23
+ `Slide#tables` / `Slide#all_tables` (`Rbpptx::Table`, `TableRow`,
24
+ `TableCell`). Table cells support the same `text` / `text=` /
25
+ `paragraphs` / `runs` API as shapes, plus `merge_origin?` / `spanned?`.
26
+ - Slide structure edits: `Presentation#move_slide`,
27
+ `Presentation#delete_slide` (removes the notes page, section and
28
+ custom-show entries, relationships, and content-type overrides; refuses
29
+ when another part still references the slide unless `unlink: true` is
30
+ given, which strips "jump to slide" hyperlinks first), and
31
+ `Presentation#slide(index)` raising `Rbpptx::SlideNotFoundError`.
32
+
33
+ ### Changed
34
+
35
+ - Allow `rubyzip` 3.x (`>= 2.3, < 4`). Development dependencies now allow
36
+ `rbs` 4.x and `rdoc` 8.x.
37
+ - Re-serialized parts are written without pretty-printing, so edits no
38
+ longer re-indent single-line XML.
39
+ - `text=` (shapes and table cells) keeps template formatting on empty
40
+ fields: when the body has no runs, the first `<a:endParaRPr>` is used as
41
+ the run template, and the first paragraph's `<a:pPr>` (alignment,
42
+ indent) and `<a:endParaRPr>` are cloned onto every new paragraph.
43
+ Previously, filling an empty form field fell back to PowerPoint's default
44
+ 18pt text and lost the field's alignment.
45
+
46
+ ### Fixed
47
+
48
+ - Saving a package whose ZIP entries use data descriptors (general-purpose
49
+ bit 3, as written by Google Slides exports and other streaming zippers)
50
+ produced a file PowerPoint refused to open, even for a no-op save. The
51
+ flag is now cleared on raw-copied entries, whose local headers carry the
52
+ real CRC and sizes.
53
+ - Saving twice from the same `Presentation` lost the edits made before the
54
+ first save; saving over the source file broke later lazy reads of
55
+ not-yet-parsed slides.
56
+
9
57
  ## [0.1.0] - 2026-04-26
10
58
 
11
59
  - Initial public release.
data/README.md CHANGED
@@ -29,6 +29,13 @@ Supported:
29
29
  - **surgical setter**: `run.text = "..."` rewrites only the run's `<a:t>`
30
30
  content, preserving `<a:rPr>`, soft line breaks, sibling runs, paragraph
31
31
  properties, and unknown OOXML extensions
32
+ - placeholder-aware access: `slide.title`, `slide.placeholders[:body]`,
33
+ `slide.placeholders[1]` (by `idx`)
34
+ - speaker notes: read and write `slide.notes`; a notes page is created from
35
+ the notes master when the slide has none
36
+ - text inside group shapes (nested to any depth) and table cells:
37
+ `slide.groups`, `slide.all_shapes`, `slide.tables`, `table.cell(r, c)`
38
+ - reorder and delete slides: `pres.move_slide`, `pres.delete_slide`
32
39
  - save back to a new path, or overwrite the original via temp-file + atomic
33
40
  rename — unmodified entries stream through untouched
34
41
 
@@ -36,10 +43,10 @@ Out of scope:
36
43
 
37
44
  - creating a presentation from scratch — `Rbpptx.new` is reserved for a
38
45
  future release and currently raises `NotImplementedError`
39
- - inserting / deleting / reordering / duplicating slides
46
+ - inserting / duplicating slides
40
47
  - editing media (images, embedded fonts) or adding new shapes
41
- - placeholder-aware API (`slide.title`, `slide.placeholders[:body]`)
42
- - notes pages, comments, charts, SmartArt
48
+ - adding / removing table rows and columns, table styling
49
+ - comments, charts, SmartArt
43
50
  - legacy `.ppt` (BIFF/CFB) input — `rbpptx` reads OOXML `.pptx` only.
44
51
  Convert first, e.g. `libreoffice --headless --convert-to pptx file.ppt`.
45
52
  `Rbpptx.open` detects the OLE compound-file magic and raises
@@ -58,7 +65,7 @@ Or in a `Gemfile`:
58
65
  gem "rbpptx"
59
66
  ```
60
67
 
61
- Requires Ruby 3.2+, `nokogiri`, and `rubyzip`.
68
+ Requires Ruby 3.2+, `nokogiri`, and `rubyzip` (2.3+ or 3.x).
62
69
 
63
70
  ## Usage
64
71
 
@@ -115,16 +122,92 @@ Rbpptx.open("template.pptx") do |pres|
115
122
  end
116
123
  ```
117
124
 
118
- Or target by placeholder role:
125
+ ### Placeholders
126
+
127
+ Target shapes by placeholder role instead of by position:
119
128
 
120
129
  ```ruby
121
130
  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."
131
+ slide = pres.slides.first
132
+ slide.title.text = "Q2 Review" # :title or :ctrTitle
133
+ slide.placeholders[:subTitle].text = "Acme Inc." # Symbol: by type
134
+ slide.placeholders[1].text = "Acme Inc." # Integer: by <p:ph idx>
135
+ slide.placeholders.map(&:placeholder_type) # => [:ctrTitle, :subTitle]
124
136
  pres.save("filled.pptx")
125
137
  end
126
138
  ```
127
139
 
140
+ Integer keys follow python-pptx and match the placeholder's `idx`
141
+ attribute, not its position in the list.
142
+
143
+ ### Speaker notes
144
+
145
+ ```ruby
146
+ Rbpptx.open("deck.pptx") do |pres|
147
+ pres.slides.each { |s| puts s.notes } # "" when the slide has no notes page
148
+ pres.slides[0].notes = "Talk about Q2.\nThen Q3."
149
+ pres.save("deck.pptx")
150
+ end
151
+ ```
152
+
153
+ Setting notes on a slide without a notes page creates one from the
154
+ presentation's notes master (new part, relationships, and content-type
155
+ override). Decks that have never had notes may lack a notes master; in that
156
+ case `notes=` raises `Rbpptx::Error` — add a note in PowerPoint once to
157
+ create it.
158
+
159
+ ### Groups and tables
160
+
161
+ `slide.shapes` returns only the top-level `<p:sp>` shapes. Shapes inside
162
+ group shapes and the text of table cells are reachable separately:
163
+
164
+ ```ruby
165
+ Rbpptx.open("deck.pptx") do |pres|
166
+ slide = pres.slides[0]
167
+
168
+ slide.groups.first.shapes # direct children of a group
169
+ slide.all_shapes # every <p:sp>, descending into nested groups
170
+ slide.all_shapes.flat_map(&:runs).each { |r| r.text = r.text.gsub("{{name}}", "Acme") }
171
+
172
+ table = slide.tables.first # slide.all_tables also searches groups
173
+ table.row_count # => 3
174
+ table.cell(0, 1).text # => "Q2"
175
+ table.cell(1, 1).text = "42" # same setters as Shape (text=, runs, paragraphs)
176
+ table.rows.map { |row| row.cells.map(&:text) }
177
+ table.cell(1, 0).spanned? # covered by a merged cell?
178
+
179
+ pres.save("deck.pptx")
180
+ end
181
+ ```
182
+
183
+ ### Reordering and deleting slides
184
+
185
+ ```ruby
186
+ Rbpptx.open("deck.pptx") do |pres|
187
+ pres.move_slide(0, -1) # first slide to the end
188
+ pres.move_slide(pres.slides[3], 0)
189
+ pres.delete_slide(2) # also removes its notes page
190
+ pres.slide(0).name # raises SlideNotFoundError when out of range
191
+ pres.save("deck.pptx")
192
+ end
193
+ ```
194
+
195
+ `delete_slide` removes the slide's `<p:sldId>`, its relationship, its
196
+ custom-show and section entries, its content-type override, and the slide
197
+ and notes parts. Shared parts (layouts, images) are kept.
198
+
199
+ If another slide still points at the one being deleted — typically a
200
+ "jump to slide" link on an agenda slide — deletion is refused with
201
+ `Rbpptx::Error`, because PowerPoint treats a dangling reference as a
202
+ corrupt file. Pass `unlink: true` to remove those hyperlinks first (the
203
+ link text stays):
204
+
205
+ ```ruby
206
+ pres.delete_slide(5, unlink: true)
207
+ ```
208
+
209
+ References that are not hyperlinks are always refused.
210
+
128
211
  ### Same-path overwrite
129
212
 
130
213
  `Presentation#save` writes to a temp file in the target directory and
@@ -164,7 +247,7 @@ the presentation path and (where relevant) the offending part name:
164
247
  | `Rbpptx::UnsupportedFormatError` | file is empty, not a ZIP, or a legacy `.ppt` (BIFF/CFB) container |
165
248
  | `Rbpptx::PresentationFormatError` | `_rels/.rels`, `ppt/presentation.xml`, or its rels are malformed |
166
249
  | `Rbpptx::SlideFormatError` | a slide part is missing or its XML cannot be parsed |
167
- | `Rbpptx::SlideNotFoundError` | reserved for future index-based slide accessors |
250
+ | `Rbpptx::SlideNotFoundError` | `slide(i)` / `move_slide` / `delete_slide` index is out of range |
168
251
  | `Rbpptx::ClosedPresentationError` | `slides` / `save` called after `close` |
169
252
 
170
253
  ```ruby
@@ -188,7 +271,10 @@ end
188
271
  re-serialize only the slides whose shapes have been mutated, and stream
189
272
  every other ZIP entry through with its already-deflated bytes
190
273
  (`Zip::OutputStream#copy_raw_entry`) — no decompress, no re-deflate.
191
- Bit-identical preservation, not just content-identical.
274
+ The compressed payload is copied verbatim. Entries written by streaming
275
+ zippers (e.g. Google Slides exports) get their "data descriptor" flag
276
+ cleared, since the real CRC and sizes are written into the local header
277
+ instead.
192
278
  - **rPr cloning, not rebuilding.** When a shape's text is replaced, the
193
279
  first existing `<a:rPr>` is deep-cloned onto each new run. A template's
194
280
  font, size, color, and `lang` attribute carry through without `rbpptx`
@@ -0,0 +1,274 @@
1
+ require "pathname"
2
+ require "set"
3
+
4
+ module Rbpptx
5
+ # The OPC (Open Packaging Conventions) container underneath a presentation.
6
+ #
7
+ # +Package+ owns the open ZIP and is the single place that knows how parts
8
+ # are read, mutated, added, removed and written back. Parts are parsed on
9
+ # demand and cached; once a caller signals a mutation via {#touch}, the
10
+ # cached document is serialized in place of the original bytes on save.
11
+ # Everything else is streamed through with +copy_raw_entry+, so parts the
12
+ # gem never looked at round-trip byte-for-byte.
13
+ #
14
+ # @api private
15
+ class Package
16
+ PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships".freeze
17
+ CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types".freeze
18
+ CONTENT_TYPES_PART = "[Content_Types].xml".freeze
19
+ SAVE_OPTIONS = Nokogiri::XML::Node::SaveOptions::AS_XML
20
+
21
+ # A single +<Relationship>+ entry, with its target already resolved to an
22
+ # absolute part name (+nil+ for external targets such as hyperlinks).
23
+ Relationship = Struct.new(:id, :type, :target, :part, :external, keyword_init: true)
24
+
25
+ attr_reader :path
26
+
27
+ def initialize(path)
28
+ @path = path
29
+ @zip = Zip::File.open(path)
30
+ @docs = {}
31
+ @modified = Set.new
32
+ @deleted = Set.new
33
+ end
34
+
35
+ def close
36
+ @zip&.close
37
+ @zip = nil
38
+ end
39
+
40
+ # @return [Boolean] whether +name+ exists in the package as it would be
41
+ # saved right now
42
+ def part?(name)
43
+ return false if @deleted.include?(name)
44
+
45
+ @docs.key?(name) || !@zip.find_entry(name).nil?
46
+ end
47
+
48
+ # @return [Array<String>] every part name as it would be saved right now
49
+ def part_names
50
+ names = @zip.entries.reject(&:directory?).map(&:name).reject { |n| @deleted.include?(n) }
51
+ names | @modified.to_a
52
+ end
53
+
54
+ # Parses (and caches) the XML part +name+.
55
+ #
56
+ # @raise [error_class] if the part is missing or not well-formed
57
+ def xml(name, error_class: PresentationFormatError)
58
+ @docs[name] ||= begin
59
+ entry = @deleted.include?(name) ? nil : @zip.find_entry(name)
60
+ raise error_class, "#{@path}: missing part #{name}" unless entry
61
+
62
+ doc = Nokogiri::XML(entry.get_input_stream.read)
63
+ raise error_class, "#{@path}: #{name}: #{doc.errors.first}" unless doc.errors.empty?
64
+
65
+ doc
66
+ end
67
+ end
68
+
69
+ # Marks +name+ as mutated so its cached document is written on save.
70
+ def touch(name)
71
+ @modified << name
72
+ true
73
+ end
74
+
75
+ # Adds a brand-new part (or replaces a deleted one).
76
+ def add(name, doc)
77
+ @deleted.delete(name)
78
+ @docs[name] = doc
79
+ @modified << name
80
+ doc
81
+ end
82
+
83
+ # Removes +name+, its relationships part, and its content-type override.
84
+ def delete(name)
85
+ [name, rels_part_for(name)].each do |n|
86
+ @docs.delete(n)
87
+ @modified.delete(n)
88
+ @deleted << n
89
+ end
90
+ remove_content_type_override(name)
91
+ end
92
+
93
+ # ---------- relationships ----------
94
+
95
+ # @return [String] the rels part name for +part+, e.g.
96
+ # +ppt/slides/_rels/slide1.xml.rels+
97
+ def rels_part_for(part)
98
+ dir = File.dirname(part)
99
+ base = File.basename(part)
100
+ dir == "." ? "_rels/#{base}.rels" : "#{dir}/_rels/#{base}.rels"
101
+ end
102
+
103
+ # @return [Array<Relationship>] relationships whose source is +part+
104
+ def relationships(part)
105
+ rels_part = rels_part_for(part)
106
+ return [] unless part?(rels_part)
107
+
108
+ xml(rels_part).xpath("/pkg:Relationships/pkg:Relationship", "pkg" => PKG_REL_NS).map do |r|
109
+ external = r["TargetMode"] == "External"
110
+ Relationship.new(
111
+ id: r["Id"], type: r["Type"], target: r["Target"],
112
+ part: external ? nil : resolve(part, r["Target"].to_s), external: external
113
+ )
114
+ end
115
+ end
116
+
117
+ # @return [Array<String>] internal targets of +part+'s relationships of
118
+ # type +rel_type+
119
+ def related(part, rel_type)
120
+ relationships(part).select { |r| r.type == rel_type && r.part }.map(&:part)
121
+ end
122
+
123
+ # @return [Array<String>] parts that hold an internal relationship
124
+ # pointing at +target_part+
125
+ def referrers_of(target_part)
126
+ part_names.grep(%r{(\A|/)_rels/[^/]+\.rels\z}).filter_map do |rels_part|
127
+ source = source_part_for(rels_part)
128
+ source if relationships(source).any? { |r| r.part == target_part }
129
+ end
130
+ end
131
+
132
+ # Adds a relationship from +source+ to +target_part+, creating the rels
133
+ # part if needed.
134
+ #
135
+ # @return [String] the new relationship id
136
+ def add_relationship(source, rel_type, target_part)
137
+ rels_part = rels_part_for(source)
138
+ unless part?(rels_part)
139
+ add(rels_part, Nokogiri::XML(<<~XML))
140
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
141
+ <Relationships xmlns="#{PKG_REL_NS}"/>
142
+ XML
143
+ end
144
+
145
+ doc = xml(rels_part)
146
+ root = doc.root
147
+ taken = root.xpath("./pkg:Relationship/@Id", "pkg" => PKG_REL_NS).map(&:value)
148
+ n = 1
149
+ n += 1 while taken.include?("rId#{n}")
150
+ rid = "rId#{n}"
151
+
152
+ rel = doc.create_element("Relationship", "Id" => rid, "Type" => rel_type,
153
+ "Target" => relative_target(source, target_part))
154
+ root.add_child(rel)
155
+ touch(rels_part)
156
+ rid
157
+ end
158
+
159
+ def remove_relationship(source, rid)
160
+ rels_part = rels_part_for(source)
161
+ return unless part?(rels_part)
162
+
163
+ doc = xml(rels_part)
164
+ doc.xpath("/pkg:Relationships/pkg:Relationship[@Id=$rid]", { "pkg" => PKG_REL_NS }, { "rid" => rid }).each(&:remove)
165
+ touch(rels_part)
166
+ end
167
+
168
+ # Resolves a relationship +target+ relative to the directory of +source+.
169
+ def resolve(source, target)
170
+ return target.sub(%r{\A/}, "") if target.start_with?("/")
171
+
172
+ base_dir = File.dirname(source)
173
+ base_dir = "" if base_dir == "."
174
+ File.expand_path(target, "/#{base_dir}").sub(%r{\A/}, "")
175
+ end
176
+
177
+ def relative_target(source, target_part)
178
+ base_dir = File.dirname("/#{source}")
179
+ Pathname.new("/#{target_part}").relative_path_from(Pathname.new(base_dir)).to_s
180
+ end
181
+
182
+ # ---------- content types ----------
183
+
184
+ def add_content_type_override(part, content_type)
185
+ doc = xml(CONTENT_TYPES_PART)
186
+ el = doc.create_element("Override", "PartName" => "/#{part}", "ContentType" => content_type)
187
+ doc.root.add_child(el)
188
+ touch(CONTENT_TYPES_PART)
189
+ end
190
+
191
+ def remove_content_type_override(part)
192
+ doc = xml(CONTENT_TYPES_PART)
193
+ nodes = doc.xpath("/ct:Types/ct:Override[@PartName=$name]", { "ct" => CT_NS }, { "name" => "/#{part}" })
194
+ return if nodes.empty?
195
+
196
+ nodes.each(&:remove)
197
+ touch(CONTENT_TYPES_PART)
198
+ end
199
+
200
+ # ---------- save ----------
201
+
202
+ # Writes the package to +out_path+ via temp file + atomic rename.
203
+ def save(out_path)
204
+ tmp_path = "#{out_path}.rbpptx-tmp.#{Process.pid}.#{rand(1 << 32).to_s(16)}"
205
+ begin
206
+ Zip::OutputStream.open(tmp_path) do |out|
207
+ written = Set.new
208
+ @zip.each do |entry|
209
+ next if entry.directory? || @deleted.include?(entry.name)
210
+
211
+ if @modified.include?(entry.name)
212
+ write_doc(out, entry.name)
213
+ else
214
+ copy_raw(out, entry)
215
+ end
216
+ written << entry.name
217
+ end
218
+ (@modified.to_a - written.to_a).each { |name| write_doc(out, name) }
219
+ end
220
+ File.rename(tmp_path, out_path)
221
+ rescue StandardError
222
+ File.unlink(tmp_path) if File.exist?(tmp_path)
223
+ raise
224
+ end
225
+
226
+ # The open Zip::File re-reads entries from @path by name, so after
227
+ # overwriting our own source the old central directory no longer
228
+ # matches the file on disk. Reopen so later lazy reads stay valid.
229
+ reopen if File.expand_path(out_path) == File.expand_path(@path)
230
+ out_path
231
+ end
232
+
233
+ private
234
+
235
+ DATA_DESCRIPTOR_FLAG = 0b1000
236
+ private_constant :DATA_DESCRIPTOR_FLAG
237
+
238
+ # Streams +entry+'s compressed bytes through unchanged.
239
+ #
240
+ # Entries written by streaming zippers (Google Slides exports, among
241
+ # others) set general-purpose bit 3: "CRC and sizes follow the data in
242
+ # a data descriptor". +copy_raw_entry+ writes the real CRC and sizes
243
+ # into the local header but keeps that bit and emits no descriptor,
244
+ # which PowerPoint rejects as corrupt. Clearing the bit on a copy makes
245
+ # the header self-describing again; the payload is not recompressed.
246
+ def copy_raw(out, entry)
247
+ if entry.gp_flags & DATA_DESCRIPTOR_FLAG != 0
248
+ entry = entry.dup
249
+ entry.gp_flags &= ~DATA_DESCRIPTOR_FLAG
250
+ end
251
+ out.copy_raw_entry(entry)
252
+ end
253
+
254
+ def write_doc(out, name)
255
+ out.put_next_entry(name)
256
+ out.write(@docs.fetch(name).to_xml(save_with: SAVE_OPTIONS))
257
+ end
258
+
259
+ def reopen
260
+ @zip.close
261
+ @zip = Zip::File.open(@path)
262
+ @deleted.clear
263
+ end
264
+
265
+ def source_part_for(rels_part)
266
+ return "" if rels_part == "_rels/.rels"
267
+
268
+ dir = File.dirname(rels_part)
269
+ base = File.basename(rels_part, ".rels")
270
+ parent = File.dirname(dir)
271
+ parent == "." ? base : "#{parent}/#{base}"
272
+ end
273
+ end
274
+ end