Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_finalize/align_embed_images.py
T

673 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""PPT Master — single-pass image alignment + Base64 embedding.
Replaces the previous three independent finalize_svg steps:
crop-images → for each <image preserveAspectRatio="… slice"/>, crop the
source bitmap to the target aspect ratio at the given
anchor and write to ``images/cropped/`` so the SVG
reference points to a pre-cropped asset.
fix-aspect → for each <image>, read the source bitmap dimensions and
adjust x/y/width/height so the rendered box matches the
image aspect ratio in PowerPoint SVG rendering paths that
do not honor preserveAspectRatio consistently.
embed-images → Base64-inline every embeddable external image reference
so ``svg_final/`` remains portable when opened or manually
inserted as an SVG image. EMF/WMF keep the documented
external-reference exception.
Why merge: each step independently parsed + serialized the SVG, each step
re-read the same bitmap from disk, and the two spatial transforms (crop and
fit-box) are mutually exclusive yet were sequenced one after the other.
The fix-aspect default ``preserveAspectRatio = "xMidYMid meet"`` could
also kick in on rects already cropped by crop-images (whose par was
already removed), with the only thing keeping it from corrupting the
geometry being that crop and fix-aspect happened to produce numerically
equal box dimensions — a brittle accident.
The merged pipeline:
for image in svg:
if href starts with data: → skip (already inline)
if href is unresolvable / external URL → skip
if href points to EMF/WMF → skip (native PPTX passthrough only)
if image belongs to a valid nested crop → preserve source pixels, embed
if missing preserveAspectRatio → just embed (do not assume meet)
if align == none → just embed (no spatial transform)
if mode == slice → crop in memory, embed cropped bytes
if mode == meet → adjust x/y/w/h, embed original bytes
write SVG once
Bonus: the cropped bitmap is base64-inlined directly without going through
``images/cropped/``, so that intermediate directory disappears and stale
crops can no longer accumulate across re-runs.
"""
from __future__ import annotations
import base64
import io
import math
import os
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from resource_paths import ( # noqa: E402
resolve_external_image_reference,
svg_data_uri_payload_error,
svg_image_payload_error,
)
configure_utf8_stdio()
if __package__ in {None, ''}:
import types
package = types.ModuleType('svg_finalize')
package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined]
sys.modules.setdefault('svg_finalize', package)
__package__ = 'svg_finalize'
# Reuse helpers from the previous standalone modules.
from .crop_images import crop_image_to_size, get_crop_anchor, parse_preserve_aspect_ratio
from .embed_images import _optimize_image_bytes, get_mime_type
from .fix_image_aspect import calculate_fitted_dimensions
from svg_to_pptx.drawingml.elements import ( # noqa: E402
parse_project_nested_svg_crop,
)
if TYPE_CHECKING: # pragma: no cover
from PIL import Image as PILImage # noqa: F401
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
# PIL save format is named slightly differently from the file extension /
# MIME type set we expose elsewhere; this map covers the formats we accept.
_PIL_FORMAT_BY_MIME = {
'image/png': 'PNG',
'image/jpeg': 'JPEG',
'image/gif': 'GIF',
'image/webp': 'WEBP',
}
_OFFICE_VECTOR_EXTENSIONS = {'.emf', '.wmf'}
def _parse_float(val: str | None, default: float = 0.0) -> float:
"""Best-effort float parse, tolerating trailing ``px`` etc."""
if val is None or val == '':
return default
try:
return float(re.sub(r'(px|pt|em|%|rem)$', '', val.strip()))
except (ValueError, AttributeError):
return default
def _format_number(n: float) -> str:
"""Format a float for compact SVG attribute output."""
if abs(n - round(n)) < 1e-6:
return str(int(round(n)))
s = f"{n:.2f}".rstrip('0').rstrip('.')
return s or '0'
def _resolve_image_path(href: str, svg_dir: Path) -> Path | None:
"""Resolve an <image> href to a local filesystem path.
Returns None for unresolvable references (http/https/etc.) so callers
can leave those refs untouched.
"""
if not href:
return None
return resolve_external_image_reference(svg_dir, href)
def _is_svg_image(img_path: Path, raw_bytes: bytes) -> bool:
"""Return True when an image reference is an SVG document."""
if img_path.suffix.lower() == '.svg':
return True
head = raw_bytes.lstrip()[:512].lower()
return head.startswith(b'<svg') or (head.startswith(b'<?xml') and b'<svg' in head)
def _embed_raw_image(image: ET.Element, img_path: Path, raw_bytes: bytes) -> None:
"""Embed raw image bytes without PIL transforms."""
mime_type = get_mime_type(img_path.name, raw_bytes)
b64 = base64.b64encode(raw_bytes).decode('ascii')
_set_href(image, f'data:{mime_type};base64,{b64}')
def _load_pil_image(img_path: Path) -> 'PILImage' | None:
"""Open an image with PIL, returning None on any failure."""
try:
from PIL import Image
except ImportError:
return None
try:
return Image.open(img_path)
except (OSError, ValueError):
return None
def _prepare_raster_for_geometry(img: 'PILImage') -> 'PILImage':
"""Apply EXIF orientation and materialize palette/tRNS transparency."""
from PIL import ImageOps
prepared = ImageOps.exif_transpose(img)
if prepared.mode == 'P':
prepared = prepared.convert('RGBA' if _has_alpha(prepared) else 'RGB')
elif (
'transparency' in getattr(prepared, 'info', {})
and prepared.mode not in {'RGBA', 'LA'}
):
prepared = prepared.convert('RGBA')
return prepared
def _has_exif_geometry_transform(img: 'PILImage') -> bool:
"""Return whether EXIF requires a physical mirror or rotation."""
try:
return int(img.getexif().get(274, 1)) in range(2, 9)
except (AttributeError, TypeError, ValueError):
return False
def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage':
"""Coerce a PIL image into a mode that the target format can save.
JPEG cannot store alpha — flatten to white background. Other formats
keep alpha when present.
"""
if mime_type == 'image/jpeg':
if img.mode in ('RGBA', 'LA'):
from PIL import Image
background = Image.new('RGB', img.size, (255, 255, 255))
alpha = img.getchannel('A')
background.paste(img.convert('RGB'), mask=alpha)
return background
if img.mode != 'RGB':
return img.convert('RGB')
return img
# Lossless output — preserve alpha if present.
if img.mode == 'P':
return img.convert('RGBA' if _has_alpha(img) else 'RGB')
if img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}:
return img.convert('RGBA' if _has_alpha(img) else 'RGB')
return img
def _has_alpha(img: 'PILImage') -> bool:
"""Return whether a PIL image has transparency."""
if img.mode in ('RGBA', 'LA'):
return True
return 'transparency' in getattr(img, 'info', {})
def _target_size(
box_w: float,
box_h: float,
*,
max_dimension: int | None,
image_scale: float,
) -> tuple[int, int]:
"""Resolve the pixel budget for a rendered SVG image box."""
target_w = max(1, int(round(box_w * max(image_scale, 1.0))))
target_h = max(1, int(round(box_h * max(image_scale, 1.0))))
if max_dimension and max(target_w, target_h) > max_dimension:
ratio = max_dimension / max(target_w, target_h)
target_w = max(1, int(round(target_w * ratio)))
target_h = max(1, int(round(target_h * ratio)))
return target_w, target_h
def _downscale_to_target(img: 'PILImage', target_w: int, target_h: int) -> tuple['PILImage', bool]:
"""Downscale without upsampling."""
width, height = img.size
if width <= 0 or height <= 0:
return img, False
ratio = min(target_w / width, target_h / height, 1.0)
if ratio >= 1.0:
return img, False
from PIL import Image
new_size = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio))))
return img.resize(new_size, Image.Resampling.LANCZOS), True
def _encode_pil_to_data_uri(
img: 'PILImage',
src_path: Path,
*,
compress: bool,
max_dimension: int | None,
fallback_bytes: bytes | None,
) -> tuple[str, int] | None:
"""Serialize *img* to a base64 data URI.
If the image has not been transformed, ``--no-compress`` preserves a
supported PNG/JPEG/GIF/WebP payload byte-for-byte. Compression mode may
still retain a smaller original payload. *fallback_bytes* carries those
raw on-disk bytes.
"""
original_mime_type = get_mime_type(src_path.name, fallback_bytes)
if (
not compress
and fallback_bytes is not None
and original_mime_type in _PIL_FORMAT_BY_MIME
):
encoded = base64.b64encode(fallback_bytes).decode('ascii')
return (
f'data:{original_mime_type};base64,{encoded}',
len(fallback_bytes),
)
# Match native export: only original JPEG assets stay lossy. PNG remains
# PNG, while BMP/TIFF and other static raster formats become lossless PNG.
mime_type = (
'image/jpeg' if original_mime_type == 'image/jpeg' else 'image/png'
)
pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG')
# Encode current PIL image
try:
prepared = _normalize_for_save(img, mime_type)
buf = io.BytesIO()
save_kwargs: dict = {'format': pil_format}
if pil_format == 'JPEG':
save_kwargs['quality'] = 95
save_kwargs['optimize'] = True
elif pil_format == 'PNG':
save_kwargs['optimize'] = True
prepared.save(buf, **save_kwargs)
encoded_bytes = buf.getvalue()
except (OSError, ValueError):
return None
optimized_bytes = _optimize_image_bytes(
encoded_bytes, mime_type, compress=compress, max_dimension=max_dimension,
)
# If the original represents the same uncropped pixels and is smaller,
# retain it instead of inflating an already efficient PNG/JPEG.
chosen = optimized_bytes
if (
fallback_bytes
and mime_type == original_mime_type
and len(fallback_bytes) < len(optimized_bytes)
):
chosen = fallback_bytes
b64 = base64.b64encode(chosen).decode('ascii')
return f'data:{mime_type};base64,{b64}', len(chosen)
def _iter_image_elements(root: ET.Element):
"""Yield every <image> in the tree regardless of namespace prefix."""
for image in root.iter(f'{{{SVG_NS}}}image'):
yield image
# Also catch namespace-stripped trees just in case
for image in root.iter('image'):
yield image
def _get_href(image: ET.Element) -> str | None:
"""Return the image href, supporting both ``href`` and ``xlink:href``."""
return image.get('href') or image.get(f'{{{XLINK_NS}}}href')
def _set_href(image: ET.Element, value: str) -> None:
"""Write the data URI back to whichever href attribute the image used."""
if image.get(f'{{{XLINK_NS}}}href') is not None:
image.set(f'{{{XLINK_NS}}}href', value)
else:
image.set('href', value)
def _nested_crop_image_ids(root: ET.Element) -> set[int]:
"""Return child image identities from valid nested crop transports."""
image_ids: set[int] = set()
for elem in root.iter(f'{{{SVG_NS}}}svg'):
if elem is root:
continue
try:
crop = parse_project_nested_svg_crop(elem)
except ValueError:
continue
image_ids.add(id(crop.image))
return image_ids
def _process_one_image(
image: ET.Element,
svg_dir: Path,
*,
compress: bool,
max_dimension: int | None,
image_scale: float,
preserve_source_pixels: bool,
verbose: bool,
) -> tuple[bool, str | None]:
"""Align (slice/meet) and embed a single <image>.
Returns ``(processed, error)`` where *processed* is True iff the image
was rewritten and *error* is a short message when something went wrong
(the image is left untouched in that case).
"""
href = _get_href(image)
if not href:
return False, None
if href.lower().startswith('data:'):
payload_error = svg_data_uri_payload_error(href)
if payload_error is not None:
return False, payload_error
return False, None # already inline
img_path = _resolve_image_path(href, svg_dir)
if img_path is None:
return False, f'unresolved href: {href[:60]}'
try:
with open(img_path, 'rb') as fh:
raw_bytes = fh.read()
except OSError as exc:
return False, f'read failed: {exc}'
if img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS:
if verbose:
print(f' [INFO] {img_path.name}: Office vector left external for native PPTX passthrough')
return False, None
if _is_svg_image(img_path, raw_bytes):
payload_error = svg_image_payload_error(raw_bytes)
if payload_error is not None:
return False, f'{img_path.name}: {payload_error}'
_embed_raw_image(image, img_path, raw_bytes)
if verbose:
print(f' [OK] {img_path.name} (svg, embedded as-is)')
return True, None
img = _load_pil_image(img_path)
if img is None:
return False, 'PIL open failed'
# Multi-frame images (animated GIF / WebP / APNG): every PIL transform
# and re-save below operates on frame 0 only, silently flattening the
# animation — and the "original bytes are smaller" fallback never fires
# because one frame is always smaller than all frames. Embed the raw
# bytes untouched and keep the geometry attributes (including
# preserveAspectRatio, which the native converter maps to srcRect
# non-destructively). Animated assets skip re-encode, resize, and the
# size cap.
if getattr(img, 'is_animated', False):
_embed_raw_image(image, img_path, raw_bytes)
if max_dimension and max(img.size) > max_dimension:
print(f' [WARN] {img_path.name}: animated image kept as-is '
f'({img.size[0]}x{img.size[1]} exceeds max dimension '
f'{max_dimension}px); animations are exempt from size limits')
if verbose:
print(f' [OK] {img_path.name} (animated, embedded as-is)')
return True, None
geometry_normalized = _has_exif_geometry_transform(img)
img = _prepare_raster_for_geometry(img)
box_x = _parse_float(image.get('x'))
box_y = _parse_float(image.get('y'))
box_w = _parse_float(image.get('width'))
box_h = _parse_float(image.get('height'))
if (
not math.isfinite(box_w)
or not math.isfinite(box_h)
or box_w <= 0
or box_h <= 0
):
return False, 'zero-sized box'
par_attr = image.get('preserveAspectRatio') or ''
par_attr = par_attr.strip()
# ------------------------------------------------------------------
# Decide the spatial transform
# ------------------------------------------------------------------
final_img: 'PILImage' = img
new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h
transformed = geometry_normalized
target_box_w, target_box_h = box_w, box_h
preserve_stretch = False
preserve_slice = False
if not par_attr:
# No preserveAspectRatio at all. The previous pipeline's fix-aspect
# step assumed "xMidYMid meet" here, which silently re-fit images
# that crop-images had already shaped. Treat absence as "leave it
# alone": embed bytes, keep box.
pass
else:
align, mode = parse_preserve_aspect_ratio(par_attr)
if align == 'none':
# Author wants stretch-to-box; preserve geometry, embed bytes.
preserve_stretch = True
elif mode == 'slice':
x_anchor, y_anchor = get_crop_anchor(align)
cropped_img = crop_image_to_size(
img, box_w, box_h, x_anchor, y_anchor
)
final_img = cropped_img
transformed = True
preserve_slice = not math.isclose(
cropped_img.size[0] / cropped_img.size[1],
box_w / box_h,
rel_tol=1e-6,
abs_tol=1e-9,
)
else: # meet (or any other mode → treat as meet)
new_w_calc, new_h_calc, off_x, off_y = calculate_fitted_dimensions(
img.size[0], img.size[1], box_w, box_h, mode='meet',
)
new_x = box_x + off_x
new_y = box_y + off_y
new_w = new_w_calc
new_h = new_h_calc
target_box_w, target_box_h = new_w, new_h
if preserve_source_pixels:
# The child's 1×1 box is source-unit crop geometry, not its rendered
# frame. Keep the full source raster so the outer viewport remains
# authoritative and independently shaped crops do not lose detail.
target_w, target_h = final_img.size
else:
target_w, target_h = _target_size(
target_box_w,
target_box_h,
max_dimension=max_dimension,
image_scale=image_scale,
)
final_img, resized = _downscale_to_target(final_img, target_w, target_h)
transformed = transformed or resized
# ------------------------------------------------------------------
# Encode and rewrite
# ------------------------------------------------------------------
encoded = _encode_pil_to_data_uri(
final_img,
img_path,
compress=compress,
max_dimension=None if preserve_source_pixels else max_dimension,
fallback_bytes=raw_bytes if not transformed else None,
)
if encoded is None:
return False, 'encode failed'
data_uri, _ = encoded
_set_href(image, data_uri)
image.set('x', _format_number(new_x))
image.set('y', _format_number(new_y))
image.set('width', _format_number(new_w))
image.set('height', _format_number(new_h))
if preserve_stretch:
image.set('preserveAspectRatio', 'none')
elif preserve_slice:
image.set('preserveAspectRatio', par_attr)
elif 'preserveAspectRatio' in image.attrib:
del image.attrib['preserveAspectRatio']
if verbose:
if preserve_source_pixels:
suffix = ' (nested crop, source pixels preserved)'
else:
suffix = ' (cropped)' if transformed else ''
print(f' [OK] {img_path.name}{suffix}')
return True, None
def count_office_vector_refs_in_svg(svg_path: str | Path) -> int:
"""Count local EMF/WMF image refs that the embed pass intentionally skips."""
svg_path = Path(svg_path)
svg_dir = svg_path.parent.resolve()
try:
tree = ET.parse(svg_path)
except ET.ParseError:
return 0
count = 0
seen: set[int] = set()
for image in _iter_image_elements(tree.getroot()):
ident = id(image)
if ident in seen:
continue
seen.add(ident)
href = _get_href(image)
if not href or href.startswith('data:'):
continue
img_path = _resolve_image_path(href, svg_dir)
if img_path and img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS:
count += 1
return count
def align_and_embed_images_in_svg(
svg_path: str | Path,
*,
dry_run: bool = False,
verbose: bool = False,
compress: bool = True,
max_dimension: int | None = 2560,
image_scale: float = 2.0,
) -> tuple[int, int]:
"""Run the merged align + embed pass on a single SVG file.
Returns ``(processed_count, error_count)``.
"""
svg_path = Path(svg_path)
svg_dir = svg_path.parent.resolve()
# Register namespaces for clean serialization
ET.register_namespace('', SVG_NS)
ET.register_namespace('xlink', XLINK_NS)
try:
tree = ET.parse(svg_path)
except ET.ParseError as exc:
print(
f' [ERROR] {svg_path.name}: parse failed ({exc})',
file=sys.stderr,
)
return (0, 1)
root = tree.getroot()
nested_crop_image_ids = _nested_crop_image_ids(root)
# Avoid double-iteration if an element matches both namespaced and
# bare-tag iteration paths.
seen: set[int] = set()
processed = 0
errors = 0
for image in _iter_image_elements(root):
ident = id(image)
if ident in seen:
continue
seen.add(ident)
if dry_run:
processed += 1
continue
ok, err = _process_one_image(
image, svg_dir,
compress=compress, max_dimension=max_dimension,
image_scale=image_scale,
preserve_source_pixels=ident in nested_crop_image_ids,
verbose=verbose,
)
if ok:
processed += 1
elif err:
errors += 1
print(f' [ERROR] {svg_path.name}: {err}', file=sys.stderr)
if processed > 0 and errors == 0 and not dry_run:
tree.write(svg_path, encoding='utf-8', xml_declaration=False)
return (processed, errors)
# ---------------------------------------------------------------------------
# Standalone CLI (rare; the main entry point is finalize_svg.py)
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""Build the standalone diagnostic parser."""
import argparse
parser = argparse.ArgumentParser(
description='Align (slice/meet) and Base64-embed all <image> refs in an SVG.',
)
parser.add_argument('svg', type=Path, help='SVG file to process in place')
parser.add_argument('-n', '--dry-run', action='store_true')
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--compress', dest='compress', action='store_true', default=True,
help='Compress images before embedding (default)')
parser.add_argument('--no-compress', dest='compress', action='store_false',
help='Disable image compression')
parser.add_argument('--max-dimension', type=int, default=2560,
help='Downscale images larger than this on either axis (default: 2560)')
parser.add_argument('--image-scale', type=float, default=2.0,
help='Target pixels per rendered SVG pixel')
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the standalone diagnostic CLI."""
parser = build_parser()
args = parser.parse_args(argv)
if not args.svg.exists():
print(f'Error: file not found: {args.svg}', file=sys.stderr)
return 1
if args.max_dimension < 1:
print('Error: --max-dimension must be >= 1', file=sys.stderr)
return 1
if args.image_scale < 1:
print('Error: --image-scale must be >= 1', file=sys.stderr)
return 1
proc, err = align_and_embed_images_in_svg(
args.svg,
dry_run=args.dry_run,
verbose=args.verbose,
compress=args.compress,
max_dimension=args.max_dimension,
image_scale=args.image_scale,
)
print(f'Processed {proc} image(s), {err} error(s)')
return 1 if err else 0
if __name__ == '__main__':
raise SystemExit(main())