thumbnail.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """Create thumbnail grids from PowerPoint presentation slides.
  2. Creates a grid layout of slide thumbnails for quick visual analysis.
  3. Labels each thumbnail with its XML filename (e.g., slide1.xml).
  4. Hidden slides are shown with a placeholder pattern.
  5. Usage:
  6. python thumbnail.py input.pptx [output_prefix] [--cols N]
  7. Examples:
  8. python thumbnail.py presentation.pptx
  9. # Creates: thumbnails.jpg
  10. python thumbnail.py template.pptx grid --cols 4
  11. # Creates: grid.jpg (or grid-1.jpg, grid-2.jpg for large decks)
  12. """
  13. import argparse
  14. import subprocess
  15. import sys
  16. import tempfile
  17. import zipfile
  18. from pathlib import Path
  19. import defusedxml.minidom
  20. from office.soffice import get_soffice_env
  21. from PIL import Image, ImageDraw, ImageFont
  22. THUMBNAIL_WIDTH = 300
  23. CONVERSION_DPI = 100
  24. MAX_COLS = 6
  25. DEFAULT_COLS = 3
  26. JPEG_QUALITY = 95
  27. GRID_PADDING = 20
  28. BORDER_WIDTH = 2
  29. FONT_SIZE_RATIO = 0.10
  30. LABEL_PADDING_RATIO = 0.4
  31. def main():
  32. parser = argparse.ArgumentParser(
  33. description="Create thumbnail grids from PowerPoint slides."
  34. )
  35. parser.add_argument("input", help="Input PowerPoint file (.pptx)")
  36. parser.add_argument(
  37. "output_prefix",
  38. nargs="?",
  39. default="thumbnails",
  40. help="Output prefix for image files (default: thumbnails)",
  41. )
  42. parser.add_argument(
  43. "--cols",
  44. type=int,
  45. default=DEFAULT_COLS,
  46. help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})",
  47. )
  48. args = parser.parse_args()
  49. cols = min(args.cols, MAX_COLS)
  50. if args.cols > MAX_COLS:
  51. print(f"Warning: Columns limited to {MAX_COLS}")
  52. input_path = Path(args.input)
  53. if not input_path.exists() or input_path.suffix.lower() != ".pptx":
  54. print(f"Error: Invalid PowerPoint file: {args.input}", file=sys.stderr)
  55. sys.exit(1)
  56. output_path = Path(f"{args.output_prefix}.jpg")
  57. try:
  58. slide_info = get_slide_info(input_path)
  59. with tempfile.TemporaryDirectory() as temp_dir:
  60. temp_path = Path(temp_dir)
  61. visible_images = convert_to_images(input_path, temp_path)
  62. if not visible_images and not any(s["hidden"] for s in slide_info):
  63. print("Error: No slides found", file=sys.stderr)
  64. sys.exit(1)
  65. slides = build_slide_list(slide_info, visible_images, temp_path)
  66. grid_files = create_grids(slides, cols, THUMBNAIL_WIDTH, output_path)
  67. print(f"Created {len(grid_files)} grid(s):")
  68. for grid_file in grid_files:
  69. print(f" {grid_file}")
  70. except Exception as e:
  71. print(f"Error: {e}", file=sys.stderr)
  72. sys.exit(1)
  73. def get_slide_info(pptx_path: Path) -> list[dict]:
  74. with zipfile.ZipFile(pptx_path, "r") as zf:
  75. rels_content = zf.read("ppt/_rels/presentation.xml.rels").decode("utf-8")
  76. rels_dom = defusedxml.minidom.parseString(rels_content)
  77. rid_to_slide = {}
  78. for rel in rels_dom.getElementsByTagName("Relationship"):
  79. rid = rel.getAttribute("Id")
  80. target = rel.getAttribute("Target")
  81. rel_type = rel.getAttribute("Type")
  82. if "slide" in rel_type and target.startswith("slides/"):
  83. rid_to_slide[rid] = target.replace("slides/", "")
  84. pres_content = zf.read("ppt/presentation.xml").decode("utf-8")
  85. pres_dom = defusedxml.minidom.parseString(pres_content)
  86. slides = []
  87. for sld_id in pres_dom.getElementsByTagName("p:sldId"):
  88. rid = sld_id.getAttribute("r:id")
  89. if rid in rid_to_slide:
  90. hidden = sld_id.getAttribute("show") == "0"
  91. slides.append({"name": rid_to_slide[rid], "hidden": hidden})
  92. return slides
  93. def build_slide_list(
  94. slide_info: list[dict],
  95. visible_images: list[Path],
  96. temp_dir: Path,
  97. ) -> list[tuple[Path, str]]:
  98. if visible_images:
  99. with Image.open(visible_images[0]) as img:
  100. placeholder_size = img.size
  101. else:
  102. placeholder_size = (1920, 1080)
  103. slides = []
  104. visible_idx = 0
  105. for info in slide_info:
  106. if info["hidden"]:
  107. placeholder_path = temp_dir / f"hidden-{info['name']}.jpg"
  108. placeholder_img = create_hidden_placeholder(placeholder_size)
  109. placeholder_img.save(placeholder_path, "JPEG")
  110. slides.append((placeholder_path, f"{info['name']} (hidden)"))
  111. else:
  112. if visible_idx < len(visible_images):
  113. slides.append((visible_images[visible_idx], info["name"]))
  114. visible_idx += 1
  115. return slides
  116. def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image:
  117. img = Image.new("RGB", size, color="#F0F0F0")
  118. draw = ImageDraw.Draw(img)
  119. line_width = max(5, min(size) // 100)
  120. draw.line([(0, 0), size], fill="#CCCCCC", width=line_width)
  121. draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width)
  122. return img
  123. def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]:
  124. pdf_path = temp_dir / f"{pptx_path.stem}.pdf"
  125. result = subprocess.run(
  126. [
  127. "soffice",
  128. "--headless",
  129. "--convert-to",
  130. "pdf",
  131. "--outdir",
  132. str(temp_dir),
  133. str(pptx_path),
  134. ],
  135. capture_output=True,
  136. text=True,
  137. env=get_soffice_env(),
  138. )
  139. if result.returncode != 0 or not pdf_path.exists():
  140. raise RuntimeError("PDF conversion failed")
  141. result = subprocess.run(
  142. [
  143. "pdftoppm",
  144. "-jpeg",
  145. "-r",
  146. str(CONVERSION_DPI),
  147. str(pdf_path),
  148. str(temp_dir / "slide"),
  149. ],
  150. capture_output=True,
  151. text=True,
  152. )
  153. if result.returncode != 0:
  154. raise RuntimeError("Image conversion failed")
  155. return sorted(temp_dir.glob("slide-*.jpg"))
  156. def create_grids(
  157. slides: list[tuple[Path, str]],
  158. cols: int,
  159. width: int,
  160. output_path: Path,
  161. ) -> list[str]:
  162. max_per_grid = cols * (cols + 1)
  163. grid_files = []
  164. for chunk_idx, start_idx in enumerate(range(0, len(slides), max_per_grid)):
  165. end_idx = min(start_idx + max_per_grid, len(slides))
  166. chunk_slides = slides[start_idx:end_idx]
  167. grid = create_grid(chunk_slides, cols, width)
  168. if len(slides) <= max_per_grid:
  169. grid_filename = output_path
  170. else:
  171. stem = output_path.stem
  172. suffix = output_path.suffix
  173. grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}"
  174. grid_filename.parent.mkdir(parents=True, exist_ok=True)
  175. grid.save(str(grid_filename), quality=JPEG_QUALITY)
  176. grid_files.append(str(grid_filename))
  177. return grid_files
  178. def create_grid(
  179. slides: list[tuple[Path, str]],
  180. cols: int,
  181. width: int,
  182. ) -> Image.Image:
  183. font_size = int(width * FONT_SIZE_RATIO)
  184. label_padding = int(font_size * LABEL_PADDING_RATIO)
  185. with Image.open(slides[0][0]) as img:
  186. aspect = img.height / img.width
  187. height = int(width * aspect)
  188. rows = (len(slides) + cols - 1) // cols
  189. grid_w = cols * width + (cols + 1) * GRID_PADDING
  190. grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING
  191. grid = Image.new("RGB", (grid_w, grid_h), "white")
  192. draw = ImageDraw.Draw(grid)
  193. try:
  194. font = ImageFont.load_default(size=font_size)
  195. except Exception:
  196. font = ImageFont.load_default()
  197. for i, (img_path, slide_name) in enumerate(slides):
  198. row, col = i // cols, i % cols
  199. x = col * width + (col + 1) * GRID_PADDING
  200. y_base = (
  201. row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING
  202. )
  203. label = slide_name
  204. bbox = draw.textbbox((0, 0), label, font=font)
  205. text_w = bbox[2] - bbox[0]
  206. draw.text(
  207. (x + (width - text_w) // 2, y_base + label_padding),
  208. label,
  209. fill="black",
  210. font=font,
  211. )
  212. y_thumbnail = y_base + label_padding + font_size + label_padding
  213. with Image.open(img_path) as img:
  214. img.thumbnail((width, height), Image.Resampling.LANCZOS)
  215. w, h = img.size
  216. tx = x + (width - w) // 2
  217. ty = y_thumbnail + (height - h) // 2
  218. grid.paste(img, (tx, ty))
  219. if BORDER_WIDTH > 0:
  220. draw.rectangle(
  221. [
  222. (tx - BORDER_WIDTH, ty - BORDER_WIDTH),
  223. (tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1),
  224. ],
  225. outline="gray",
  226. width=BORDER_WIDTH,
  227. )
  228. return grid
  229. if __name__ == "__main__":
  230. main()