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