Setting the file. One moment.
Thumbnail · PPTX · anthropics/skills · Skills Docs
ContentsBack to the top of the page Opc Dig Sig
scripts/ thumbnail.py
Python · 311 lines · 10 KB
17
18 import argparse
19 import posixpath
20 import subprocess
21 import sys
22 import tempfile
23 import zipfile
24 from pathlib import Path
25
26 import defusedxml.minidom
27 from defusedxml import ElementTree
28 from office.helpers import SLIDE_REL_TYPE , opc_target
29 from office.soffice import run_soffice
30 from PIL import Image, ImageDraw, ImageFont
31
32
33 THUMBNAIL_WIDTH = 300
34 CONVERSION_DPI = 100
35 MAX_COLS = 6
36 DEFAULT_COLS = 3
37 JPEG_QUALITY = 95
38 GRID_PADDING = 20
39 BORDER_WIDTH = 2
40 FONT_SIZE_RATIO = 0.10
41 LABEL_PADDING_RATIO = 0.4
42
43
44 def main ():
45 parser = argparse.ArgumentParser(
46 description = "Create thumbnail grids from PowerPoint slides."
47 )
48 parser.add_argument( "input" , help = "Input PowerPoint file (.pptx)" )
49 parser.add_argument(
50 "output_prefix" ,
51 nargs = "?" ,
52 default = "thumbnails" ,
53 help = "Output prefix for image files (default: thumbnails)" ,
54 )
55 parser.add_argument(
56 "--cols" ,
57 type = int ,
58 default = DEFAULT_COLS ,
59 help = f "Number of columns (default: { DEFAULT_COLS } , max: { MAX_COLS } )" ,
60 )
61
62 args = parser.parse_args()
63
64 cols = min (args.cols, MAX_COLS )
65 if args.cols > MAX_COLS :
66 print ( f "Warning: Columns limited to { MAX_COLS } " )
67
68 input_path = Path(args.input)
69 if not input_path.exists() or input_path.suffix.lower() != ".pptx" :
70 print ( f "Error: Invalid PowerPoint file: { args.input } " , file = sys.stderr)
71 sys.exit( 1 )
72
73 output_path = Path( f " { args.output_prefix } .jpg" )
74
75 try :
76 slide_info = get_slide_info(input_path)
77
78 with tempfile.TemporaryDirectory() as temp_dir:
79 temp_path = Path(temp_dir)
80 visible_images = convert_to_images(input_path, temp_path)
81
82 if not visible_images and not any (s[ "hidden" ] for s in slide_info):
83 print ( "Error: No slides found" , file = sys.stderr)
84 sys.exit( 1 )
85
86 slides = build_slide_list(slide_info, visible_images, temp_path)
87
88 grid_files = create_grids(slides, cols, THUMBNAIL_WIDTH , output_path)
89
90 print ( f "Created { len (grid_files) } grid(s):" )
91 for grid_file in grid_files:
92 print ( f " { grid_file } " )
93
94 except Exception as e:
95 print ( f "Error: { e } " , file = sys.stderr)
96 sys.exit( 1 )
97
98
99 def _is_hidden (zf: zipfile.ZipFile, part: str ) -> bool :
100 try :
101 with zf.open(part) as f:
102 for _, root in ElementTree.iterparse(f, events = ( "start" ,)):
103 return root.get( "show" ) in ( "0" , "false" )
104 except ( KeyError , ElementTree.ParseError):
105 return False
106 return False
107
108
109 def get_slide_info (pptx_path: Path) -> list[ dict ]:
110 with zipfile.ZipFile(pptx_path, "r" ) as zf:
111 rels_content = zf.read( "ppt/_rels/presentation.xml.rels" ).decode( "utf-8" )
112 rels_dom = defusedxml.minidom.parseString(rels_content)
113
114 rid_to_part = {}
115 for rel in rels_dom.getElementsByTagName( "Relationship" ):
116 if rel.getAttribute( "Type" ) != SLIDE_REL_TYPE :
117 continue
118 part = opc_target(
119 rel.getAttribute( "Target" ),
120 "ppt/presentation.xml" ,
121 rel.getAttribute( "TargetMode" ),
122 )
123 if part is not None :
124 rid_to_part[rel.getAttribute( "Id" )] = part
125
126 pres_content = zf.read( "ppt/presentation.xml" ).decode( "utf-8" )
127 pres_dom = defusedxml.minidom.parseString(pres_content)
128
129 present = set (zf.namelist())
130
131 slides = []
132 for sld_id in pres_dom.getElementsByTagName( "p:sldId" ):
133 part = rid_to_part.get(sld_id.getAttribute( "r:id" ))
134 if part is not None and part in present:
135 slides.append(
136 { "name" : posixpath.basename(part), "hidden" : _is_hidden(zf, part)}
137 )
138
139 return slides
140
141
142 def build_slide_list (
143 slide_info: list[ dict ],
144 visible_images: list[Path],
145 temp_dir: Path,
146 ) -> list[tuple[Path, str ]]:
147 visible_count = sum ( 1 for info in slide_info if not info[ "hidden" ])
148 rendered_hidden = len (visible_images) == len (slide_info) != visible_count
149
150 if not rendered_hidden and visible_count != len (visible_images):
151 raise ValueError (
152 f "LibreOffice rendered { len (visible_images) } page(s) for { visible_count } "
153 f "visible slide(s) of { len (slide_info) } ; thumbnails would be mislabeled"
154 )
155
156 if visible_images:
157 with Image.open(visible_images[ 0 ]) as img:
158 placeholder_size = img.size
159 else :
160 placeholder_size = ( 1920 , 1080 )
161
162 slides = []
163 visible_idx = 0
164
165 for info in slide_info:
166 if info[ "hidden" ] and not rendered_hidden:
167 placeholder_path = temp_dir / f "hidden- { info[ 'name' ] } .jpg"
168 placeholder_img = create_hidden_placeholder(placeholder_size)
169 placeholder_img.save(placeholder_path, "JPEG" )
170 slides.append((placeholder_path, f " { info[ 'name' ] } (hidden)" ))
171 else :
172 label = f " { info[ 'name' ] } (hidden)" if info[ "hidden" ] else info[ "name" ]
173 slides.append((visible_images[visible_idx], label))
174 visible_idx += 1
175
176 return slides
177
178
179 def create_hidden_placeholder (size: tuple[ int , int ]) -> Image.Image:
180 img = Image.new( "RGB" , size, color = "#F0F0F0" )
181 draw = ImageDraw.Draw(img)
182 line_width = max ( 5 , min (size) // 100 )
183 draw.line([( 0 , 0 ), size], fill = "#CCCCCC" , width = line_width)
184 draw.line([(size[ 0 ], 0 ), ( 0 , size[ 1 ])], fill = "#CCCCCC" , width = line_width)
185 return img
186
187
188 def convert_to_images (pptx_path: Path, temp_dir: Path) -> list[Path]:
189 pdf_path = temp_dir / f " { pptx_path.stem } .pdf"
190
191 result = run_soffice(
192 [ "--headless" , "--convert-to" , "pdf" , "--outdir" , str (temp_dir), str (pptx_path)],
193 capture_output = True ,
194 text = True ,
195 )
196 if result.returncode != 0 or not pdf_path.exists():
197 detail = (result.stderr or result.stdout or "" ).strip()
198 raise RuntimeError ( f "PDF conversion failed: { detail } " if detail else "PDF conversion failed" )
199
200 result = subprocess.run(
201 [
202 "pdftoppm" ,
203 "-jpeg" ,
204 "-r" ,
205 str ( CONVERSION_DPI ),
206 str (pdf_path),
207 str (temp_dir / "slide" ),
208 ],
209 capture_output = True ,
210 text = True ,
211 )
212 if result.returncode != 0 :
213 raise RuntimeError ( "Image conversion failed" )
214
215 return sorted (temp_dir.glob( "slide-*.jpg" ))
216
217
218 def create_grids (
219 slides: list[tuple[Path, str ]],
220 cols: int ,
221 width: int ,
222 output_path: Path,
223 ) -> list[ str ]:
224 max_per_grid = cols * (cols + 1 )
225 grid_files = []
226
227 for chunk_idx, start_idx in enumerate ( range ( 0 , len (slides), max_per_grid)):
228 end_idx = min (start_idx + max_per_grid, len (slides))
229 chunk_slides = slides[start_idx:end_idx]
230
231 grid = create_grid(chunk_slides, cols, width)
232
233 if len (slides) <= max_per_grid:
234 grid_filename = output_path
235 else :
236 stem = output_path.stem
237 suffix = output_path.suffix
238 grid_filename = output_path.parent / f " { stem } - { chunk_idx + 1 }{ suffix } "
239
240 grid_filename.parent.mkdir( parents = True , exist_ok = True )
241 grid.save( str (grid_filename), quality = JPEG_QUALITY )
242 grid_files.append( str (grid_filename))
243
244 return grid_files
245
246
247 def create_grid (
248 slides: list[tuple[Path, str ]],
249 cols: int ,
250 width: int ,
251 ) -> Image.Image:
252 font_size = int (width * FONT_SIZE_RATIO )
253 label_padding = int (font_size * LABEL_PADDING_RATIO )
254
255 with Image.open(slides[ 0 ][ 0 ]) as img:
256 aspect = img.height / img.width
257 height = int (width * aspect)
258
259 rows = ( len (slides) + cols - 1 ) // cols
260 grid_w = cols * width + (cols + 1 ) * GRID_PADDING
261 grid_h = rows * (height + font_size + label_padding * 2 ) + (rows + 1 ) * GRID_PADDING
262
263 grid = Image.new( "RGB" , (grid_w, grid_h), "white" )
264 draw = ImageDraw.Draw(grid)
265
266 try :
267 font = ImageFont.load_default( size = font_size)
268 except Exception :
269 font = ImageFont.load_default()
270
271 for i, (img_path, slide_name) in enumerate (slides):
272 row, col = i // cols, i % cols
273 x = col * width + (col + 1 ) * GRID_PADDING
274 y_base = (
275 row * (height + font_size + label_padding * 2 ) + (row + 1 ) * GRID_PADDING
276 )
277
278 label = slide_name
279 bbox = draw.textbbox(( 0 , 0 ), label, font = font)
280 text_w = bbox[ 2 ] - bbox[ 0 ]
281 draw.text(
282 (x + (width - text_w) // 2 , y_base + label_padding),
283 label,
284 fill = "black" ,
285 font = font,
286 )
287
288 y_thumbnail = y_base + label_padding + font_size + label_padding
289
290 with Image.open(img_path) as img:
291 img.thumbnail((width, height), Image.Resampling. LANCZOS )
292 w, h = img.size
293 tx = x + (width - w) // 2
294 ty = y_thumbnail + (height - h) // 2
295 grid.paste(img, (tx, ty))
296
297 if BORDER_WIDTH > 0 :
298 draw.rectangle(
299 [
300 (tx - BORDER_WIDTH , ty - BORDER_WIDTH ),
301 (tx + w + BORDER_WIDTH - 1 , ty + h + BORDER_WIDTH - 1 ),
302 ],
303 outline = "gray" ,
304 width = BORDER_WIDTH ,
305 )
306
307 return grid
308
309
310 if __name__ == "__main__" :
311 main()