返回 ppt-master
rotate_images.py
根目录 / skills / ppt-master / scripts / rotate_images.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Image Orientation Management Tool
4
5 Provides visual image orientation filtering, fix code generation,
6 and batch image rotation functionality.
7
8 Usage:
9 python3 scripts/rotate_images.py sheet <images_directory>
10 python3 scripts/rotate_images.py gen <images_directory>
11 python3 scripts/rotate_images.py fix <fixes.json>
12 python3 scripts/rotate_images.py auto <images_directory>
13 """
14
15
16 import argparse
17 import json
18 import os
19 import re
20 from pathlib import Path
21 from typing import List, Dict, Union, Any, Optional
22
23 from console_encoding import configure_utf8_stdio
24
25 configure_utf8_stdio()
26
27 from PIL import (
28 ExifTags,
29 Image,
30 ImageDraw,
31 ImageFont,
32 ImageOps,
33 )
34
35
36 ORIENTATION_TAG_ID = 274 # 0x0112
37
38 class ImageRotator:
39 """Image orientation manager"""
40
41 def __init__(self):
42 """Initialize the manager"""
43 pass
44
45 @staticmethod
46 def _repo_root() -> Path:
47 # scripts/rotate_images.py -> skills/ppt-master/
48 return Path(__file__).resolve().parent.parent
49
50 @staticmethod
51 def _normalize_task_path(path_str: str) -> str:
52 p = (path_str or "").strip()
53 if not p:
54 return p
55
56 # common copy/paste artifacts
57 p = re.sub(r"^file:(?:///?)+", "", p, flags=re.IGNORECASE)
58 p = p.replace("\\", "/")
59 p = re.sub(r"^\\./", "", p)
60 return p
61
62 @staticmethod
63 def _natural_sort_key(s: Union[str, Path]) -> List[Union[int, str]]:
64 """Natural sort key generator"""
65 return [int(text) if text.isdigit() else text.lower()
66 for text in re.split(r'(\d+)', str(s))]
67
68 def _save_in_place(
69 self,
70 img: Image.Image,
71 file_path: Path,
72 src_format: Optional[str],
73 *,
74 exif_bytes: Optional[bytes] = None,
75 icc_profile: Optional[bytes] = None,
76 ) -> None:
77 fmt = (src_format or "").upper()
78
79 save_kwargs: Dict[str, Any] = {}
80 if icc_profile:
81 save_kwargs["icc_profile"] = icc_profile
82 if exif_bytes:
83 save_kwargs["exif"] = exif_bytes
84
85 # Avoid passing unsupported params to formats (e.g. PNG doesn't take `quality`).
86 if fmt in {"JPEG", "JPG"}:
87 save_kwargs["quality"] = 95
88 # keep it simple; avoid Pillow-version-specific kwargs like optimize/subsampling
89 if img.mode not in {"RGB", "L"}:
90 img = img.convert("RGB")
91 elif fmt == "WEBP":
92 save_kwargs["quality"] = 95
93
94 try:
95 img.save(file_path, **save_kwargs)
96 except TypeError:
97 # Fallback: drop metadata kwargs that some formats/plugins may reject.
98 save_kwargs.pop("exif", None)
99 save_kwargs.pop("icc_profile", None)
100 img.save(file_path, **save_kwargs)
101
102 def auto_fix_exif(self, target_dir: Union[str, Path]) -> int:
103 """Auto-fix EXIF orientation for all images in the directory
104
105 Args:
106 target_dir: Target directory
107
108 Returns:
109 Number of images fixed
110 """
111 target_path = Path(target_dir)
112 if not target_path.exists():
113 return 0
114
115 print(f"[AUTO] Checking EXIF orientation information...")
116 fixed_count = 0
117 valid_exts = {'.jpg', '.jpeg', '.webp'} # PNG typically does not carry rotation EXIF
118
119 # Pre-collect file list to avoid issues caused by modifying during iteration
120 files = [f for f in target_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts]
121
122 for f in files:
123 if self._fix_single_exif(f):
124 fixed_count += 1
125
126 if fixed_count > 0:
127 print(f"[OK] Auto-fixed EXIF orientation for {fixed_count} image(s)")
128 else:
129 print(f"[INFO] No images requiring EXIF correction found")
130
131 return fixed_count
132
133 def generate_contact_sheet(
134 self,
135 target_dir: Union[str, Path],
136 output_path: Optional[Union[str, Path]] = None,
137 ) -> str:
138 """Generate a labeled, read-only contact sheet for visual review."""
139 target_path = Path(target_dir).resolve()
140 if not target_path.is_dir():
141 raise FileNotFoundError(f"Directory not found: {target_path}")
142
143 valid_exts = {
144 '.bmp', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'
145 }
146 files = sorted(
147 (
148 path for path in target_path.iterdir()
149 if path.is_file() and path.suffix.lower() in valid_exts
150 ),
151 key=lambda path: self._natural_sort_key(path.name),
152 )
153 if not files:
154 raise ValueError("No image files found")
155
156 columns = 6
157 thumbnail_width = 180
158 thumbnail_height = 180
159 label_height = 34
160 padding = 12
161 cell_width = thumbnail_width + padding * 2
162 cell_height = thumbnail_height + label_height + padding * 2
163 rows = (len(files) + columns - 1) // columns
164
165 sheet = Image.new(
166 'RGB',
167 (columns * cell_width, rows * cell_height),
168 'white',
169 )
170 draw = ImageDraw.Draw(sheet)
171 try:
172 font = ImageFont.truetype('DejaVuSans.ttf', 13)
173 except OSError:
174 font = ImageFont.load_default()
175 resampling = getattr(Image, 'Resampling', Image)
176
177 for index, file_path in enumerate(files):
178 column = index % columns
179 row = index // columns
180 cell_x = column * cell_width
181 cell_y = row * cell_height
182 draw.rectangle(
183 (
184 cell_x,
185 cell_y,
186 cell_x + cell_width - 1,
187 cell_y + cell_height - 1,
188 ),
189 outline='#D0D7DE',
190 )
191
192 details = 'unreadable'
193 try:
194 with Image.open(file_path) as img:
195 if getattr(img, 'is_animated', False):
196 img.seek(0)
197 prepared = ImageOps.exif_transpose(img)
198 try:
199 source_width, source_height = prepared.size
200 preview = prepared.convert('RGBA')
201 finally:
202 if prepared is not img:
203 prepared.close()
204
205 preview.thumbnail(
206 (thumbnail_width, thumbnail_height),
207 resampling.LANCZOS,
208 )
209 preview_x = cell_x + padding + (thumbnail_width - preview.width) // 2
210 preview_y = cell_y + padding + (thumbnail_height - preview.height) // 2
211 sheet.paste(preview, (preview_x, preview_y), preview)
212 preview.close()
213 details = f'{source_width}x{source_height}'
214 except (
215 EOFError,
216 OSError,
217 SyntaxError,
218 ValueError,
219 Image.DecompressionBombError,
220 ):
221 draw.line(
222 (
223 cell_x + padding,
224 cell_y + padding,
225 cell_x + cell_width - padding,
226 cell_y + thumbnail_height,
227 ),
228 fill='#CF222E',
229 width=2,
230 )
231 draw.line(
232 (
233 cell_x + cell_width - padding,
234 cell_y + padding,
235 cell_x + padding,
236 cell_y + thumbnail_height,
237 ),
238 fill='#CF222E',
239 width=2,
240 )
241
242 label = file_path.name
243 if len(label) > 30:
244 label = f'{label[:13]}...{label[-14:]}'
245 label_y = cell_y + padding + thumbnail_height + 2
246 for line_index, text in enumerate((label, details)):
247 text_bbox = draw.textbbox((0, 0), text, font=font)
248 text_width = text_bbox[2] - text_bbox[0]
249 draw.text(
250 (
251 cell_x + (cell_width - text_width) / 2,
252 label_y + line_index * 15,
253 ),
254 text,
255 fill='#24292F',
256 font=font,
257 )
258
259 if output_path is None:
260 resolved_output = (
261 target_path.parent
262 / 'analysis'
263 / f'{target_path.name}_orientation_contact_sheet.jpg'
264 )
265 else:
266 resolved_output = Path(output_path).expanduser().resolve()
267
268 suffix = resolved_output.suffix.lower()
269 if suffix not in {'.jpg', '.jpeg', '.png'}:
270 sheet.close()
271 raise ValueError("Contact sheet output must use .jpg, .jpeg, or .png")
272 resolved_output.parent.mkdir(parents=True, exist_ok=True)
273 if suffix == '.png':
274 sheet.save(resolved_output, format='PNG', optimize=True)
275 else:
276 sheet.save(resolved_output, format='JPEG', quality=90, optimize=True)
277 sheet.close()
278 return str(resolved_output)
279
280 def generate_html_tool(self, target_dir: str, output_filename: str = "image_orientation_tool.html") -> str:
281 """Generate the image filtering HTML tool
282
283 Automatically performs EXIF correction before generating.
284 """
285 target_path = Path(target_dir).resolve()
286 repo_root = self._repo_root()
287
288 if not target_path.exists():
289 raise FileNotFoundError(f"Directory not found: {target_path}")
290
291 # 1. Perform automatic EXIF correction first
292 self.auto_fix_exif(target_path)
293
294 # 2. Generate HTML
295 # Tool is generated in the parent directory (projects/)
296 project_root = target_path.parent
297 html_output_path = project_root / output_filename
298
299 # Collect images
300 images = []
301 valid_exts = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
302
303 print(f"[SCAN] Scanning directory to generate webpage: {target_path}")
304
305 files = sorted(target_path.iterdir(), key=lambda p: self._natural_sort_key(p.name))
306
307 for f in files:
308 if f.is_file() and f.suffix.lower() in valid_exts:
309 try:
310 # src is used for HTML display, keep path relative to the HTML file (e.g. "images/1.jpg")
311 src_rel_path = f.relative_to(project_root).as_posix()
312
313 # path is used for JSON data, using path relative to the working directory (usually repo root)
314 # e.g. "projects/Name/images/1.jpg"
315 # We assume the script is run from the repo root, or target_path is already absolute
316 # The safest approach is to compute a path relative to the repo root (avoids CWD changes making fixes.json unusable)
317 try:
318 repo_rel_path = f.relative_to(repo_root).as_posix()
319 except ValueError:
320 # If the file is not under CWD, fall back to absolute path
321 repo_rel_path = str(f.resolve())
322
323 images.append({'src': src_rel_path, 'path': repo_rel_path})
324 except ValueError:
325 print(f"[WARN] Warning: {f.name} cannot compute relative path, skipped")
326 continue
327
328 if not images:
329 raise ValueError("No image files found")
330
331 json_data = json.dumps(images)
332
333 # Embed HTML template
334 html_content = self._get_html_template().replace('__IMAGES__', json_data)
335
336 with open(html_output_path, 'w', encoding='utf-8') as f:
337 f.write(html_content)
338
339 return str(html_output_path)
340
341 def apply_fixes(self, json_source: Union[str, List[Dict]]) -> Dict[str, int]:
342 """Apply image rotation fixes"""
343 tasks = []
344 json_file_dir: Optional[Path] = None
345
346 # Parse input
347 if isinstance(json_source, str):
348 if json_source.endswith('.json') or os.path.exists(json_source):
349 json_file_dir = Path(json_source).resolve().parent
350 with open(json_source, 'r', encoding='utf-8') as f:
351 tasks = json.load(f)
352 else:
353 try:
354 tasks = json.loads(json_source)
355 except json.JSONDecodeError:
356 raise ValueError("Invalid input: not a file path nor a valid JSON string")
357 elif isinstance(json_source, list):
358 tasks = json_source
359
360 gif_paths = []
361 for task in tasks:
362 if not isinstance(task, dict):
363 continue
364 task_path = self._normalize_task_path(task.get('path', ''))
365 if Path(task_path).suffix.lower() == '.gif':
366 gif_paths.append(task_path)
367 if gif_paths:
368 raise ValueError(
369 "GIF rotation is unsupported; preserve GIF files unchanged: "
370 + ", ".join(gif_paths)
371 )
372
373 print(f"[WORK] Starting {len(tasks)} manual rotation task(s)...")
374 print("=" * 60)
375
376 cwd = Path(os.getcwd())
377 repo_root = self._repo_root()
378 stats = {'total': len(tasks), 'success': 0}
379
380 for task in tasks:
381 rel_path = self._normalize_task_path(task.get('path', ''))
382 rotation = task.get('rotation')
383
384 if not rel_path or rotation is None:
385 continue
386
387 # Absolute paths should stay absolute; repo-relative paths should resolve from repo root.
388 target_file = Path(rel_path)
389 if not target_file.is_absolute():
390 # Prefer repo root (stable); also allow CWD and fixes.json location as fallbacks.
391 candidates = [
392 repo_root / rel_path,
393 cwd / rel_path,
394 ]
395 if json_file_dir:
396 candidates.append(json_file_dir / rel_path)
397
398 # Compatibility with legacy logic / bare filenames (try finding under the projects directory)
399 candidates.append(repo_root / 'projects' / rel_path)
400 candidates.append(cwd / 'projects' / rel_path)
401 if json_file_dir:
402 candidates.append(json_file_dir / 'projects' / rel_path)
403
404 target_file = next((c for c in candidates if c.exists()), candidates[0])
405
406 if not target_file.exists():
407 print(f"[SKIP] File not found: {rel_path}")
408 continue
409
410 try:
411 self._rotate_single_image(target_file, rotation)
412 print(f"[OK] {target_file.name} rotated {rotation} degrees")
413 stats['success'] += 1
414 except Exception as e:
415 print(f"[ERROR] {target_file.name}: {e}")
416
417 return stats
418
419 def _fix_single_exif(self, file_path: Path) -> bool:
420 """Check and fix EXIF orientation for a single image"""
421 try:
422 fixed_img: Optional[Image.Image] = None
423 exif_bytes: Optional[bytes] = None
424 icc_profile: Optional[bytes] = None
425 src_format: Optional[str] = None
426
427 with Image.open(file_path) as img:
428 exif = img.getexif()
429 orientation = exif.get(ORIENTATION_TAG_ID, 1) if exif else None
430
431 if not orientation or orientation == 1:
432 return False
433
434 print(f" [EXIF] Fixing: {file_path.name} (Orientation={orientation})")
435
436 # Apply rotation
437 fixed_img = self._apply_exif_orientation(img, orientation)
438 fixed_img.load()
439
440 # Remove the specific Orientation tag, keep other EXIF data
441 if exif:
442 exif[ORIENTATION_TAG_ID] = 1
443 exif_bytes = exif.tobytes()
444
445 icc_profile = img.info.get('icc_profile')
446 src_format = img.format
447
448 # Must save after the original file is closed (Windows requirement)
449 if fixed_img is None:
450 return False
451
452 self._save_in_place(
453 fixed_img,
454 file_path,
455 src_format,
456 exif_bytes=exif_bytes,
457 icc_profile=icc_profile,
458 )
459 return True
460 except Exception as e:
461 print(f" [WARN] Failed to read EXIF for {file_path.name}: {e}")
462 return False
463
464 def _get_exif_orientation(self, img: Image.Image) -> Optional[int]:
465 """Get the Orientation value"""
466 try:
467 exif = img._getexif()
468 if exif:
469 for tag, value in exif.items():
470 if ExifTags.TAGS.get(tag) == 'Orientation':
471 return value
472 except Exception:
473 pass
474 return None
475
476 def _apply_exif_orientation(self, img: Image.Image, orientation: int) -> Image.Image:
477 """Rotate image according to the Orientation value"""
478 T = getattr(Image, "Transpose", Image)
479 if orientation == 2:
480 return img.transpose(T.FLIP_LEFT_RIGHT)
481 if orientation == 3:
482 return img.transpose(T.ROTATE_180)
483 if orientation == 4:
484 return img.transpose(T.FLIP_TOP_BOTTOM)
485 if orientation == 5:
486 return img.transpose(T.TRANSPOSE)
487 if orientation == 6:
488 return img.transpose(T.ROTATE_270)
489 if orientation == 7:
490 return img.transpose(T.TRANSVERSE)
491 if orientation == 8:
492 return img.transpose(T.ROTATE_90)
493 return img
494
495 def _rotate_single_image(self, file_path: Path, rotation_deg: int):
496 """Manually rotate a single image"""
497 T = getattr(Image, "Transpose", Image)
498 with Image.open(file_path) as img:
499 ccw_angle = (360 - int(rotation_deg)) % 360
500 if ccw_angle == 0:
501 return
502
503 if ccw_angle == 90:
504 rotated = img.transpose(T.ROTATE_90)
505 elif ccw_angle == 180:
506 rotated = img.transpose(T.ROTATE_180)
507 elif ccw_angle == 270:
508 rotated = img.transpose(T.ROTATE_270)
509 else:
510 rotated = img.rotate(ccw_angle, expand=True)
511
512 rotated.load()
513
514 exif = img.getexif()
515 exif_bytes: Optional[bytes] = None
516 if exif:
517 exif[ORIENTATION_TAG_ID] = 1
518 exif_bytes = exif.tobytes()
519
520 icc_profile = img.info.get('icc_profile')
521 src_format = img.format
522
523 self._save_in_place(
524 rotated,
525 file_path,
526 src_format,
527 exif_bytes=exif_bytes,
528 icc_profile=icc_profile,
529 )
530
531 def _get_html_template(self) -> str:
532 """Get HTML template content"""
533 return """
534 <!DOCTYPE html>
535 <html lang="en">
536 <head>
537 <meta charset="UTF-8">
538 <title>Image Orientation Tool</title>
539 <style>
540 body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
541 .header {
542 position: sticky; top: 0; background: rgba(255,255,255,0.95); padding: 20px;
543 box-shadow: 0 4px 20px rgba(0,0,0,0.08); z-index: 100;
544 border-radius: 12px; margin-bottom: 20px;
545 backdrop-filter: blur(10px);
546 display: flex; justify-content: space-between; align-items: center;
547 }
548 h2 { margin: 0; font-size: 1.5rem; color: #1a1a1a; }
549 .instructions { color: #666; margin-top: 5px; font-size: 0.9rem; }
550
551 .grid {
552 display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
553 gap: 15px;
554 }
555 .card {
556 background: white; border-radius: 12px; overflow: hidden;
557 box-shadow: 0 2px 8px rgba(0,0,0,0.05); text-align: center;
558 cursor: pointer; transition: all 0.2s ease;
559 position: relative; border: 2px solid transparent;
560 }
561 .card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(0,0,0,0.1); }
562 .card.modified { border-color: #007bff; background: #f8fbff; }
563
564 .img-wrapper {
565 height: 180px; width: 100%; display: flex; align-items: center; justify-content: center;
566 background: #e9ecef; overflow: hidden; position: relative;
567 }
568 img {
569 max-width: 100%; max-height: 100%; object-fit: contain;
570 transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
571 }
572
573 .info { padding: 10px; font-size: 11px; color: #555; word-break: break-all; border-top: 1px solid #eee; }
574
575 .badge {
576 position: absolute; top: 10px; right: 10px;
577 background: #007bff; color: white; padding: 4px 8px;
578 border-radius: 20px; font-size: 11px; font-weight: bold;
579 opacity: 0; transform: scale(0.8); transition: all 0.2s;
580 box-shadow: 0 2px 4px rgba(0,0,0,0.2);
581 }
582 .card.modified .badge { opacity: 1; transform: scale(1); }
583
584 .btn {
585 background: #007bff; color: white; border: none; padding: 10px 24px;
586 border-radius: 8px; font-weight: 600; cursor: pointer; transition: background 0.2s;
587 }
588 .btn:hover { background: #0056b3; }
589
590 #output-modal {
591 display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
592 background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;
593 }
594 .modal-content {
595 background: white; padding: 30px; border-radius: 16px; width: 80%; max-width: 600px;
596 box-shadow: 0 10px 40px rgba(0,0,0,0.2);
597 }
598 textarea {
599 width: 100%; height: 200px; padding: 10px; border: 1px solid #ddd; border-radius: 8px;
600 font-family: inherit; resize: vertical; margin: 15px 0;
601 background: #f8f9fa;
602 }
603 </style>
604 </head>
605 <body>
606
607 <div class="header">
608 <div>
609 <h2>Image Orientation Fix</h2>
610 <div class="instructions">Click an image to rotate (90 -> 180 -> 270 -> 0). Natural order sorting.</div>
611 </div>
612 <button class="btn" onclick="showCode()">Generate Fix Code</button>
613 </div>
614
615 <div class="grid" id="grid"></div>
616
617 <div id="output-modal" onclick="if(event.target===this)this.style.display='none'">
618 <div class="modal-content">
619 <h3>Copy the code below</h3>
620 <p style="color:#666; font-size: 0.9em;">Copy this JSON content and send it to the AI assistant, or save it as 'fixes.json'.</p>
621 <textarea id="output-area" readonly></textarea>
622 <div style="text-align: right;">
623 <button class="btn" onclick="document.getElementById('output-modal').style.display='none'">Close</button>
624 </div>
625 </div>
626 </div>
627
628 <script>
629 const images = __IMAGES__;
630 const grid = document.getElementById('grid');
631
632 images.forEach(item => {
633 const card = document.createElement('div');
634 card.className = 'card';
635 card.setAttribute('data-rotation', 0);
636 // use stable path for the data attribute
637 card.setAttribute('data-path', item.path);
638
639 const filename = item.src.split('/').pop();
640
641 card.innerHTML = `
642 <div class="img-wrapper">
643 <img src="${item.src}" alt="${filename}" loading="lazy">
644 <div class="badge">0°</div>
645 </div>
646 <div class="info">${filename}</div>
647 `;
648
649 card.onclick = function() {
650 let rot = parseInt(this.getAttribute('data-rotation'));
651 rot = (rot + 90) % 360;
652 this.setAttribute('data-rotation', rot);
653
654 const img = this.querySelector('img');
655 img.style.transform = `rotate(${rot}deg)`;
656
657 const badge = this.querySelector('.badge');
658 badge.innerText = rot + '°';
659
660 if (rot > 0) {
661 this.classList.add('modified');
662 } else {
663 this.classList.remove('modified');
664 }
665 };
666
667 grid.appendChild(card);
668 });
669
670 function showCode() {
671 const tasks = [];
672 document.querySelectorAll('.card').forEach(card => {
673 const rot = parseInt(card.getAttribute('data-rotation'));
674 if (rot > 0) {
675 tasks.push({
676 path: card.getAttribute('data-path'),
677 rotation: rot
678 });
679 }
680 });
681
682 const jsonStr = JSON.stringify(tasks, null, 2);
683 const modal = document.getElementById('output-modal');
684 const area = document.getElementById('output-area');
685
686 modal.style.display = 'flex';
687 area.value = jsonStr;
688 area.select();
689 try { document.execCommand('copy'); } catch(e){}
690 }
691 </script>
692 </body>
693 </html>
694 """
695
696 def build_parser() -> argparse.ArgumentParser:
697 """Build the command-line parser."""
698 parser = argparse.ArgumentParser(
699 description="Manage image orientation and manual rotation fixes.",
700 formatter_class=argparse.RawDescriptionHelpFormatter,
701 epilog="""Examples:
702 python3 scripts/rotate_images.py sheet projects/demo/images
703 python3 scripts/rotate_images.py gen projects/demo/images
704 python3 scripts/rotate_images.py fix fixes.json
705 python3 scripts/rotate_images.py auto projects/demo/images
706 """,
707 )
708 subparsers = parser.add_subparsers(dest="command", required=True)
709
710 sheet = subparsers.add_parser(
711 "sheet",
712 help="Generate a static contact sheet without modifying images",
713 )
714 sheet.add_argument("images_directory", help="Images directory")
715 sheet.add_argument(
716 "-o",
717 "--output",
718 help="Output .jpg or .png path (default: sibling analysis directory)",
719 )
720
721 gen = subparsers.add_parser("gen", help="Generate the visual rotation HTML tool")
722 gen.add_argument("images_directory", help="Images directory")
723
724 fix = subparsers.add_parser("fix", help="Apply rotations from a fixes JSON file")
725 fix.add_argument("fixes_json", help="Path to fixes.json")
726
727 auto = subparsers.add_parser("auto", help="Automatically fix EXIF orientation")
728 auto.add_argument("images_directory", help="Images directory")
729 return parser
730
731
732 def main(argv: list[str] | None = None) -> int:
733 """Run the CLI entry point."""
734 parser = build_parser()
735 args = parser.parse_args(argv)
736 rotator = ImageRotator()
737
738 if args.command == 'sheet':
739 try:
740 output_path = rotator.generate_contact_sheet(
741 args.images_directory,
742 args.output,
743 )
744 print(f"[REPORT] Image orientation contact sheet: {output_path}")
745 except (OSError, ValueError) as e:
746 print(f"[ERROR] Contact sheet generation failed: {e}")
747 return 1
748 return 0
749
750 if args.command == 'gen':
751 target_dir = args.images_directory
752 try:
753 output_path = rotator.generate_html_tool(target_dir)
754 print(f"[OK] HTML tool created: {output_path}")
755 print(f"[LINK] Open in browser: file:///{Path(output_path).as_posix()}")
756 except Exception as e:
757 print(f"[ERROR] Generation failed: {e}")
758 return 1
759 return 0
760
761 if args.command == 'fix':
762 json_file = args.fixes_json
763 try:
764 stats = rotator.apply_fixes(json_file)
765 print(f"\n[DONE] Processing complete: {stats['success']} succeeded / {stats['total']} total")
766 except Exception as e:
767 print(f"[ERROR] Execution failed: {e}")
768 return 1
769 return 0
770
771 if args.command == 'auto':
772 target_dir = args.images_directory
773 try:
774 # Only perform automatic EXIF fix
775 count = rotator.auto_fix_exif(Path(target_dir))
776 if count == 0:
777 print("[INFO] No images requiring automatic fix found")
778 except Exception as e:
779 print(f"[ERROR] Automatic fix failed: {e}")
780 return 1
781 return 0
782
783 parser.error(f"Unknown command: {args.command}")
784
785 if __name__ == '__main__':
786 raise SystemExit(main())
787
787 lines PYTHON