返回 ppt-master
fix_image_aspect.py
根目录 / skills / ppt-master / scripts / svg_finalize / fix_image_aspect.py
1 #!/usr/bin/env python3
2 """
3 SVG Image Aspect Ratio Fix Tool
4
5 Fixes the dimensions of <image> elements in SVG to match the original image aspect ratio.
6 This prevents images from being stretched by PowerPoint SVG rendering paths that do not
7 honor preserveAspectRatio consistently.
8
9 Principle:
10 Some PowerPoint SVG rendering paths ignore the preserveAspectRatio attribute and directly
11 stretch the image to fill the area specified by width/height.
12
13 This tool reads the actual image aspect ratio and recalculates the x, y, width, height of
14 <image> elements so that images are centered and maintain their original aspect ratio.
15
16 Usage:
17 python3 scripts/svg_finalize/fix_image_aspect.py <svg_file> [svg_file2] ...
18 python3 scripts/svg_finalize/fix_image_aspect.py projects/xxx/svg_output/*.svg
19
20 # Preview mode
21 python3 scripts/svg_finalize/fix_image_aspect.py --dry-run projects/xxx/svg_output/*.svg
22
23 Examples:
24 python3 scripts/svg_finalize/fix_image_aspect.py projects/demo/svg_output/slide_06_current_overview.svg
25 """
26
27 import os
28 import re
29 import sys
30 import base64
31 import argparse
32 from pathlib import Path
33 from xml.etree import ElementTree as ET
34
35 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
36 if str(_SCRIPTS_DIR) not in sys.path:
37 sys.path.insert(0, str(_SCRIPTS_DIR))
38
39 from console_encoding import configure_utf8_stdio # noqa: E402
40
41 configure_utf8_stdio()
42
43 # Try to import PIL for getting image dimensions
44 try:
45 from PIL import Image
46 HAS_PIL = True
47 except ImportError:
48 HAS_PIL = False
49 print("[WARN] PIL not installed. Install with: pip install Pillow")
50 print(" Will try to use basic method for JPEG/PNG files.")
51
52
53 def get_image_dimensions_pil(image_path: str) -> tuple[int | None, int | None]:
54 """Get image dimensions using PIL."""
55 try:
56 with Image.open(image_path) as img:
57 return img.width, img.height
58 except Exception as e:
59 print(f" [WARN] Cannot read image with PIL: {e}")
60 return None, None
61
62
63 def get_image_dimensions_basic(image_path: str) -> tuple[int | None, int | None]:
64 """Get image dimensions using basic parsing without PIL."""
65 try:
66 with open(image_path, 'rb') as f:
67 data = f.read(64) # Read header information
68
69 # PNG
70 if data[:8] == b'\x89PNG\r\n\x1a\n':
71 w = int.from_bytes(data[16:20], 'big')
72 h = int.from_bytes(data[20:24], 'big')
73 return w, h
74
75 # JPEG
76 if data[:2] == b'\xff\xd8':
77 # Need to read full file to parse JPEG
78 with open(image_path, 'rb') as f:
79 f.seek(2)
80 while True:
81 marker = f.read(2)
82 if not marker or len(marker) < 2:
83 break
84 if marker[0] != 0xff:
85 break
86 m = marker[1]
87 # SOF0, SOF2 markers contain dimensions
88 if m in (0xC0, 0xC2):
89 f.read(3) # Skip length and precision
90 h = int.from_bytes(f.read(2), 'big')
91 w = int.from_bytes(f.read(2), 'big')
92 return w, h
93 elif m == 0xD9: # EOI
94 break
95 elif m == 0xD8: # SOI
96 continue
97 elif 0xD0 <= m <= 0xD7: # RST
98 continue
99 else:
100 length = int.from_bytes(f.read(2), 'big')
101 f.seek(length - 2, 1)
102
103 return None, None
104 except Exception as e:
105 print(f" [WARN] Cannot read image dimensions: {e}")
106 return None, None
107
108
109 def get_image_dimensions_from_base64(data_uri: str) -> tuple[int | None, int | None]:
110 """Get image dimensions from a Base64 data URI."""
111 import io
112 try:
113 # Parse data URI
114 match = re.match(r'data:image/(\w+);base64,(.+)', data_uri)
115 if not match:
116 return None, None
117
118 img_format = match.group(1)
119 b64_data = match.group(2)
120 img_bytes = base64.b64decode(b64_data)
121
122 if HAS_PIL:
123 with Image.open(io.BytesIO(img_bytes)) as img:
124 return img.width, img.height
125 else:
126 # Use basic method
127 if img_bytes[:8] == b'\x89PNG\r\n\x1a\n':
128 w = int.from_bytes(img_bytes[16:20], 'big')
129 h = int.from_bytes(img_bytes[20:24], 'big')
130 return w, h
131
132 return None, None
133 except Exception as e:
134 print(f" [WARN] Cannot parse base64 image: {e}")
135 return None, None
136
137
138 def get_image_dimensions(href: str, svg_dir: str) -> tuple[int | None, int | None]:
139 """Get image dimensions for either inline or external images."""
140 # Handle data URI
141 if href.startswith('data:'):
142 return get_image_dimensions_from_base64(href)
143
144 # Handle external files
145 if not os.path.isabs(href):
146 full_path = os.path.join(svg_dir, href)
147 else:
148 full_path = href
149
150 if not os.path.exists(full_path):
151 print(f" [WARN] Image not found: {href}")
152 return None, None
153
154 if HAS_PIL:
155 return get_image_dimensions_pil(full_path)
156 else:
157 return get_image_dimensions_basic(full_path)
158
159
160 def calculate_fitted_dimensions(
161 img_width: int,
162 img_height: int,
163 box_width: float,
164 box_height: float,
165 mode: str = 'meet',
166 ) -> tuple[float, float, float, float]:
167 """
168 Calculate the fitted dimensions for an image within a bounding box.
169
170 Args:
171 img_width, img_height: Original image dimensions
172 box_width, box_height: Container box dimensions
173 mode: 'meet' preserves aspect ratio and fully displays image (may have whitespace)
174 'slice' preserves aspect ratio and fully fills container (may crop)
175
176 Returns:
177 (new_width, new_height, offset_x, offset_y)
178 """
179 img_ratio = img_width / img_height
180 box_ratio = box_width / box_height
181
182 if mode == 'meet':
183 # Fully display image, may have whitespace
184 if img_ratio > box_ratio:
185 # Image is wider, fit by width
186 new_width = box_width
187 new_height = box_width / img_ratio
188 else:
189 # Image is taller, fit by height
190 new_height = box_height
191 new_width = box_height * img_ratio
192 else: # slice
193 # Fully fill container, may crop
194 if img_ratio > box_ratio:
195 # Image is wider, fit by height
196 new_height = box_height
197 new_width = box_height * img_ratio
198 else:
199 # Image is taller, fit by width
200 new_width = box_width
201 new_height = box_width / img_ratio
202
203 # Center offset
204 offset_x = (box_width - new_width) / 2
205 offset_y = (box_height - new_height) / 2
206
207 return new_width, new_height, offset_x, offset_y
208
209
210 def fix_image_aspect_in_svg(svg_path: str, dry_run: bool = False, verbose: bool = True) -> int:
211 """
212 Fix image aspect ratios in an SVG file.
213
214 Args:
215 svg_path: SVG file path
216 dry_run: Whether to only preview without modifying
217 verbose: Whether to output detailed information
218
219 Returns:
220 Number of images fixed
221 """
222 svg_dir = os.path.dirname(os.path.abspath(svg_path))
223
224 with open(svg_path, 'r', encoding='utf-8') as f:
225 content = f.read()
226
227 # Register SVG namespaces
228 namespaces = {
229 '': 'http://www.w3.org/2000/svg',
230 'xlink': 'http://www.w3.org/1999/xlink',
231 'svg': 'http://www.w3.org/2000/svg',
232 'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
233 'inkscape': 'http://www.inkscape.org/namespaces/inkscape',
234 }
235
236 for prefix, uri in namespaces.items():
237 if prefix:
238 ET.register_namespace(prefix, uri)
239 else:
240 ET.register_namespace('', uri)
241
242 try:
243 tree = ET.parse(svg_path)
244 root = tree.getroot()
245 except ET.ParseError as e:
246 print(f" [ERROR] Cannot parse SVG: {e}")
247 return 0
248
249 # Find all image elements
250 fixed_count = 0
251
252 # Check image elements with and without namespace
253 for ns_prefix in ['', '{http://www.w3.org/2000/svg}']:
254 for image_elem in root.iter(f'{ns_prefix}image'):
255 # Get href attribute (supports xlink:href and href)
256 href = image_elem.get('{http://www.w3.org/1999/xlink}href')
257 if href is None:
258 href = image_elem.get('href')
259 if href is None:
260 continue
261
262 # Get current dimensions and position
263 try:
264 x = float(image_elem.get('x', 0))
265 y = float(image_elem.get('y', 0))
266 width = float(image_elem.get('width', 0))
267 height = float(image_elem.get('height', 0))
268 except (ValueError, TypeError):
269 continue
270
271 if width <= 0 or height <= 0:
272 continue
273
274 # Get preserveAspectRatio
275 par = image_elem.get('preserveAspectRatio', 'xMidYMid meet')
276
277 # Parse preserveAspectRatio
278 # Format: <align> [<meetOrSlice>]
279 # e.g.: xMidYMid meet, xMidYMid slice, none
280 par_parts = par.split()
281 align = par_parts[0] if par_parts else 'xMidYMid'
282 meet_or_slice = par_parts[1] if len(par_parts) > 1 else 'meet'
283
284 if align == 'none':
285 # If none, no fix needed
286 continue
287
288 # Get original image dimensions
289 img_width, img_height = get_image_dimensions(href, svg_dir)
290 if img_width is None or img_height is None:
291 continue
292
293 # Calculate fitted dimensions
294 mode = 'slice' if meet_or_slice == 'slice' else 'meet'
295 new_width, new_height, offset_x, offset_y = calculate_fitted_dimensions(
296 img_width, img_height, width, height, mode
297 )
298
299 # Check if modification is needed
300 tolerance = 0.5 # Allowed tolerance
301 if (abs(new_width - width) < tolerance and
302 abs(new_height - height) < tolerance):
303 # Dimensions are already correct, no modification needed
304 continue
305
306 if verbose:
307 img_name = os.path.basename(href.split('?')[0][:50] if not href.startswith('data:') else '[base64]')
308 print(f" [FIX] {img_name}")
309 print(f" Original image: {img_width}x{img_height} (ratio: {img_width/img_height:.3f})")
310 print(f" Original box: {width}x{height} @ ({x}, {y})")
311 print(f" New box: {new_width:.1f}x{new_height:.1f} @ ({x + offset_x:.1f}, {y + offset_y:.1f})")
312
313 if not dry_run:
314 # Update attributes
315 image_elem.set('x', f'{x + offset_x:.1f}')
316 image_elem.set('y', f'{y + offset_y:.1f}')
317 image_elem.set('width', f'{new_width:.1f}')
318 image_elem.set('height', f'{new_height:.1f}')
319 # Remove preserveAspectRatio since dimensions are now correct
320 if 'preserveAspectRatio' in image_elem.attrib:
321 del image_elem.attrib['preserveAspectRatio']
322
323 fixed_count += 1
324
325 if not dry_run and fixed_count > 0:
326 # Save modifications
327 tree.write(svg_path, encoding='unicode', xml_declaration=True)
328
329 return fixed_count
330
331
332 def main() -> None:
333 """Run the CLI entry point."""
334 parser = argparse.ArgumentParser(
335 description='Normalize SVG image boxes for PowerPoint SVG rendering diagnostics',
336 formatter_class=argparse.RawDescriptionHelpFormatter,
337 epilog='''
338 Examples:
339 %(prog)s slide_01.svg # Process a single file
340 %(prog)s *.svg # Process all SVGs in current directory
341 %(prog)s --dry-run *.svg # Preview files to be processed
342 %(prog)s projects/xxx/svg_output/*.svg # Process project directory
343 '''
344 )
345 parser.add_argument('files', nargs='+', help='SVG files to process')
346 parser.add_argument('--dry-run', '-n', action='store_true',
347 help='Only show which images would be fixed, without modifying files')
348 parser.add_argument('--quiet', '-q', action='store_true',
349 help='Quiet mode, reduce output')
350
351 args = parser.parse_args()
352
353 if args.dry_run:
354 print("[INFO] Preview mode: only showing what would be modified, no files will be changed\n")
355
356 total_fixed = 0
357 total_files = 0
358
359 for svg_file in args.files:
360 if not os.path.exists(svg_file):
361 if not args.quiet:
362 print(f"[ERROR] File not found: {svg_file}")
363 continue
364
365 if not svg_file.endswith('.svg'):
366 if not args.quiet:
367 print(f"[SKIP] Skipping non-SVG file: {svg_file}")
368 continue
369
370 if not args.quiet:
371 print(f"\n[FILE] {os.path.basename(svg_file)}")
372
373 fixed = fix_image_aspect_in_svg(svg_file, dry_run=args.dry_run, verbose=not args.quiet)
374
375 if fixed > 0:
376 total_fixed += fixed
377 total_files += 1
378 elif not args.quiet:
379 print(" No fix needed")
380
381 print(f"\n{'=' * 50}")
382 if args.dry_run:
383 print(f"[PREVIEW] Will fix {total_fixed} image(s) in {total_files} file(s)")
384 else:
385 print(f"[DONE] Fixed {total_fixed} image(s) in {total_files} file(s)")
386
387
388 if __name__ == '__main__':
389 main()
390
390 lines PYTHON