zcbot/skills/ppt/scripts/svg_finalize/fix_image_aspect.py

381 lines
13 KiB
Python

#!/usr/bin/env python3
"""
SVG Image Aspect Ratio Fix Tool
Fixes the dimensions of <image> elements in SVG to match the original image aspect ratio.
This prevents images from being stretched when PowerPoint converts SVG to editable shapes.
Principle:
When PowerPoint converts SVG to editable shapes, it ignores the preserveAspectRatio attribute
and directly stretches the image to fill the area specified by width/height.
This tool reads the actual image aspect ratio and recalculates the x, y, width, height of
<image> elements so that images are centered and maintain their original aspect ratio.
Usage:
python3 scripts/svg_finalize/fix_image_aspect.py <svg_file> [svg_file2] ...
python3 scripts/svg_finalize/fix_image_aspect.py projects/xxx/svg_output/*.svg
# Preview mode
python3 scripts/svg_finalize/fix_image_aspect.py --dry-run projects/xxx/svg_output/*.svg
Examples:
python3 scripts/svg_finalize/fix_image_aspect.py projects/demo/svg_output/slide_06_current_overview.svg
"""
import os
import re
import sys
import base64
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
# Try to import PIL for getting image dimensions
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
print("[WARN] PIL not installed. Install with: pip install Pillow")
print(" Will try to use basic method for JPEG/PNG files.")
def get_image_dimensions_pil(image_path: str) -> tuple[int | None, int | None]:
"""Get image dimensions using PIL."""
try:
with Image.open(image_path) as img:
return img.width, img.height
except Exception as e:
print(f" [WARN] Cannot read image with PIL: {e}")
return None, None
def get_image_dimensions_basic(image_path: str) -> tuple[int | None, int | None]:
"""Get image dimensions using basic parsing without PIL."""
try:
with open(image_path, 'rb') as f:
data = f.read(64) # Read header information
# PNG
if data[:8] == b'\x89PNG\r\n\x1a\n':
w = int.from_bytes(data[16:20], 'big')
h = int.from_bytes(data[20:24], 'big')
return w, h
# JPEG
if data[:2] == b'\xff\xd8':
# Need to read full file to parse JPEG
with open(image_path, 'rb') as f:
f.seek(2)
while True:
marker = f.read(2)
if not marker or len(marker) < 2:
break
if marker[0] != 0xff:
break
m = marker[1]
# SOF0, SOF2 markers contain dimensions
if m in (0xC0, 0xC2):
f.read(3) # Skip length and precision
h = int.from_bytes(f.read(2), 'big')
w = int.from_bytes(f.read(2), 'big')
return w, h
elif m == 0xD9: # EOI
break
elif m == 0xD8: # SOI
continue
elif 0xD0 <= m <= 0xD7: # RST
continue
else:
length = int.from_bytes(f.read(2), 'big')
f.seek(length - 2, 1)
return None, None
except Exception as e:
print(f" [WARN] Cannot read image dimensions: {e}")
return None, None
def get_image_dimensions_from_base64(data_uri: str) -> tuple[int | None, int | None]:
"""Get image dimensions from a Base64 data URI."""
import io
try:
# Parse data URI
match = re.match(r'data:image/(\w+);base64,(.+)', data_uri)
if not match:
return None, None
img_format = match.group(1)
b64_data = match.group(2)
img_bytes = base64.b64decode(b64_data)
if HAS_PIL:
with Image.open(io.BytesIO(img_bytes)) as img:
return img.width, img.height
else:
# Use basic method
if img_bytes[:8] == b'\x89PNG\r\n\x1a\n':
w = int.from_bytes(img_bytes[16:20], 'big')
h = int.from_bytes(img_bytes[20:24], 'big')
return w, h
return None, None
except Exception as e:
print(f" [WARN] Cannot parse base64 image: {e}")
return None, None
def get_image_dimensions(href: str, svg_dir: str) -> tuple[int | None, int | None]:
"""Get image dimensions for either inline or external images."""
# Handle data URI
if href.startswith('data:'):
return get_image_dimensions_from_base64(href)
# Handle external files
if not os.path.isabs(href):
full_path = os.path.join(svg_dir, href)
else:
full_path = href
if not os.path.exists(full_path):
print(f" [WARN] Image not found: {href}")
return None, None
if HAS_PIL:
return get_image_dimensions_pil(full_path)
else:
return get_image_dimensions_basic(full_path)
def calculate_fitted_dimensions(
img_width: int,
img_height: int,
box_width: float,
box_height: float,
mode: str = 'meet',
) -> tuple[float, float, float, float]:
"""
Calculate the fitted dimensions for an image within a bounding box.
Args:
img_width, img_height: Original image dimensions
box_width, box_height: Container box dimensions
mode: 'meet' preserves aspect ratio and fully displays image (may have whitespace)
'slice' preserves aspect ratio and fully fills container (may crop)
Returns:
(new_width, new_height, offset_x, offset_y)
"""
img_ratio = img_width / img_height
box_ratio = box_width / box_height
if mode == 'meet':
# Fully display image, may have whitespace
if img_ratio > box_ratio:
# Image is wider, fit by width
new_width = box_width
new_height = box_width / img_ratio
else:
# Image is taller, fit by height
new_height = box_height
new_width = box_height * img_ratio
else: # slice
# Fully fill container, may crop
if img_ratio > box_ratio:
# Image is wider, fit by height
new_height = box_height
new_width = box_height * img_ratio
else:
# Image is taller, fit by width
new_width = box_width
new_height = box_width / img_ratio
# Center offset
offset_x = (box_width - new_width) / 2
offset_y = (box_height - new_height) / 2
return new_width, new_height, offset_x, offset_y
def fix_image_aspect_in_svg(svg_path: str, dry_run: bool = False, verbose: bool = True) -> int:
"""
Fix image aspect ratios in an SVG file.
Args:
svg_path: SVG file path
dry_run: Whether to only preview without modifying
verbose: Whether to output detailed information
Returns:
Number of images fixed
"""
svg_dir = os.path.dirname(os.path.abspath(svg_path))
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
# Register SVG namespaces
namespaces = {
'': 'http://www.w3.org/2000/svg',
'xlink': 'http://www.w3.org/1999/xlink',
'svg': 'http://www.w3.org/2000/svg',
'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
'inkscape': 'http://www.inkscape.org/namespaces/inkscape',
}
for prefix, uri in namespaces.items():
if prefix:
ET.register_namespace(prefix, uri)
else:
ET.register_namespace('', uri)
try:
tree = ET.parse(svg_path)
root = tree.getroot()
except ET.ParseError as e:
print(f" [ERROR] Cannot parse SVG: {e}")
return 0
# Find all image elements
fixed_count = 0
# Check image elements with and without namespace
for ns_prefix in ['', '{http://www.w3.org/2000/svg}']:
for image_elem in root.iter(f'{ns_prefix}image'):
# Get href attribute (supports xlink:href and href)
href = image_elem.get('{http://www.w3.org/1999/xlink}href')
if href is None:
href = image_elem.get('href')
if href is None:
continue
# Get current dimensions and position
try:
x = float(image_elem.get('x', 0))
y = float(image_elem.get('y', 0))
width = float(image_elem.get('width', 0))
height = float(image_elem.get('height', 0))
except (ValueError, TypeError):
continue
if width <= 0 or height <= 0:
continue
# Get preserveAspectRatio
par = image_elem.get('preserveAspectRatio', 'xMidYMid meet')
# Parse preserveAspectRatio
# Format: <align> [<meetOrSlice>]
# e.g.: xMidYMid meet, xMidYMid slice, none
par_parts = par.split()
align = par_parts[0] if par_parts else 'xMidYMid'
meet_or_slice = par_parts[1] if len(par_parts) > 1 else 'meet'
if align == 'none':
# If none, no fix needed
continue
# Get original image dimensions
img_width, img_height = get_image_dimensions(href, svg_dir)
if img_width is None or img_height is None:
continue
# Calculate fitted dimensions
mode = 'slice' if meet_or_slice == 'slice' else 'meet'
new_width, new_height, offset_x, offset_y = calculate_fitted_dimensions(
img_width, img_height, width, height, mode
)
# Check if modification is needed
tolerance = 0.5 # Allowed tolerance
if (abs(new_width - width) < tolerance and
abs(new_height - height) < tolerance):
# Dimensions are already correct, no modification needed
continue
if verbose:
img_name = os.path.basename(href.split('?')[0][:50] if not href.startswith('data:') else '[base64]')
print(f" [FIX] {img_name}")
print(f" Original image: {img_width}x{img_height} (ratio: {img_width/img_height:.3f})")
print(f" Original box: {width}x{height} @ ({x}, {y})")
print(f" New box: {new_width:.1f}x{new_height:.1f} @ ({x + offset_x:.1f}, {y + offset_y:.1f})")
if not dry_run:
# Update attributes
image_elem.set('x', f'{x + offset_x:.1f}')
image_elem.set('y', f'{y + offset_y:.1f}')
image_elem.set('width', f'{new_width:.1f}')
image_elem.set('height', f'{new_height:.1f}')
# Remove preserveAspectRatio since dimensions are now correct
if 'preserveAspectRatio' in image_elem.attrib:
del image_elem.attrib['preserveAspectRatio']
fixed_count += 1
if not dry_run and fixed_count > 0:
# Save modifications
tree.write(svg_path, encoding='unicode', xml_declaration=True)
return fixed_count
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='Fix image aspect ratios in SVG to prevent stretching when PowerPoint converts to shapes',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s slide_01.svg # Process a single file
%(prog)s *.svg # Process all SVGs in current directory
%(prog)s --dry-run *.svg # Preview files to be processed
%(prog)s projects/xxx/svg_output/*.svg # Process project directory
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--dry-run', '-n', action='store_true',
help='Only show which images would be fixed, without modifying files')
parser.add_argument('--quiet', '-q', action='store_true',
help='Quiet mode, reduce output')
args = parser.parse_args()
if args.dry_run:
print("[INFO] Preview mode: only showing what would be modified, no files will be changed\n")
total_fixed = 0
total_files = 0
for svg_file in args.files:
if not os.path.exists(svg_file):
if not args.quiet:
print(f"[ERROR] File not found: {svg_file}")
continue
if not svg_file.endswith('.svg'):
if not args.quiet:
print(f"[SKIP] Skipping non-SVG file: {svg_file}")
continue
if not args.quiet:
print(f"\n[FILE] {os.path.basename(svg_file)}")
fixed = fix_image_aspect_in_svg(svg_file, dry_run=args.dry_run, verbose=not args.quiet)
if fixed > 0:
total_fixed += fixed
total_files += 1
elif not args.quiet:
print(" No fix needed")
print(f"\n{'=' * 50}")
if args.dry_run:
print(f"[PREVIEW] Will fix {total_fixed} image(s) in {total_files} file(s)")
else:
print(f"[DONE] Fixed {total_fixed} image(s) in {total_files} file(s)")
if __name__ == '__main__':
main()