673 lines
24 KiB
Python
673 lines
24 KiB
Python
#!/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())
|