| 1 | #!/usr/bin/env python3 |
| 2 | """PPT Master — single-pass image alignment + Base64 embedding. |
| 3 | |
| 4 | Replaces the previous three independent finalize_svg steps: |
| 5 | |
| 6 | crop-images → for each <image preserveAspectRatio="… slice"/>, crop the |
| 7 | source bitmap to the target aspect ratio at the given |
| 8 | anchor and write to ``images/cropped/`` so the SVG |
| 9 | reference points to a pre-cropped asset. |
| 10 | fix-aspect → for each <image>, read the source bitmap dimensions and |
| 11 | adjust x/y/width/height so the rendered box matches the |
| 12 | image aspect ratio in PowerPoint SVG rendering paths that |
| 13 | do not honor preserveAspectRatio consistently. |
| 14 | embed-images → Base64-inline every embeddable external image reference |
| 15 | so ``svg_final/`` remains portable when opened or manually |
| 16 | inserted as an SVG image. EMF/WMF keep the documented |
| 17 | external-reference exception. |
| 18 | |
| 19 | Why merge: each step independently parsed + serialized the SVG, each step |
| 20 | re-read the same bitmap from disk, and the two spatial transforms (crop and |
| 21 | fit-box) are mutually exclusive yet were sequenced one after the other. |
| 22 | The fix-aspect default ``preserveAspectRatio = "xMidYMid meet"`` could |
| 23 | also kick in on rects already cropped by crop-images (whose par was |
| 24 | already removed), with the only thing keeping it from corrupting the |
| 25 | geometry being that crop and fix-aspect happened to produce numerically |
| 26 | equal box dimensions — a brittle accident. |
| 27 | |
| 28 | The merged pipeline: |
| 29 | |
| 30 | for image in svg: |
| 31 | if href starts with data: → skip (already inline) |
| 32 | if href is unresolvable / external URL → skip |
| 33 | if href points to EMF/WMF → skip (native PPTX passthrough only) |
| 34 | if image belongs to a valid nested crop → preserve source pixels, embed |
| 35 | if missing preserveAspectRatio → just embed (do not assume meet) |
| 36 | if align == none → just embed (no spatial transform) |
| 37 | if mode == slice → crop in memory, embed cropped bytes |
| 38 | if mode == meet → adjust x/y/w/h, embed original bytes |
| 39 | write SVG once |
| 40 | |
| 41 | Bonus: the cropped bitmap is base64-inlined directly without going through |
| 42 | ``images/cropped/``, so that intermediate directory disappears and stale |
| 43 | crops can no longer accumulate across re-runs. |
| 44 | """ |
| 45 | |
| 46 | from __future__ import annotations |
| 47 | |
| 48 | import base64 |
| 49 | import io |
| 50 | import math |
| 51 | import os |
| 52 | import re |
| 53 | import sys |
| 54 | from pathlib import Path |
| 55 | from typing import TYPE_CHECKING |
| 56 | from xml.etree import ElementTree as ET |
| 57 | |
| 58 | _SCRIPTS_DIR = Path(__file__).resolve().parents[1] |
| 59 | if str(_SCRIPTS_DIR) not in sys.path: |
| 60 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 61 | |
| 62 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 63 | from resource_paths import ( # noqa: E402 |
| 64 | resolve_external_image_reference, |
| 65 | svg_data_uri_payload_error, |
| 66 | svg_image_payload_error, |
| 67 | ) |
| 68 | |
| 69 | configure_utf8_stdio() |
| 70 | |
| 71 | if __package__ in {None, ''}: |
| 72 | import types |
| 73 | |
| 74 | package = types.ModuleType('svg_finalize') |
| 75 | package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined] |
| 76 | sys.modules.setdefault('svg_finalize', package) |
| 77 | __package__ = 'svg_finalize' |
| 78 | |
| 79 | # Reuse helpers from the previous standalone modules. |
| 80 | from .crop_images import crop_image_to_size, get_crop_anchor, parse_preserve_aspect_ratio |
| 81 | from .embed_images import _optimize_image_bytes, get_mime_type |
| 82 | from .fix_image_aspect import calculate_fitted_dimensions |
| 83 | from svg_to_pptx.drawingml.elements import ( # noqa: E402 |
| 84 | parse_project_nested_svg_crop, |
| 85 | ) |
| 86 | |
| 87 | if TYPE_CHECKING: # pragma: no cover |
| 88 | from PIL import Image as PILImage # noqa: F401 |
| 89 | |
| 90 | |
| 91 | SVG_NS = 'http://www.w3.org/2000/svg' |
| 92 | XLINK_NS = 'http://www.w3.org/1999/xlink' |
| 93 | |
| 94 | # PIL save format is named slightly differently from the file extension / |
| 95 | # MIME type set we expose elsewhere; this map covers the formats we accept. |
| 96 | _PIL_FORMAT_BY_MIME = { |
| 97 | 'image/png': 'PNG', |
| 98 | 'image/jpeg': 'JPEG', |
| 99 | 'image/gif': 'GIF', |
| 100 | 'image/webp': 'WEBP', |
| 101 | } |
| 102 | _OFFICE_VECTOR_EXTENSIONS = {'.emf', '.wmf'} |
| 103 | |
| 104 | |
| 105 | def _parse_float(val: str | None, default: float = 0.0) -> float: |
| 106 | """Best-effort float parse, tolerating trailing ``px`` etc.""" |
| 107 | if val is None or val == '': |
| 108 | return default |
| 109 | try: |
| 110 | return float(re.sub(r'(px|pt|em|%|rem)$', '', val.strip())) |
| 111 | except (ValueError, AttributeError): |
| 112 | return default |
| 113 | |
| 114 | |
| 115 | def _format_number(n: float) -> str: |
| 116 | """Format a float for compact SVG attribute output.""" |
| 117 | if abs(n - round(n)) < 1e-6: |
| 118 | return str(int(round(n))) |
| 119 | s = f"{n:.2f}".rstrip('0').rstrip('.') |
| 120 | return s or '0' |
| 121 | |
| 122 | |
| 123 | def _resolve_image_path(href: str, svg_dir: Path) -> Path | None: |
| 124 | """Resolve an <image> href to a local filesystem path. |
| 125 | |
| 126 | Returns None for unresolvable references (http/https/etc.) so callers |
| 127 | can leave those refs untouched. |
| 128 | """ |
| 129 | if not href: |
| 130 | return None |
| 131 | return resolve_external_image_reference(svg_dir, href) |
| 132 | |
| 133 | |
| 134 | def _is_svg_image(img_path: Path, raw_bytes: bytes) -> bool: |
| 135 | """Return True when an image reference is an SVG document.""" |
| 136 | if img_path.suffix.lower() == '.svg': |
| 137 | return True |
| 138 | head = raw_bytes.lstrip()[:512].lower() |
| 139 | return head.startswith(b'<svg') or (head.startswith(b'<?xml') and b'<svg' in head) |
| 140 | |
| 141 | |
| 142 | def _embed_raw_image(image: ET.Element, img_path: Path, raw_bytes: bytes) -> None: |
| 143 | """Embed raw image bytes without PIL transforms.""" |
| 144 | mime_type = get_mime_type(img_path.name, raw_bytes) |
| 145 | b64 = base64.b64encode(raw_bytes).decode('ascii') |
| 146 | _set_href(image, f'data:{mime_type};base64,{b64}') |
| 147 | |
| 148 | |
| 149 | def _load_pil_image(img_path: Path) -> 'PILImage' | None: |
| 150 | """Open an image with PIL, returning None on any failure.""" |
| 151 | try: |
| 152 | from PIL import Image |
| 153 | except ImportError: |
| 154 | return None |
| 155 | try: |
| 156 | return Image.open(img_path) |
| 157 | except (OSError, ValueError): |
| 158 | return None |
| 159 | |
| 160 | |
| 161 | def _prepare_raster_for_geometry(img: 'PILImage') -> 'PILImage': |
| 162 | """Apply EXIF orientation and materialize palette/tRNS transparency.""" |
| 163 | from PIL import ImageOps |
| 164 | |
| 165 | prepared = ImageOps.exif_transpose(img) |
| 166 | if prepared.mode == 'P': |
| 167 | prepared = prepared.convert('RGBA' if _has_alpha(prepared) else 'RGB') |
| 168 | elif ( |
| 169 | 'transparency' in getattr(prepared, 'info', {}) |
| 170 | and prepared.mode not in {'RGBA', 'LA'} |
| 171 | ): |
| 172 | prepared = prepared.convert('RGBA') |
| 173 | return prepared |
| 174 | |
| 175 | |
| 176 | def _has_exif_geometry_transform(img: 'PILImage') -> bool: |
| 177 | """Return whether EXIF requires a physical mirror or rotation.""" |
| 178 | try: |
| 179 | return int(img.getexif().get(274, 1)) in range(2, 9) |
| 180 | except (AttributeError, TypeError, ValueError): |
| 181 | return False |
| 182 | |
| 183 | |
| 184 | def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage': |
| 185 | """Coerce a PIL image into a mode that the target format can save. |
| 186 | |
| 187 | JPEG cannot store alpha — flatten to white background. Other formats |
| 188 | keep alpha when present. |
| 189 | """ |
| 190 | if mime_type == 'image/jpeg': |
| 191 | if img.mode in ('RGBA', 'LA'): |
| 192 | from PIL import Image |
| 193 | background = Image.new('RGB', img.size, (255, 255, 255)) |
| 194 | alpha = img.getchannel('A') |
| 195 | background.paste(img.convert('RGB'), mask=alpha) |
| 196 | return background |
| 197 | if img.mode != 'RGB': |
| 198 | return img.convert('RGB') |
| 199 | return img |
| 200 | # Lossless output — preserve alpha if present. |
| 201 | if img.mode == 'P': |
| 202 | return img.convert('RGBA' if _has_alpha(img) else 'RGB') |
| 203 | if img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}: |
| 204 | return img.convert('RGBA' if _has_alpha(img) else 'RGB') |
| 205 | return img |
| 206 | |
| 207 | |
| 208 | def _has_alpha(img: 'PILImage') -> bool: |
| 209 | """Return whether a PIL image has transparency.""" |
| 210 | if img.mode in ('RGBA', 'LA'): |
| 211 | return True |
| 212 | return 'transparency' in getattr(img, 'info', {}) |
| 213 | |
| 214 | |
| 215 | def _target_size( |
| 216 | box_w: float, |
| 217 | box_h: float, |
| 218 | *, |
| 219 | max_dimension: int | None, |
| 220 | image_scale: float, |
| 221 | ) -> tuple[int, int]: |
| 222 | """Resolve the pixel budget for a rendered SVG image box.""" |
| 223 | target_w = max(1, int(round(box_w * max(image_scale, 1.0)))) |
| 224 | target_h = max(1, int(round(box_h * max(image_scale, 1.0)))) |
| 225 | if max_dimension and max(target_w, target_h) > max_dimension: |
| 226 | ratio = max_dimension / max(target_w, target_h) |
| 227 | target_w = max(1, int(round(target_w * ratio))) |
| 228 | target_h = max(1, int(round(target_h * ratio))) |
| 229 | return target_w, target_h |
| 230 | |
| 231 | |
| 232 | def _downscale_to_target(img: 'PILImage', target_w: int, target_h: int) -> tuple['PILImage', bool]: |
| 233 | """Downscale without upsampling.""" |
| 234 | width, height = img.size |
| 235 | if width <= 0 or height <= 0: |
| 236 | return img, False |
| 237 | ratio = min(target_w / width, target_h / height, 1.0) |
| 238 | if ratio >= 1.0: |
| 239 | return img, False |
| 240 | from PIL import Image |
| 241 | new_size = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio)))) |
| 242 | return img.resize(new_size, Image.Resampling.LANCZOS), True |
| 243 | |
| 244 | |
| 245 | def _encode_pil_to_data_uri( |
| 246 | img: 'PILImage', |
| 247 | src_path: Path, |
| 248 | *, |
| 249 | compress: bool, |
| 250 | max_dimension: int | None, |
| 251 | fallback_bytes: bytes | None, |
| 252 | ) -> tuple[str, int] | None: |
| 253 | """Serialize *img* to a base64 data URI. |
| 254 | |
| 255 | If the image has not been transformed, ``--no-compress`` preserves a |
| 256 | supported PNG/JPEG/GIF/WebP payload byte-for-byte. Compression mode may |
| 257 | still retain a smaller original payload. *fallback_bytes* carries those |
| 258 | raw on-disk bytes. |
| 259 | """ |
| 260 | original_mime_type = get_mime_type(src_path.name, fallback_bytes) |
| 261 | if ( |
| 262 | not compress |
| 263 | and fallback_bytes is not None |
| 264 | and original_mime_type in _PIL_FORMAT_BY_MIME |
| 265 | ): |
| 266 | encoded = base64.b64encode(fallback_bytes).decode('ascii') |
| 267 | return ( |
| 268 | f'data:{original_mime_type};base64,{encoded}', |
| 269 | len(fallback_bytes), |
| 270 | ) |
| 271 | |
| 272 | # Match native export: only original JPEG assets stay lossy. PNG remains |
| 273 | # PNG, while BMP/TIFF and other static raster formats become lossless PNG. |
| 274 | mime_type = ( |
| 275 | 'image/jpeg' if original_mime_type == 'image/jpeg' else 'image/png' |
| 276 | ) |
| 277 | pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG') |
| 278 | |
| 279 | # Encode current PIL image |
| 280 | try: |
| 281 | prepared = _normalize_for_save(img, mime_type) |
| 282 | buf = io.BytesIO() |
| 283 | save_kwargs: dict = {'format': pil_format} |
| 284 | if pil_format == 'JPEG': |
| 285 | save_kwargs['quality'] = 95 |
| 286 | save_kwargs['optimize'] = True |
| 287 | elif pil_format == 'PNG': |
| 288 | save_kwargs['optimize'] = True |
| 289 | prepared.save(buf, **save_kwargs) |
| 290 | encoded_bytes = buf.getvalue() |
| 291 | except (OSError, ValueError): |
| 292 | return None |
| 293 | |
| 294 | optimized_bytes = _optimize_image_bytes( |
| 295 | encoded_bytes, mime_type, compress=compress, max_dimension=max_dimension, |
| 296 | ) |
| 297 | |
| 298 | # If the original represents the same uncropped pixels and is smaller, |
| 299 | # retain it instead of inflating an already efficient PNG/JPEG. |
| 300 | chosen = optimized_bytes |
| 301 | if ( |
| 302 | fallback_bytes |
| 303 | and mime_type == original_mime_type |
| 304 | and len(fallback_bytes) < len(optimized_bytes) |
| 305 | ): |
| 306 | chosen = fallback_bytes |
| 307 | |
| 308 | b64 = base64.b64encode(chosen).decode('ascii') |
| 309 | return f'data:{mime_type};base64,{b64}', len(chosen) |
| 310 | |
| 311 | |
| 312 | def _iter_image_elements(root: ET.Element): |
| 313 | """Yield every <image> in the tree regardless of namespace prefix.""" |
| 314 | for image in root.iter(f'{{{SVG_NS}}}image'): |
| 315 | yield image |
| 316 | # Also catch namespace-stripped trees just in case |
| 317 | for image in root.iter('image'): |
| 318 | yield image |
| 319 | |
| 320 | |
| 321 | def _get_href(image: ET.Element) -> str | None: |
| 322 | """Return the image href, supporting both ``href`` and ``xlink:href``.""" |
| 323 | return image.get('href') or image.get(f'{{{XLINK_NS}}}href') |
| 324 | |
| 325 | |
| 326 | def _set_href(image: ET.Element, value: str) -> None: |
| 327 | """Write the data URI back to whichever href attribute the image used.""" |
| 328 | if image.get(f'{{{XLINK_NS}}}href') is not None: |
| 329 | image.set(f'{{{XLINK_NS}}}href', value) |
| 330 | else: |
| 331 | image.set('href', value) |
| 332 | |
| 333 | |
| 334 | def _nested_crop_image_ids(root: ET.Element) -> set[int]: |
| 335 | """Return child image identities from valid nested crop transports.""" |
| 336 | image_ids: set[int] = set() |
| 337 | for elem in root.iter(f'{{{SVG_NS}}}svg'): |
| 338 | if elem is root: |
| 339 | continue |
| 340 | try: |
| 341 | crop = parse_project_nested_svg_crop(elem) |
| 342 | except ValueError: |
| 343 | continue |
| 344 | image_ids.add(id(crop.image)) |
| 345 | return image_ids |
| 346 | |
| 347 | |
| 348 | def _process_one_image( |
| 349 | image: ET.Element, |
| 350 | svg_dir: Path, |
| 351 | *, |
| 352 | compress: bool, |
| 353 | max_dimension: int | None, |
| 354 | image_scale: float, |
| 355 | preserve_source_pixels: bool, |
| 356 | verbose: bool, |
| 357 | ) -> tuple[bool, str | None]: |
| 358 | """Align (slice/meet) and embed a single <image>. |
| 359 | |
| 360 | Returns ``(processed, error)`` where *processed* is True iff the image |
| 361 | was rewritten and *error* is a short message when something went wrong |
| 362 | (the image is left untouched in that case). |
| 363 | """ |
| 364 | href = _get_href(image) |
| 365 | if not href: |
| 366 | return False, None |
| 367 | if href.lower().startswith('data:'): |
| 368 | payload_error = svg_data_uri_payload_error(href) |
| 369 | if payload_error is not None: |
| 370 | return False, payload_error |
| 371 | return False, None # already inline |
| 372 | |
| 373 | img_path = _resolve_image_path(href, svg_dir) |
| 374 | if img_path is None: |
| 375 | return False, f'unresolved href: {href[:60]}' |
| 376 | |
| 377 | try: |
| 378 | with open(img_path, 'rb') as fh: |
| 379 | raw_bytes = fh.read() |
| 380 | except OSError as exc: |
| 381 | return False, f'read failed: {exc}' |
| 382 | |
| 383 | if img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS: |
| 384 | if verbose: |
| 385 | print(f' [INFO] {img_path.name}: Office vector left external for native PPTX passthrough') |
| 386 | return False, None |
| 387 | |
| 388 | if _is_svg_image(img_path, raw_bytes): |
| 389 | payload_error = svg_image_payload_error(raw_bytes) |
| 390 | if payload_error is not None: |
| 391 | return False, f'{img_path.name}: {payload_error}' |
| 392 | _embed_raw_image(image, img_path, raw_bytes) |
| 393 | if verbose: |
| 394 | print(f' [OK] {img_path.name} (svg, embedded as-is)') |
| 395 | return True, None |
| 396 | |
| 397 | img = _load_pil_image(img_path) |
| 398 | if img is None: |
| 399 | return False, 'PIL open failed' |
| 400 | |
| 401 | # Multi-frame images (animated GIF / WebP / APNG): every PIL transform |
| 402 | # and re-save below operates on frame 0 only, silently flattening the |
| 403 | # animation — and the "original bytes are smaller" fallback never fires |
| 404 | # because one frame is always smaller than all frames. Embed the raw |
| 405 | # bytes untouched and keep the geometry attributes (including |
| 406 | # preserveAspectRatio, which the native converter maps to srcRect |
| 407 | # non-destructively). Animated assets skip re-encode, resize, and the |
| 408 | # size cap. |
| 409 | if getattr(img, 'is_animated', False): |
| 410 | _embed_raw_image(image, img_path, raw_bytes) |
| 411 | if max_dimension and max(img.size) > max_dimension: |
| 412 | print(f' [WARN] {img_path.name}: animated image kept as-is ' |
| 413 | f'({img.size[0]}x{img.size[1]} exceeds max dimension ' |
| 414 | f'{max_dimension}px); animations are exempt from size limits') |
| 415 | if verbose: |
| 416 | print(f' [OK] {img_path.name} (animated, embedded as-is)') |
| 417 | return True, None |
| 418 | |
| 419 | geometry_normalized = _has_exif_geometry_transform(img) |
| 420 | img = _prepare_raster_for_geometry(img) |
| 421 | box_x = _parse_float(image.get('x')) |
| 422 | box_y = _parse_float(image.get('y')) |
| 423 | box_w = _parse_float(image.get('width')) |
| 424 | box_h = _parse_float(image.get('height')) |
| 425 | if ( |
| 426 | not math.isfinite(box_w) |
| 427 | or not math.isfinite(box_h) |
| 428 | or box_w <= 0 |
| 429 | or box_h <= 0 |
| 430 | ): |
| 431 | return False, 'zero-sized box' |
| 432 | |
| 433 | par_attr = image.get('preserveAspectRatio') or '' |
| 434 | par_attr = par_attr.strip() |
| 435 | |
| 436 | # ------------------------------------------------------------------ |
| 437 | # Decide the spatial transform |
| 438 | # ------------------------------------------------------------------ |
| 439 | final_img: 'PILImage' = img |
| 440 | new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h |
| 441 | transformed = geometry_normalized |
| 442 | target_box_w, target_box_h = box_w, box_h |
| 443 | preserve_stretch = False |
| 444 | preserve_slice = False |
| 445 | |
| 446 | if not par_attr: |
| 447 | # No preserveAspectRatio at all. The previous pipeline's fix-aspect |
| 448 | # step assumed "xMidYMid meet" here, which silently re-fit images |
| 449 | # that crop-images had already shaped. Treat absence as "leave it |
| 450 | # alone": embed bytes, keep box. |
| 451 | pass |
| 452 | else: |
| 453 | align, mode = parse_preserve_aspect_ratio(par_attr) |
| 454 | if align == 'none': |
| 455 | # Author wants stretch-to-box; preserve geometry, embed bytes. |
| 456 | preserve_stretch = True |
| 457 | elif mode == 'slice': |
| 458 | x_anchor, y_anchor = get_crop_anchor(align) |
| 459 | cropped_img = crop_image_to_size( |
| 460 | img, box_w, box_h, x_anchor, y_anchor |
| 461 | ) |
| 462 | final_img = cropped_img |
| 463 | transformed = True |
| 464 | preserve_slice = not math.isclose( |
| 465 | cropped_img.size[0] / cropped_img.size[1], |
| 466 | box_w / box_h, |
| 467 | rel_tol=1e-6, |
| 468 | abs_tol=1e-9, |
| 469 | ) |
| 470 | else: # meet (or any other mode → treat as meet) |
| 471 | new_w_calc, new_h_calc, off_x, off_y = calculate_fitted_dimensions( |
| 472 | img.size[0], img.size[1], box_w, box_h, mode='meet', |
| 473 | ) |
| 474 | new_x = box_x + off_x |
| 475 | new_y = box_y + off_y |
| 476 | new_w = new_w_calc |
| 477 | new_h = new_h_calc |
| 478 | target_box_w, target_box_h = new_w, new_h |
| 479 | |
| 480 | if preserve_source_pixels: |
| 481 | # The child's 1×1 box is source-unit crop geometry, not its rendered |
| 482 | # frame. Keep the full source raster so the outer viewport remains |
| 483 | # authoritative and independently shaped crops do not lose detail. |
| 484 | target_w, target_h = final_img.size |
| 485 | else: |
| 486 | target_w, target_h = _target_size( |
| 487 | target_box_w, |
| 488 | target_box_h, |
| 489 | max_dimension=max_dimension, |
| 490 | image_scale=image_scale, |
| 491 | ) |
| 492 | final_img, resized = _downscale_to_target(final_img, target_w, target_h) |
| 493 | transformed = transformed or resized |
| 494 | |
| 495 | # ------------------------------------------------------------------ |
| 496 | # Encode and rewrite |
| 497 | # ------------------------------------------------------------------ |
| 498 | encoded = _encode_pil_to_data_uri( |
| 499 | final_img, |
| 500 | img_path, |
| 501 | compress=compress, |
| 502 | max_dimension=None if preserve_source_pixels else max_dimension, |
| 503 | fallback_bytes=raw_bytes if not transformed else None, |
| 504 | ) |
| 505 | if encoded is None: |
| 506 | return False, 'encode failed' |
| 507 | data_uri, _ = encoded |
| 508 | |
| 509 | _set_href(image, data_uri) |
| 510 | image.set('x', _format_number(new_x)) |
| 511 | image.set('y', _format_number(new_y)) |
| 512 | image.set('width', _format_number(new_w)) |
| 513 | image.set('height', _format_number(new_h)) |
| 514 | if preserve_stretch: |
| 515 | image.set('preserveAspectRatio', 'none') |
| 516 | elif preserve_slice: |
| 517 | image.set('preserveAspectRatio', par_attr) |
| 518 | elif 'preserveAspectRatio' in image.attrib: |
| 519 | del image.attrib['preserveAspectRatio'] |
| 520 | |
| 521 | if verbose: |
| 522 | if preserve_source_pixels: |
| 523 | suffix = ' (nested crop, source pixels preserved)' |
| 524 | else: |
| 525 | suffix = ' (cropped)' if transformed else '' |
| 526 | print(f' [OK] {img_path.name}{suffix}') |
| 527 | return True, None |
| 528 | |
| 529 | |
| 530 | def count_office_vector_refs_in_svg(svg_path: str | Path) -> int: |
| 531 | """Count local EMF/WMF image refs that the embed pass intentionally skips.""" |
| 532 | svg_path = Path(svg_path) |
| 533 | svg_dir = svg_path.parent.resolve() |
| 534 | try: |
| 535 | tree = ET.parse(svg_path) |
| 536 | except ET.ParseError: |
| 537 | return 0 |
| 538 | count = 0 |
| 539 | seen: set[int] = set() |
| 540 | for image in _iter_image_elements(tree.getroot()): |
| 541 | ident = id(image) |
| 542 | if ident in seen: |
| 543 | continue |
| 544 | seen.add(ident) |
| 545 | href = _get_href(image) |
| 546 | if not href or href.startswith('data:'): |
| 547 | continue |
| 548 | img_path = _resolve_image_path(href, svg_dir) |
| 549 | if img_path and img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS: |
| 550 | count += 1 |
| 551 | return count |
| 552 | |
| 553 | |
| 554 | def align_and_embed_images_in_svg( |
| 555 | svg_path: str | Path, |
| 556 | *, |
| 557 | dry_run: bool = False, |
| 558 | verbose: bool = False, |
| 559 | compress: bool = True, |
| 560 | max_dimension: int | None = 2560, |
| 561 | image_scale: float = 2.0, |
| 562 | ) -> tuple[int, int]: |
| 563 | """Run the merged align + embed pass on a single SVG file. |
| 564 | |
| 565 | Returns ``(processed_count, error_count)``. |
| 566 | """ |
| 567 | svg_path = Path(svg_path) |
| 568 | svg_dir = svg_path.parent.resolve() |
| 569 | |
| 570 | # Register namespaces for clean serialization |
| 571 | ET.register_namespace('', SVG_NS) |
| 572 | ET.register_namespace('xlink', XLINK_NS) |
| 573 | |
| 574 | try: |
| 575 | tree = ET.parse(svg_path) |
| 576 | except ET.ParseError as exc: |
| 577 | print( |
| 578 | f' [ERROR] {svg_path.name}: parse failed ({exc})', |
| 579 | file=sys.stderr, |
| 580 | ) |
| 581 | return (0, 1) |
| 582 | root = tree.getroot() |
| 583 | nested_crop_image_ids = _nested_crop_image_ids(root) |
| 584 | |
| 585 | # Avoid double-iteration if an element matches both namespaced and |
| 586 | # bare-tag iteration paths. |
| 587 | seen: set[int] = set() |
| 588 | processed = 0 |
| 589 | errors = 0 |
| 590 | |
| 591 | for image in _iter_image_elements(root): |
| 592 | ident = id(image) |
| 593 | if ident in seen: |
| 594 | continue |
| 595 | seen.add(ident) |
| 596 | |
| 597 | if dry_run: |
| 598 | processed += 1 |
| 599 | continue |
| 600 | |
| 601 | ok, err = _process_one_image( |
| 602 | image, svg_dir, |
| 603 | compress=compress, max_dimension=max_dimension, |
| 604 | image_scale=image_scale, |
| 605 | preserve_source_pixels=ident in nested_crop_image_ids, |
| 606 | verbose=verbose, |
| 607 | ) |
| 608 | if ok: |
| 609 | processed += 1 |
| 610 | elif err: |
| 611 | errors += 1 |
| 612 | print(f' [ERROR] {svg_path.name}: {err}', file=sys.stderr) |
| 613 | |
| 614 | if processed > 0 and errors == 0 and not dry_run: |
| 615 | tree.write(svg_path, encoding='utf-8', xml_declaration=False) |
| 616 | |
| 617 | return (processed, errors) |
| 618 | |
| 619 | |
| 620 | # --------------------------------------------------------------------------- |
| 621 | # Standalone CLI (rare; the main entry point is finalize_svg.py) |
| 622 | # --------------------------------------------------------------------------- |
| 623 | |
| 624 | def build_parser() -> argparse.ArgumentParser: |
| 625 | """Build the standalone diagnostic parser.""" |
| 626 | import argparse |
| 627 | parser = argparse.ArgumentParser( |
| 628 | description='Align (slice/meet) and Base64-embed all <image> refs in an SVG.', |
| 629 | ) |
| 630 | parser.add_argument('svg', type=Path, help='SVG file to process in place') |
| 631 | parser.add_argument('-n', '--dry-run', action='store_true') |
| 632 | parser.add_argument('-v', '--verbose', action='store_true') |
| 633 | parser.add_argument('--compress', dest='compress', action='store_true', default=True, |
| 634 | help='Compress images before embedding (default)') |
| 635 | parser.add_argument('--no-compress', dest='compress', action='store_false', |
| 636 | help='Disable image compression') |
| 637 | parser.add_argument('--max-dimension', type=int, default=2560, |
| 638 | help='Downscale images larger than this on either axis (default: 2560)') |
| 639 | parser.add_argument('--image-scale', type=float, default=2.0, |
| 640 | help='Target pixels per rendered SVG pixel') |
| 641 | return parser |
| 642 | |
| 643 | |
| 644 | def main(argv: list[str] | None = None) -> int: |
| 645 | """Run the standalone diagnostic CLI.""" |
| 646 | parser = build_parser() |
| 647 | args = parser.parse_args(argv) |
| 648 | |
| 649 | if not args.svg.exists(): |
| 650 | print(f'Error: file not found: {args.svg}', file=sys.stderr) |
| 651 | return 1 |
| 652 | if args.max_dimension < 1: |
| 653 | print('Error: --max-dimension must be >= 1', file=sys.stderr) |
| 654 | return 1 |
| 655 | if args.image_scale < 1: |
| 656 | print('Error: --image-scale must be >= 1', file=sys.stderr) |
| 657 | return 1 |
| 658 | |
| 659 | proc, err = align_and_embed_images_in_svg( |
| 660 | args.svg, |
| 661 | dry_run=args.dry_run, |
| 662 | verbose=args.verbose, |
| 663 | compress=args.compress, |
| 664 | max_dimension=args.max_dimension, |
| 665 | image_scale=args.image_scale, |
| 666 | ) |
| 667 | print(f'Processed {proc} image(s), {err} error(s)') |
| 668 | return 1 if err else 0 |
| 669 | |
| 670 | |
| 671 | if __name__ == '__main__': |
| 672 | raise SystemExit(main()) |
| 673 |