返回 ppt-master
embed_images.py
根目录 / skills / ppt-master / scripts / svg_finalize / embed_images.py
1 #!/usr/bin/env python3
2 """
3 SVG Image Embedding Tool
4 Converts externally referenced images in SVG files to Base64 inline format.
5
6 Usage:
7 python3 scripts/svg_finalize/embed_images.py <svg_file> [svg_file2] ...
8 python3 scripts/svg_finalize/embed_images.py *.svg
9
10 Examples:
11 python3 scripts/svg_finalize/embed_images.py examples/ppt169_demo/svg_output/01_cover.svg
12 python3 scripts/svg_finalize/embed_images.py examples/ppt169_demo/svg_output/*.svg
13 """
14
15 import os
16 import base64
17 import re
18 import sys
19 import argparse
20 from pathlib import Path
21
22 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
23 if str(_SCRIPTS_DIR) not in sys.path:
24 sys.path.insert(0, str(_SCRIPTS_DIR))
25
26 from console_encoding import configure_utf8_stdio # noqa: E402
27
28 configure_utf8_stdio()
29
30
31 def get_mime_type(filename: str, file_bytes: bytes | None = None) -> str:
32 """Return the MIME type based on file bytes first, then extension."""
33 if file_bytes:
34 if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
35 return 'image/png'
36 if file_bytes.startswith(b"\xff\xd8\xff"):
37 return 'image/jpeg'
38 if file_bytes.startswith((b"GIF87a", b"GIF89a")):
39 return 'image/gif'
40 if file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP":
41 return 'image/webp'
42 if file_bytes.lstrip().startswith(b"<svg"):
43 return 'image/svg+xml'
44
45 ext = filename.lower().split('.')[-1]
46 mime_map = {
47 'png': 'image/png',
48 'jpg': 'image/jpeg',
49 'jpeg': 'image/jpeg',
50 'gif': 'image/gif',
51 'webp': 'image/webp',
52 'svg': 'image/svg+xml',
53 }
54 return mime_map.get(ext, 'application/octet-stream')
55
56 def get_file_size_str(size_bytes: int) -> str:
57 """Convert byte count to a human-readable file size string."""
58 if size_bytes < 1024:
59 return f"{size_bytes} B"
60 elif size_bytes < 1024 * 1024:
61 return f"{size_bytes / 1024:.1f} KB"
62 else:
63 return f"{size_bytes / (1024 * 1024):.1f} MB"
64
65 def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
66 compress: bool = False,
67 max_dimension: int | None = None) -> bytes:
68 """Optionally compress and/or downscale image bytes.
69
70 Returns the (possibly optimized) image bytes. Falls back to the
71 original bytes if PIL is not available or optimization fails.
72 """
73 if not compress and not max_dimension:
74 return img_bytes
75
76 try:
77 from PIL import Image as PILImage
78 import io
79 except ImportError:
80 return img_bytes
81
82 try:
83 img = PILImage.open(io.BytesIO(img_bytes))
84 except Exception:
85 return img_bytes
86
87 # Multi-frame images (animated GIF / WebP / APNG): resize/re-save below
88 # keeps frame 0 only, silently flattening the animation. Pass the
89 # original bytes through — animations are exempt from compression and
90 # the size cap.
91 if getattr(img, 'is_animated', False):
92 if max_dimension:
93 w, h = img.size
94 if w > max_dimension or h > max_dimension:
95 print(f" [WARN] Animated image kept as-is ({w}x{h} exceeds "
96 f"max dimension {max_dimension}px); animations are "
97 f"exempt from size limits")
98 return img_bytes
99
100 changed = False
101
102 # Downscale if exceeding max_dimension
103 if max_dimension:
104 w, h = img.size
105 if w > max_dimension or h > max_dimension:
106 ratio = min(max_dimension / w, max_dimension / h)
107 new_w, new_h = int(w * ratio), int(h * ratio)
108 img = img.resize((new_w, new_h), PILImage.LANCZOS)
109 changed = True
110
111 # Compress
112 if compress or changed:
113 buf = io.BytesIO()
114 if mime_type == 'image/jpeg':
115 if img.mode in ('RGBA', 'P'):
116 img = img.convert('RGB')
117 img.save(buf, format='JPEG', quality=85, optimize=True)
118 elif mime_type == 'image/png':
119 img.save(buf, format='PNG', optimize=True)
120 else:
121 # For other formats, just re-save
122 fmt = img.format or 'PNG'
123 img.save(buf, format=fmt)
124
125 optimized = buf.getvalue()
126 # Only use optimized version if it's actually smaller
127 if len(optimized) < len(img_bytes):
128 return optimized
129
130 return img_bytes
131
132
133 def embed_images_in_svg(svg_path: str, dry_run: bool = False,
134 compress: bool = False,
135 max_dimension: int | None = None) -> tuple[int, int]:
136 """
137 Convert externally referenced images in an SVG file to Base64 inline format.
138
139 Args:
140 svg_path: SVG file path
141 dry_run: If True, only show which images would be processed without modifying the file
142 compress: If True, compress images before embedding (JPEG quality=85, PNG optimize)
143 max_dimension: If set, downscale images exceeding this dimension on either axis
144
145 Returns:
146 tuple: (number of images processed, file size after embedding)
147 """
148 svg_dir = os.path.dirname(os.path.abspath(svg_path))
149
150 with open(svg_path, 'r', encoding='utf-8') as f:
151 content = f.read()
152
153 original_size = len(content.encode('utf-8'))
154
155 # Match href="xxx.png" or href="xxx.jpg" etc. (exclude those already using data:)
156 pattern = r'href="(?!data:)([^"]+\.(png|jpg|jpeg|gif|webp))"'
157
158 images_found = []
159 images_embedded = 0
160
161 def replace_with_base64(match):
162 nonlocal images_embedded
163 img_path = match.group(1)
164
165 # Decode XML/HTML entities (e.g., &amp; -> &)
166 import html
167 img_path_decoded = html.unescape(img_path)
168
169 # Handle relative paths
170 if not os.path.isabs(img_path_decoded):
171 full_path = os.path.join(svg_dir, img_path_decoded)
172 else:
173 full_path = img_path_decoded
174
175 if not os.path.exists(full_path):
176 print(f" [WARN] Image not found: {img_path}")
177 images_found.append((img_path, "NOT FOUND", 0, None))
178 return match.group(0)
179
180 img_size = os.path.getsize(full_path)
181
182 if dry_run:
183 images_found.append((img_path, "WILL EMBED", img_size, None))
184 return match.group(0)
185
186 with open(full_path, 'rb') as img_file:
187 img_bytes = img_file.read()
188
189 mime_type = get_mime_type(img_path, img_bytes)
190 optimized_bytes = _optimize_image_bytes(
191 img_bytes, mime_type, compress=compress, max_dimension=max_dimension)
192 b64_data = base64.b64encode(optimized_bytes).decode('utf-8')
193
194 images_embedded += 1
195 saved = len(img_bytes) - len(optimized_bytes)
196 if saved > 0 and (compress or max_dimension):
197 pct = saved / len(img_bytes) * 100
198 images_found.append((img_path, "EMBEDDED", img_size,
199 f"{get_file_size_str(len(img_bytes))} → {get_file_size_str(len(optimized_bytes))}, saved {pct:.0f}%"))
200 else:
201 images_found.append((img_path, "EMBEDDED", img_size, None))
202
203 return f'href="data:{mime_type};base64,{b64_data}"'
204
205 new_content = re.sub(pattern, replace_with_base64, content)
206
207 new_size = len(new_content.encode('utf-8'))
208
209 # Print processed images
210 if images_found:
211 print(f"\n[FILE] {os.path.basename(svg_path)}")
212 for img_path, status, size, opt_info in images_found:
213 size_str = get_file_size_str(size) if size > 0 else ""
214 if status == "EMBEDDED":
215 if opt_info:
216 print(f" [OK] {img_path} ({opt_info})")
217 else:
218 print(f" [OK] {img_path} ({size_str})")
219 elif status == "WILL EMBED":
220 print(f" [PREVIEW] {img_path} ({size_str}) [dry-run]")
221 else:
222 print(f" [FAIL] {img_path} ({status})")
223
224 print(f" [SIZE] {get_file_size_str(original_size)} -> {get_file_size_str(new_size)}")
225
226 if not dry_run and images_embedded > 0:
227 with open(svg_path, 'w', encoding='utf-8') as f:
228 f.write(new_content)
229
230 processed_count = len(images_found) if dry_run else images_embedded
231 return (processed_count, new_size)
232
233 def main() -> None:
234 """Run the CLI entry point."""
235 parser = argparse.ArgumentParser(
236 description='Convert externally referenced images in SVG files to Base64 inline format',
237 formatter_class=argparse.RawDescriptionHelpFormatter,
238 epilog='''
239 Examples:
240 %(prog)s 01_cover.svg # Process a single file
241 %(prog)s *.svg # Process all SVGs in current directory
242 %(prog)s --dry-run *.svg # Preview files to be processed
243 '''
244 )
245 parser.add_argument('files', nargs='+', help='SVG files to process')
246 parser.add_argument('--dry-run', '-n', action='store_true',
247 help='Only show which images would be processed, without modifying files')
248 parser.add_argument('--compress', action='store_true',
249 help='Compress images before embedding (JPEG quality=85, PNG optimize)')
250 parser.add_argument('--max-dimension', type=int, default=None,
251 help='Downscale images exceeding this dimension on either axis (e.g., 2560)')
252
253 args = parser.parse_args()
254
255 if args.dry_run:
256 print("[INFO] Dry-run mode: only preview, no modification\n")
257 if args.compress:
258 print("[INFO] Compression enabled: JPEG quality=85, PNG optimize")
259 if args.max_dimension:
260 print(f"[INFO] Max dimension: {args.max_dimension}px")
261
262 total_images = 0
263 total_files = 0
264
265 for svg_file in args.files:
266 if not os.path.exists(svg_file):
267 print(f"[ERROR] File not found: {svg_file}")
268 continue
269
270 if not svg_file.endswith('.svg'):
271 print(f"[SKIP] Skipping non-SVG file: {svg_file}")
272 continue
273
274 images, _ = embed_images_in_svg(svg_file, dry_run=args.dry_run,
275 compress=args.compress,
276 max_dimension=args.max_dimension)
277 if images > 0:
278 total_images += images
279 total_files += 1
280
281 print(f"\n{'=' * 50}")
282 if args.dry_run:
283 print(f"[PREVIEW] Will process {total_images} images in {total_files} files")
284 else:
285 print(f"[DONE] Embedded {total_images} images in {total_files} files")
286
287 if __name__ == '__main__':
288 main()
289
289 lines PYTHON