Setting the file. One moment.
Add Slide · PPTX · anthropics/skills · Skills Docs
ContentsBack to the top of the page Opc Dig Sig
def _insert_into_sld_id_lst
— line 258
This file
Number 11.2
Position 2 of 54
Type Python
Size 14 KB
Lines 367 scripts/ add_slide.py
Python · 367 lines · 14 KB
15
16 Usage:
17 python add_slide.py unpacked/ slide2.xml # duplicate slide2
18 python add_slide.py unpacked/ slideLayout3.xml # new slide from a layout
19 python add_slide.py unpacked/ slide2.xml --after slide2.xml
20 python add_slide.py deck.pptx slide2.xml # rewrite deck.pptx in place
21 python add_slide.py deck.pptx slide2.xml -o out.pptx
22
23 A duplicated slide still holds the source's content: edit ppt/slides/slideN.xml
24 (printed on success) to change it. To list layouts: ls <dir>/ppt/slideLayouts/
25 """
26
27 import argparse
28 import re
29 import shutil
30 import sys
31 from typing import NoReturn
32 import tempfile
33 import zipfile
34 from pathlib import Path
35
36 from office.helpers import rezip, safe_extract
37
38 MINIMAL_SLIDE_XML = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
39 <p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
40 <p:cSld>
41 <p:spTree>
42 <p:nvGrpSpPr>
43 <p:cNvPr id="1" name=""/>
44 <p:cNvGrpSpPr/>
45 <p:nvPr/>
46 </p:nvGrpSpPr>
47 <p:grpSpPr>
48 <a:xfrm>
49 <a:off x="0" y="0"/>
50 <a:ext cx="0" cy="0"/>
51 <a:chOff x="0" y="0"/>
52 <a:chExt cx="0" cy="0"/>
53 </a:xfrm>
54 </p:grpSpPr>
55 </p:spTree>
56 </p:cSld>
57 <p:clrMapOvr>
58 <a:masterClrMapping/>
59 </p:clrMapOvr>
60 </p:sld>'''
61
62 SHARED_PART_TYPES = ( "chart" , "diagramData" , "oleObject" , "package" )
63
64 NOTES_SLIDE_TYPE_RE = re.compile( r """Type= [ "' ][ ^"' ] * /relationships/notesSlide [ "' ] """ )
65 RELATIONSHIP_RE = re.compile( r "<Relationship \b[ ^> ] *? (?: /> | > . *? </Relationship \s * > ) " , re. DOTALL )
66
67 SLIDE_ID_MIN = 256
68 SLIDE_ID_MAX = 2147483647
69
70
71 def _die (msg: str ) -> NoReturn:
72 print ( f "Error: { msg } " , file = sys.stderr)
73 sys.exit( 1 )
74
75
76 def get_next_slide_number (slides_dir: Path) -> int :
77 existing = [ int (m.group( 1 )) for f in slides_dir.glob( "slide*.xml" )
78 if (m := re.match( r "slide (\d + ) \. xml" , f.name))]
79 return max (existing) + 1 if existing else 1
80
81
82 def parse_source (source: str ) -> tuple[ str , str | None ]:
83 if source.startswith( "slideLayout" ) and source.endswith( ".xml" ):
84 return ( "layout" , source)
85
86 return ( "slide" , None )
87
88
89 def create_slide_from_layout (unpacked_dir: Path, layout_file: str , after: str | None = None ) -> str :
90 slides_dir = unpacked_dir / "ppt" / "slides"
91 rels_dir = slides_dir / "_rels"
92 layout_path = unpacked_dir / "ppt" / "slideLayouts" / layout_file
93
94 if not layout_path.exists():
95 _die( f " { layout_path } not found" )
96
97 next_num = get_next_slide_number(slides_dir)
98 dest = f "slide { next_num } .xml"
99 after_rid = _precheck_registration(unpacked_dir, after, dest)
100 slides_dir.mkdir( parents = True , exist_ok = True )
101
102 (slides_dir / dest).write_text( MINIMAL_SLIDE_XML , encoding = "utf-8" )
103
104 rels_dir.mkdir( exist_ok = True )
105 rels_xml = f '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
106 <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
107 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/ { layout_file } "/>
108 </Relationships>'''
109 (rels_dir / f " { dest } .rels" ).write_text(rels_xml, encoding = "utf-8" )
110
111 _register_slide(unpacked_dir, dest, layout_file, after_rid)
112 return dest
113
114
115 def duplicate_slide (unpacked_dir: Path, source: str , after: str | None = None ) -> str :
116 slides_dir = unpacked_dir / "ppt" / "slides"
117 rels_dir = slides_dir / "_rels"
118 source_slide = slides_dir / source
119
120 if not source_slide.exists():
121 _die( f " { source_slide } not found" )
122
123 next_num = get_next_slide_number(slides_dir)
124 dest = f "slide { next_num } .xml"
125 after_rid = _precheck_registration(unpacked_dir, after, dest)
126
127 shutil.copy2(source_slide, slides_dir / dest)
128
129 source_rels = rels_dir / f " { source } .rels"
130 shared_parts: list[ str ] = []
131 if source_rels.exists():
132 dest_rels = rels_dir / f " { dest } .rels"
133 shutil.copy2(source_rels, dest_rels)
134 rels_content = dest_rels.read_text( encoding = "utf-8" )
135 rels_content = RELATIONSHIP_RE .sub(
136 lambda m: "" if NOTES_SLIDE_TYPE_RE .search(m.group( 0 )) else m.group( 0 ),
137 rels_content,
138 )
139 dest_rels.write_text(rels_content, encoding = "utf-8" )
140 shared_parts = sorted ({
141 t for t in re.findall( r 'Type=" [ ^" ] * /relationships/ (\w + ) "' , rels_content)
142 if t in SHARED_PART_TYPES
143 })
144
145 _register_slide(unpacked_dir, dest, source, after_rid)
146 if shared_parts:
147 print (
148 f "Note: { dest } shares its { ', ' .join(shared_parts) } part(s) with { source } "
149 f "(they are referenced, not copied) — editing those parts changes both slides"
150 )
151 return dest
152
153
154 def _precheck_registration (unpacked_dir: Path, after: str | None , dest: str ) -> str | None :
155 pres_path = unpacked_dir / "ppt" / "presentation.xml"
156 if not pres_path.exists():
157 _die( f " { pres_path } not found — is this an unpacked PPTX?" )
158 xml = pres_path.read_text( encoding = "utf-8" )
159
160 has_slot = (
161 "</p:sldIdLst>" in xml
162 or re.search( r "<p:sldIdLst \s * />" , xml)
163 or "</p:sldMasterIdLst>" in xml
164 )
165 if not has_slot:
166 _die( "presentation.xml has no <p:sldIdLst> (or <p:sldMasterIdLst> to anchor a new one)" )
167
168 stale = []
169 content_types = unpacked_dir / "[Content_Types].xml"
170 if content_types.exists() and f 'PartName="/ppt/slides/ { dest } "' in content_types.read_text( encoding = "utf-8" ):
171 stale.append( "[Content_Types].xml" )
172 pres_rels = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
173 if pres_rels.exists() and _find_slide_relationship(
174 pres_rels.read_text( encoding = "utf-8" ), dest
175 ):
176 stale.append( "presentation.xml.rels" )
177 if stale:
178 _die(
179 f " { dest } is still registered in { ' and ' .join(stale) } but absent from ppt/slides/ — "
180 f "run clean.py first"
181 )
182
183 if not after:
184 return None
185 after_rid = _rid_for_slide(unpacked_dir, after)
186 if not re.search( rf '<p:sldId\b[^>]*r:id=" { re.escape(after_rid) } "[^>]*>' , xml):
187 _die( f " { after } ( { after_rid } ) is not listed in <p:sldIdLst>" )
188 return after_rid
189
190
191 def _register_slide (unpacked_dir: Path, dest: str , source_desc: str , after_rid: str | None ) -> None :
192 _add_to_content_types(unpacked_dir, dest)
193 rid = _add_to_presentation_rels(unpacked_dir, dest)
194 slide_id = _get_next_slide_id(unpacked_dir)
195 pos, total = _insert_into_sld_id_lst(unpacked_dir, slide_id, rid, after_rid)
196
197 print ( f "Created ppt/slides/ { dest } from { source_desc } " )
198 print (
199 f 'Inserted <p:sldId id=" { slide_id } " r:id=" { rid } "/> into <p:sldIdLst> '
200 f "at position { pos } of { total } "
201 )
202
203
204 def _add_to_content_types (unpacked_dir: Path, dest: str ) -> None :
205 content_types_path = unpacked_dir / "[Content_Types].xml"
206 content_types = content_types_path.read_text( encoding = "utf-8" )
207
208 new_override = f '<Override PartName="/ppt/slides/ { dest } " ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
209
210 if f 'PartName="/ppt/slides/ { dest } "' not in content_types:
211 content_types = content_types.replace( "</Types>" , f " { new_override }\n </Types>" )
212 content_types_path.write_text(content_types, encoding = "utf-8" )
213
214
215 def _add_to_presentation_rels (unpacked_dir: Path, dest: str ) -> str :
216 pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
217 pres_rels = pres_rels_path.read_text( encoding = "utf-8" )
218
219 existing = _find_slide_relationship(pres_rels, dest)
220 if existing:
221 return existing
222
223 pres_xml = (unpacked_dir / "ppt" / "presentation.xml" ).read_text( encoding = "utf-8" )
224 used = { int (n) for n in re.findall( r ' \b Id="rId (\d + ) "' , pres_rels)}
225 used |= { int (n) for n in re.findall( r ' \b r:id="rId (\d + ) "' , pres_xml)}
226 rid = f "rId { max (used) + 1 if used else 1 } "
227
228 new_rel = f '<Relationship Id=" { rid } " Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/ { dest } "/>'
229 pres_rels = pres_rels.replace( "</Relationships>" , f " { new_rel }\n </Relationships>" )
230 pres_rels_path.write_text(pres_rels, encoding = "utf-8" )
231
232 return rid
233
234
235 def _find_slide_relationship (pres_rels: str , slide_name: str ) -> str | None :
236 for m in re.finditer( r "<Relationship \b[ ^> ] * >" , pres_rels):
237 element = m.group( 0 )
238 if re.search( rf 'Target="(?:/ppt/)?slides/ { re.escape(slide_name) } "' , element):
239 id_match = re.search( r ' \b Id=" ([ ^" ] + ) "' , element)
240 if id_match:
241 return id_match.group( 1 )
242 return None
243
244
245 def _get_next_slide_id (unpacked_dir: Path) -> int :
246 pres_content = (unpacked_dir / "ppt" / "presentation.xml" ).read_text( encoding = "utf-8" )
247 used = { int (m) for m in re.findall( r '<p:sldId [ ^> ] * \b id=" (\d + ) "' , pres_content)}
248
249 candidate = max ((i for i in used if i >= SLIDE_ID_MIN ), default = SLIDE_ID_MIN - 1 ) + 1
250 if candidate <= SLIDE_ID_MAX and candidate not in used:
251 return candidate
252 for i in range ( SLIDE_ID_MIN , SLIDE_ID_MAX + 1 ):
253 if i not in used:
254 return i
255 _die( "no slide id available in [256, 2147483647] — the deck is full" )
256
257
258 def _insert_into_sld_id_lst (
259 unpacked_dir: Path, slide_id: int , rid: str , after_rid: str | None = None
260 ) -> tuple[ int , int ]:
261 pres_path = unpacked_dir / "ppt" / "presentation.xml"
262 xml = pres_path.read_text( encoding = "utf-8" )
263 entry = f '<p:sldId id=" { slide_id } " r:id=" { rid } "/>'
264
265 if f 'r:id=" { rid } "' in xml:
266 _die( f "presentation.xml already references { rid } ; refusing to add a duplicate" )
267
268 if after_rid:
269 open_tag = re.search( rf '<p:sldId\b[^>]*r:id=" { re.escape(after_rid) } "[^>]*>' , xml)
270 if not open_tag:
271 _die( f " { after_rid } is not listed in <p:sldIdLst>" )
272 end = open_tag.end()
273 if not open_tag.group( 0 ).endswith( "/>" ):
274 close = xml.find( "</p:sldId>" , end)
275 if close == - 1 :
276 _die( f "unclosed <p:sldId> for { after_rid } in presentation.xml" )
277 end = close + len ( "</p:sldId>" )
278 xml = xml[:end] + entry + xml[end:]
279 elif "</p:sldIdLst>" in xml:
280 xml = xml.replace( "</p:sldIdLst>" , f " { entry } </p:sldIdLst>" , 1 )
281 elif re.search( r "<p:sldIdLst \s * />" , xml):
282 xml = re.sub( r "<p:sldIdLst \s * />" , f "<p:sldIdLst> { entry } </p:sldIdLst>" , xml, count = 1 )
283 elif "</p:sldMasterIdLst>" in xml:
284 xml = xml.replace(
285 "</p:sldMasterIdLst>" , f "</p:sldMasterIdLst><p:sldIdLst> { entry } </p:sldIdLst>" , 1
286 )
287 else :
288 _die( "presentation.xml has no <p:sldIdLst> (or <p:sldMasterIdLst> to anchor a new one)" )
289
290 pres_path.write_text(xml, encoding = "utf-8" )
291
292 lst = re.search( r "<p:sldIdLst> (. * ) </p:sldIdLst>" , xml, re. DOTALL )
293 entries = re.findall( r "<p:sldId \b[ ^> ] * >" , lst.group( 1 )) if lst else []
294 position = next (
295 (i for i, e in enumerate (entries, 1 ) if f 'r:id=" { rid } "' in e), len (entries)
296 )
297 return position, len (entries)
298
299
300 def _rid_for_slide (unpacked_dir: Path, slide_name: str ) -> str :
301 pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
302 rid = _find_slide_relationship(pres_rels_path.read_text( encoding = "utf-8" ), slide_name)
303 if not rid:
304 _die( f " { slide_name } has no relationship in presentation.xml.rels" )
305 return rid
306
307
308 def add_slide (unpacked_dir: Path, source: str , after: str | None = None ) -> str :
309 source_type, layout_file = parse_source(source)
310 if source_type == "layout" and layout_file is not None :
311 return create_slide_from_layout(unpacked_dir, layout_file, after)
312 return duplicate_slide(unpacked_dir, source, after)
313
314
315 def add_slide_to_package (
316 package: Path, source: str , after: str | None = None , output: Path | None = None
317 ) -> str :
318 out = output or package
319 with tempfile.TemporaryDirectory() as tmp:
320 tmp_path = Path(tmp)
321 with zipfile.ZipFile(package) as zf:
322 safe_extract(zf, tmp_path)
323 dest = add_slide(tmp_path, source, after)
324 rezip(tmp_path, out)
325 print ( f "Wrote { out } — the new slide is ppt/slides/ { dest } inside it (unpack to edit its content)" )
326 return dest
327
328
329 def main () -> None :
330 parser = argparse.ArgumentParser(
331 description = "Add a slide to a PPTX: duplicate a slide or instantiate a layout. "
332 "Registers content types, relationships, and <p:sldIdLst>."
333 )
334 parser.add_argument( "target" , help = "Unpacked PPTX directory OR a .pptx/.potx file" )
335 parser.add_argument(
336 "source" ,
337 help = "slideN.xml to duplicate, or slideLayoutN.xml to create from a layout "
338 "(list layouts with: ls <dir>/ppt/slideLayouts/)" ,
339 )
340 parser.add_argument(
341 "--after" ,
342 metavar = "SLIDE" ,
343 help = "insert after this slide, e.g. slide2.xml (default: append at the end)" ,
344 )
345 parser.add_argument(
346 "-o" ,
347 "--output" ,
348 help = "output file (only with a .pptx/.potx target; default: rewrite the input in place)" ,
349 )
350 args = parser.parse_args()
351
352 target = Path(args.target)
353 if target.is_dir():
354 if args.output:
355 parser.error( "--output is only valid for .pptx/.potx input; a directory is modified in place" )
356 add_slide(target, args.source, args.after)
357 elif target.is_file() and target.suffix.lower() in ( ".pptx" , ".potx" ):
358 try :
359 add_slide_to_package(target, args.source, args.after, Path(args.output) if args.output else None )
360 except ( OSError , ValueError , zipfile.BadZipFile) as e:
361 _die( str (e))
362 else :
363 _die( f " { target } is neither a directory nor a .pptx/.potx file" )
364
365
366 if __name__ == "__main__" :
367 main()