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 +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +95 -9
- data/lib/rbpptx/package.rb +274 -0
- data/lib/rbpptx/presentation.rb +201 -75
- data/lib/rbpptx/shape.rb +31 -75
- data/lib/rbpptx/shape_container.rb +161 -0
- data/lib/rbpptx/slide.rb +131 -38
- data/lib/rbpptx/table.rb +106 -0
- data/lib/rbpptx/text_body.rb +92 -0
- data/lib/rbpptx/version.rb +1 -1
- data/lib/rbpptx.rb +4 -0
- data/sig/rbpptx.rbs +148 -10
- metadata +13 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c99ecf7e3e63fbcaa1de703c43ef4b922b91e7b28d66dccfda044031ec2a0032
|
|
4
|
+
data.tar.gz: b87c7117ae034d6c414999223ccb96e460b5ca0535463c32793dfd4af8183c30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 /
|
|
46
|
+
- inserting / duplicating slides
|
|
40
47
|
- editing media (images, embedded fonts) or adding new shapes
|
|
41
|
-
-
|
|
42
|
-
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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` |
|
|
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
|
-
|
|
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
|