Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""svg_finalize — shared self-containment + DrawingML-compat utilities.
|
||||
|
||||
Used by two consumers:
|
||||
|
||||
1. finalize_svg.py — writes svg_output/ → svg_final/ on disk
|
||||
2. svg_to_pptx (use_expander, tspan_flattener) — reuses these modules
|
||||
in memory during native pptx conversion
|
||||
|
||||
Deleting any module here is likely to break native pptx output, not just
|
||||
svg_final/. See scripts/docs/svg-pipeline.md for the full per-module consumer
|
||||
table.
|
||||
"""
|
||||
@@ -0,0 +1,672 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Smart Image Cropping Tool
|
||||
|
||||
Smartly crops images based on the preserveAspectRatio attribute of <image> elements in SVG:
|
||||
- slice: Crop to fill (similar to CSS object-fit: cover)
|
||||
- meet: Display fully without cropping (similar to CSS object-fit: contain)
|
||||
|
||||
Supports 9 alignment modes:
|
||||
- xMinYMin / xMidYMin / xMaxYMin (top alignment)
|
||||
- xMinYMid / xMidYMid / xMaxYMid (vertical center)
|
||||
- xMinYMax / xMidYMax / xMaxYMax (bottom alignment)
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/crop_images.py <SVG file or directory> [--dry-run]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
from urllib.parse import unquote
|
||||
|
||||
_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
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("Error: PIL (Pillow) is required. Run: pip install Pillow")
|
||||
exit(1)
|
||||
|
||||
|
||||
def parse_preserve_aspect_ratio(attr: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse the preserveAspectRatio attribute.
|
||||
|
||||
Returns: (align, meet_or_slice)
|
||||
align: e.g. 'xMidYMid'
|
||||
meet_or_slice: 'meet' or 'slice'
|
||||
"""
|
||||
if not attr:
|
||||
return ('xMidYMid', 'meet') # Default value
|
||||
|
||||
parts = attr.strip().split()
|
||||
align = parts[0] if parts else 'xMidYMid'
|
||||
meet_or_slice = parts[1] if len(parts) > 1 else 'meet'
|
||||
|
||||
return (align, meet_or_slice)
|
||||
|
||||
|
||||
def get_crop_anchor(align: str) -> tuple[float, float]:
|
||||
"""
|
||||
Return the crop anchor point based on the align value.
|
||||
|
||||
Returns: (x_anchor, y_anchor)
|
||||
x_anchor: 0.0 (left), 0.5 (center), 1.0 (right)
|
||||
y_anchor: 0.0 (top), 0.5 (center), 1.0 (bottom)
|
||||
"""
|
||||
x_map = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0}
|
||||
y_map = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0}
|
||||
|
||||
x_anchor = 0.5
|
||||
y_anchor = 0.5
|
||||
|
||||
for key, val in x_map.items():
|
||||
if key in align:
|
||||
x_anchor = val
|
||||
break
|
||||
|
||||
for key, val in y_map.items():
|
||||
if key in align:
|
||||
y_anchor = val
|
||||
break
|
||||
|
||||
return (x_anchor, y_anchor)
|
||||
|
||||
|
||||
def crop_image_to_size(
|
||||
img: Image.Image,
|
||||
target_width: float,
|
||||
target_height: float,
|
||||
x_anchor: float = 0.5,
|
||||
y_anchor: float = 0.5,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Crop an image to the target aspect ratio, preserving original resolution (no scaling).
|
||||
|
||||
New logic: Only crops the original image to the target aspect ratio without any scaling,
|
||||
thus preserving the original resolution and clarity.
|
||||
|
||||
Args:
|
||||
img: PIL Image object
|
||||
target_width: Target width (used to calculate ratio)
|
||||
target_height: Target height (used to calculate ratio)
|
||||
x_anchor: Horizontal anchor (0=left, 0.5=center, 1=right)
|
||||
y_anchor: Vertical anchor (0=top, 0.5=center, 1=bottom)
|
||||
|
||||
Returns:
|
||||
Cropped PIL Image object (preserving original resolution)
|
||||
"""
|
||||
img_width, img_height = img.size
|
||||
if img_width <= 0 or img_height <= 0:
|
||||
raise ValueError('source image dimensions must be positive')
|
||||
if target_width <= 0 or target_height <= 0:
|
||||
raise ValueError('target image dimensions must be positive')
|
||||
|
||||
# Calculate target aspect ratio
|
||||
target_ratio = target_width / target_height
|
||||
img_ratio = img_width / img_height
|
||||
|
||||
# Calculate crop region on the original image based on ratio (no scaling)
|
||||
if img_ratio > target_ratio:
|
||||
# Original image is wider; crop left and right sides
|
||||
crop_height = img_height
|
||||
crop_width = max(1, min(img_width, int(round(img_height * target_ratio))))
|
||||
else:
|
||||
# Original image is taller; crop top and bottom sides
|
||||
crop_width = img_width
|
||||
crop_height = max(1, min(img_height, int(round(img_width / target_ratio))))
|
||||
|
||||
# Calculate crop position based on anchor point
|
||||
extra_width = img_width - crop_width
|
||||
extra_height = img_height - crop_height
|
||||
|
||||
left = int(extra_width * x_anchor)
|
||||
top = int(extra_height * y_anchor)
|
||||
right = left + crop_width
|
||||
bottom = top + crop_height
|
||||
|
||||
# Crop only, no scaling
|
||||
return img.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def process_svg_images(
|
||||
svg_file: str,
|
||||
output_dir: str | Path | None = None,
|
||||
dry_run: bool = False,
|
||||
verbose: bool = True,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Process images in an SVG file, cropping based on the preserveAspectRatio attribute.
|
||||
|
||||
Args:
|
||||
svg_file: SVG file path
|
||||
output_dir: Output directory for cropped images (default: images/cropped/)
|
||||
dry_run: Preview only, no actual processing
|
||||
verbose: Verbose output
|
||||
|
||||
Returns:
|
||||
(processed_count, error_count)
|
||||
"""
|
||||
svg_path = Path(svg_file)
|
||||
svg_dir = svg_path.parent
|
||||
|
||||
# Default output directory
|
||||
if output_dir is None:
|
||||
# Find the project's images directory
|
||||
# Parent directory of svg_output or svg_final, under images
|
||||
project_dir = svg_dir.parent
|
||||
output_dir = project_dir / 'images' / 'cropped'
|
||||
else:
|
||||
output_dir = Path(output_dir)
|
||||
|
||||
# Parse SVG
|
||||
try:
|
||||
ET.register_namespace('', 'http://www.w3.org/2000/svg')
|
||||
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
|
||||
tree = ET.parse(str(svg_path))
|
||||
root = tree.getroot()
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" [ERROR] Failed to parse SVG: {e}")
|
||||
return (0, 1)
|
||||
|
||||
ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
|
||||
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
modified = False
|
||||
|
||||
# Find all image elements
|
||||
for image in root.iter('{http://www.w3.org/2000/svg}image'):
|
||||
# Get href attribute
|
||||
href = image.get('{http://www.w3.org/1999/xlink}href') or image.get('href')
|
||||
if not href:
|
||||
continue
|
||||
|
||||
# Skip Base64 inline images
|
||||
if href.startswith('data:'):
|
||||
continue
|
||||
|
||||
# Get preserveAspectRatio attribute
|
||||
par = image.get('preserveAspectRatio', '')
|
||||
align, mode = parse_preserve_aspect_ratio(par)
|
||||
|
||||
# Only process slice mode
|
||||
if mode != 'slice':
|
||||
continue
|
||||
|
||||
# Get target dimensions
|
||||
try:
|
||||
target_width = int(float(image.get('width', 0)))
|
||||
target_height = int(float(image.get('height', 0)))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if target_width <= 0 or target_height <= 0:
|
||||
continue
|
||||
|
||||
# Parse image path
|
||||
href_decoded = unquote(href)
|
||||
if href_decoded.startswith('../'):
|
||||
img_path = (svg_dir / href_decoded).resolve()
|
||||
else:
|
||||
img_path = (svg_dir / href_decoded).resolve()
|
||||
|
||||
if not img_path.exists():
|
||||
if verbose:
|
||||
print(f" [SKIP] Image not found: {href}")
|
||||
continue
|
||||
|
||||
# Get crop anchor point
|
||||
x_anchor, y_anchor = get_crop_anchor(align)
|
||||
|
||||
if dry_run:
|
||||
if verbose:
|
||||
print(f" [DRY] {img_path.name} -> {target_width}x{target_height} "
|
||||
f"(align: {align}, anchor: {x_anchor},{y_anchor})")
|
||||
processed_count += 1
|
||||
continue
|
||||
|
||||
# Create output directory
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Open and process image
|
||||
img = Image.open(img_path)
|
||||
output_is_png = img_path.suffix.lower() == '.png'
|
||||
|
||||
# Preserve alpha for PNG assets such as translucent overlays.
|
||||
if output_is_png:
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
elif img.mode not in ('RGBA', 'LA', 'RGB', 'L'):
|
||||
img = img.convert('RGBA' if 'A' in img.getbands() else 'RGB')
|
||||
else:
|
||||
if img.mode in ('RGBA', 'LA'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
alpha = img.getchannel('A')
|
||||
background.paste(img.convert('RGB'), mask=alpha)
|
||||
img = background
|
||||
elif img.mode == 'P':
|
||||
img = img.convert('RGB')
|
||||
elif img.mode not in ('RGB', 'L'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Crop
|
||||
cropped = crop_image_to_size(img, target_width, target_height, x_anchor, y_anchor)
|
||||
|
||||
# Generate output filename (keep original name, place in cropped directory)
|
||||
output_filename = img_path.name
|
||||
output_path = output_dir / output_filename
|
||||
|
||||
# Save
|
||||
if output_is_png:
|
||||
cropped.save(output_path, 'PNG', optimize=True)
|
||||
else:
|
||||
cropped.save(output_path, 'JPEG', quality=90, optimize=True)
|
||||
|
||||
if verbose:
|
||||
print(f" [OK] {img_path.name}: {img.size} -> {target_width}x{target_height} "
|
||||
f"({align})")
|
||||
|
||||
# Update image path in SVG
|
||||
new_href = f"../images/cropped/{output_filename}"
|
||||
if image.get('{http://www.w3.org/1999/xlink}href'):
|
||||
image.set('{http://www.w3.org/1999/xlink}href', new_href)
|
||||
else:
|
||||
image.set('href', new_href)
|
||||
|
||||
# Remove preserveAspectRatio (image is now correctly sized)
|
||||
if 'preserveAspectRatio' in image.attrib:
|
||||
del image.attrib['preserveAspectRatio']
|
||||
|
||||
modified = True
|
||||
processed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" [ERROR] {img_path.name}: {e}")
|
||||
error_count += 1
|
||||
|
||||
# Save modified SVG
|
||||
if modified and not dry_run:
|
||||
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
|
||||
|
||||
return (processed_count, error_count)
|
||||
|
||||
|
||||
def process_directory(directory: str, dry_run: bool = False, verbose: bool = True) -> tuple[int, int]:
|
||||
"""Process all SVG files in a directory."""
|
||||
directory_path = Path(directory)
|
||||
total_processed = 0
|
||||
total_errors = 0
|
||||
|
||||
for svg_file in directory_path.glob('*.svg'):
|
||||
if verbose:
|
||||
print(f" Processing: {svg_file.name}")
|
||||
processed, errors = process_svg_images(str(svg_file), dry_run=dry_run, verbose=verbose)
|
||||
total_processed += processed
|
||||
total_errors += errors
|
||||
|
||||
return (total_processed, total_errors)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master - Smart Image Cropping Tool',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
%(prog)s projects/my_project/svg_output
|
||||
%(prog)s page_01.svg --dry-run
|
||||
|
||||
preserveAspectRatio usage:
|
||||
xMidYMid slice Center crop (default)
|
||||
xMidYMin slice Keep top
|
||||
xMidYMax slice Keep bottom
|
||||
xMinYMid slice Keep left
|
||||
xMaxYMid slice Keep right
|
||||
xMidYMid meet Display fully, no cropping
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument('path', type=Path, help='SVG file or directory')
|
||||
parser.add_argument('--dry-run', '-n', action='store_true', help='Preview only, no actual processing')
|
||||
parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.path.exists():
|
||||
print(f"[ERROR] Path not found: {args.path}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PPT Master - Smart Image Cropping")
|
||||
print("=" * 50)
|
||||
|
||||
if args.path.is_file():
|
||||
processed, errors = process_svg_images(str(args.path), dry_run=args.dry_run,
|
||||
verbose=not args.quiet)
|
||||
else:
|
||||
processed, errors = process_directory(str(args.path), dry_run=args.dry_run,
|
||||
verbose=not args.quiet)
|
||||
|
||||
print()
|
||||
print(f"Done: {processed} image(s) cropped, {errors} error(s)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,564 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SVG Icon Embedding Tool
|
||||
|
||||
Replaces icon placeholders in SVG files with actual icon code.
|
||||
|
||||
Placeholder syntax (new SVGs must include a library prefix):
|
||||
<use data-icon="chunk-filled/rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/>
|
||||
<use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
|
||||
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
|
||||
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8" stroke-width="3"/>
|
||||
<use data-icon="imported/layered_slide_06_ill01"/>
|
||||
|
||||
Legacy compatibility accepted by the resolver:
|
||||
<use data-icon="rocket" .../> -> chunk-filled/rocket
|
||||
<use data-icon="chunk/rocket" .../> -> chunk-filled/rocket
|
||||
|
||||
Optional `stroke-width` (stroke-style libraries only — e.g. tabler-outline):
|
||||
Default 2 (matches the source). Pass 1.5 for thin, 3 for bold.
|
||||
Ignored on fill-style libraries.
|
||||
|
||||
After replacement:
|
||||
<g transform="translate(100, 200) scale(3)" fill="#0076A8">
|
||||
<path d="..."/>
|
||||
</g>
|
||||
|
||||
Icon libraries (subdirectories of templates/icons/):
|
||||
chunk-filled/ - 640+ fill icons, 16x16 viewBox (use prefix: chunk-filled/name; legacy 'chunk/' also accepted)
|
||||
tabler-filled/ - 1000+ fill icons, 24x24 viewBox (use prefix: tabler-filled/name)
|
||||
tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name)
|
||||
phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate)
|
||||
simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library — used alongside the chosen primary library, NOT as a standalone library for generic icons)
|
||||
imported/ - project-local extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/embed_icons.py <svg_file> [svg_file2] ...
|
||||
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
|
||||
|
||||
Options:
|
||||
--icons-dir <path> Icon directory path (default: templates/icons/)
|
||||
--dry-run Only show what would be replaced, without modifying files
|
||||
--verbose Show detailed information
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
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 svg_to_pptx.drawingml.utils import parse_project_geometry_length # noqa: E402
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
# Default icon directory
|
||||
DEFAULT_ICONS_DIR = Path(__file__).parent.parent.parent / 'templates' / 'icons'
|
||||
|
||||
# Icon base size per library
|
||||
ICON_BASE_SIZES = {
|
||||
'chunk-filled': 16,
|
||||
'chunk': 16, # backward compat alias → chunk-filled/
|
||||
'tabler-filled': 24,
|
||||
'tabler-outline': 24,
|
||||
'phosphor-duotone': 256,
|
||||
'simple-icons': 24,
|
||||
}
|
||||
_ICON_LIBRARY_ALIASES = {'chunk': 'chunk-filled'}
|
||||
DEFAULT_ICON_BASE_SIZE = 24
|
||||
BaseGeometry = float | tuple[float, float, float, float]
|
||||
|
||||
|
||||
def _get_viewbox_size(content: str) -> float:
|
||||
"""Extract the width from viewBox attribute (assumed square). Returns 0 if not found."""
|
||||
m = re.search(r'viewBox=["\']0 0 ([\d.]+)', content)
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
def _get_viewbox_geometry(content: str) -> tuple[float, float, float, float] | None:
|
||||
"""Extract full viewBox geometry as (min_x, min_y, width, height)."""
|
||||
match = re.search(r'viewBox=["\']([^"\']+)["\']', content)
|
||||
if not match:
|
||||
return None
|
||||
parts = re.split(r'[\s,]+', match.group(1).strip())
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
try:
|
||||
min_x, min_y, width, height = [float(part) for part in parts[:4]]
|
||||
except ValueError:
|
||||
return None
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return min_x, min_y, width, height
|
||||
|
||||
|
||||
def _format_number(value: object) -> str:
|
||||
"""Format SVG numeric values compactly without losing meaningful precision."""
|
||||
if isinstance(value, float):
|
||||
return f'{value:g}'
|
||||
return str(value)
|
||||
|
||||
|
||||
def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]:
|
||||
"""Normalize legacy square icon size and full viewBox geometry."""
|
||||
if isinstance(base_size, tuple):
|
||||
return base_size
|
||||
return 0.0, 0.0, float(base_size), float(base_size)
|
||||
|
||||
|
||||
def _is_preserve_color_asset(content: str) -> bool:
|
||||
"""Project illustrations are vector assets, not recolorable monochrome icons.
|
||||
|
||||
The `data-icon-style="preserve-color"` marker is stamped by
|
||||
extract_svg_assets.py and is the single source of truth — hand-authored
|
||||
multi-color assets must carry it to keep their colors and aspect ratio.
|
||||
"""
|
||||
return 'data-icon-style="preserve-color"' in content
|
||||
|
||||
|
||||
def _detect_icon_style(content: str) -> str:
|
||||
"""Detect whether an icon is fill-based or stroke-based."""
|
||||
# stroke="currentColor" with fill="none" → stroke style
|
||||
if 'stroke="currentColor"' in content and 'fill="none"' in content:
|
||||
return 'stroke'
|
||||
return 'fill'
|
||||
|
||||
|
||||
def _extract_svg_body(content: str) -> list[str]:
|
||||
"""Return the root SVG body for preserve-color assets without editing attrs."""
|
||||
match = re.search(r'<svg\b[^>]*>(.*)</svg>\s*$', content, re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
body = match.group(1).strip()
|
||||
return [body] if body else []
|
||||
|
||||
|
||||
def _extract_shape_elements(content: str, color: str) -> list[str]:
|
||||
"""
|
||||
Extract all drawable shape elements from an icon SVG, replacing
|
||||
fill/stroke color references (currentColor or #xxxxxx) with the target color.
|
||||
|
||||
Supports: <path>, <circle>, <rect>, <line>, <polyline>, <polygon>, <ellipse>
|
||||
"""
|
||||
shape_tags = ('path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse')
|
||||
pattern = r'<(' + '|'.join(shape_tags) + r')(\s[^>]*)?(?:/>|></\1>)'
|
||||
matches = re.findall(pattern, content, re.DOTALL)
|
||||
|
||||
elements = []
|
||||
for tag, attrs in matches:
|
||||
# Remove standalone fill/stroke color attrs so outer <g> controls color.
|
||||
# Also strip stroke-width so the outer <g> can override it (otherwise the
|
||||
# icon's source stroke-width="2" would shadow any caller-specified value).
|
||||
attrs_clean = re.sub(r'\s*fill="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs)
|
||||
attrs_clean = re.sub(r'\s*stroke="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs_clean)
|
||||
attrs_clean = re.sub(r'\s*stroke-width="[^"]*"', '', attrs_clean)
|
||||
elements.append(f'<{tag}{attrs_clean}/>')
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
|
||||
"""Resolve `icon_name` against a single icons dir (no fallback)."""
|
||||
if '/' in icon_name:
|
||||
lib, name = icon_name.split('/', 1)
|
||||
lib = _ICON_LIBRARY_ALIASES.get(lib, lib) # resolve aliases
|
||||
icon_path = icons_dir / lib / f'{name}.svg'
|
||||
base_size = ICON_BASE_SIZES.get(lib, 24)
|
||||
else:
|
||||
# Backward compatibility: un-prefixed names fall back to legacy chunk-filled/ library
|
||||
icon_path = icons_dir / 'chunk-filled' / f'{icon_name}.svg'
|
||||
base_size = 16
|
||||
if not icon_path.exists():
|
||||
icon_path = icons_dir / f'{icon_name}.svg' # legacy flat layout
|
||||
base_size = 16
|
||||
|
||||
return icon_path, base_size
|
||||
|
||||
|
||||
def _casefold_icon_name_in_dir(icon_name: str, icons_dir: Path) -> str | None:
|
||||
"""Return the exact on-disk identifier when only casing differs."""
|
||||
if not icons_dir.is_dir():
|
||||
return None
|
||||
|
||||
search_dirs: list[Path] = []
|
||||
expected_name = icon_name
|
||||
if '/' in icon_name:
|
||||
raw_lib, expected_name = icon_name.split('/', 1)
|
||||
requested_lib = _ICON_LIBRARY_ALIASES.get(raw_lib.casefold(), raw_lib)
|
||||
library_dir = icons_dir / requested_lib
|
||||
if not library_dir.is_dir():
|
||||
library_dir = next(
|
||||
(
|
||||
path for path in icons_dir.iterdir()
|
||||
if path.is_dir()
|
||||
and path.name.casefold() == requested_lib.casefold()
|
||||
),
|
||||
library_dir,
|
||||
)
|
||||
search_dirs.append(library_dir)
|
||||
else:
|
||||
search_dirs.extend((icons_dir / 'chunk-filled', icons_dir))
|
||||
|
||||
expected_filename = f'{expected_name}.svg'.casefold()
|
||||
for search_dir in search_dirs:
|
||||
if not search_dir.is_dir():
|
||||
continue
|
||||
matches = sorted(
|
||||
path for path in search_dir.iterdir()
|
||||
if path.is_file()
|
||||
and path.suffix.casefold() == '.svg'
|
||||
and path.name.casefold() == expected_filename
|
||||
)
|
||||
if len(matches) != 1:
|
||||
continue
|
||||
relative = matches[0].relative_to(icons_dir).with_suffix('')
|
||||
return relative.as_posix()
|
||||
return None
|
||||
|
||||
|
||||
def suggest_icon_name(
|
||||
icon_name: str,
|
||||
icons_dir: Path,
|
||||
fallback_dir: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Suggest an exact project-first icon identifier without auto-correcting it."""
|
||||
suggestion = _casefold_icon_name_in_dir(icon_name, icons_dir)
|
||||
if suggestion is None and fallback_dir is not None:
|
||||
suggestion = _casefold_icon_name_in_dir(icon_name, fallback_dir)
|
||||
return suggestion
|
||||
|
||||
|
||||
def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None = None) -> tuple[Path, float]:
|
||||
"""
|
||||
Resolve icon name to file path and base size, e.g. "chunk-filled/home" →
|
||||
icons_dir/chunk-filled/home.svg. "chunk/" is a backward-compat alias; an
|
||||
un-prefixed name falls back to chunk-filled/ then a legacy flat layout.
|
||||
|
||||
Resolution is project-first: if the icon is absent under ``icons_dir`` and a
|
||||
``fallback_dir`` (the global library) is given, the fallback's path is
|
||||
returned instead. Returns (path, base_size); the path may not exist when
|
||||
neither dir has the icon.
|
||||
"""
|
||||
icon_path, base_size = _resolve_in_dir(icon_name, icons_dir)
|
||||
if fallback_dir is not None and not icon_path.exists():
|
||||
fb_path, fb_size = _resolve_in_dir(icon_name, fallback_dir)
|
||||
if fb_path.exists():
|
||||
return fb_path, fb_size
|
||||
return icon_path, base_size
|
||||
|
||||
|
||||
def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, BaseGeometry]:
|
||||
"""
|
||||
Extract drawable elements from an icon SVG file.
|
||||
|
||||
Returns:
|
||||
(elements, style, base_size)
|
||||
style: 'fill', 'stroke', or 'preserve'
|
||||
base_size: square icon size, or full viewBox geometry for preserve assets
|
||||
"""
|
||||
if not icon_path.exists():
|
||||
return [], 'fill', 16
|
||||
|
||||
content = icon_path.read_text(encoding='utf-8')
|
||||
if _is_preserve_color_asset(content):
|
||||
geometry = _get_viewbox_geometry(content) or (0.0, 0.0, DEFAULT_ICON_BASE_SIZE, DEFAULT_ICON_BASE_SIZE)
|
||||
elements = _extract_svg_body(content)
|
||||
return elements, 'preserve', geometry
|
||||
|
||||
style = _detect_icon_style(content)
|
||||
base_size = _get_viewbox_size(content) or 16
|
||||
elements = _extract_shape_elements(content, target_color)
|
||||
return elements, style, base_size
|
||||
|
||||
|
||||
def _attr_value(tag_text: str, attr: str) -> str | None:
|
||||
"""Return an attribute value from a raw tag, accepting either quote style."""
|
||||
match = re.search(
|
||||
rf'\b{re.escape(attr)}\s*=\s*(["\'])(.*?)\1',
|
||||
tag_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
return match.group(2) if match else None
|
||||
|
||||
|
||||
def parse_use_element(use_match: str) -> dict[str, str | float]:
|
||||
"""
|
||||
Parse attributes of a use element.
|
||||
|
||||
Args:
|
||||
use_match: Complete string of the use element
|
||||
|
||||
Returns:
|
||||
Attribute dictionary
|
||||
"""
|
||||
attrs: dict[str, str | float] = {}
|
||||
|
||||
# Extract data-icon
|
||||
icon_value = _attr_value(use_match, 'data-icon')
|
||||
if icon_value:
|
||||
attrs['icon'] = icon_value
|
||||
|
||||
# Extract numeric attributes
|
||||
for attr in ['x', 'y', 'width', 'height']:
|
||||
value = _attr_value(use_match, attr)
|
||||
if value is not None:
|
||||
attrs[attr] = parse_project_geometry_length(value, attr)
|
||||
|
||||
# Extract fill color
|
||||
fill_value = _attr_value(use_match, 'fill')
|
||||
if fill_value is not None:
|
||||
attrs['fill'] = fill_value
|
||||
|
||||
# Stroke-style icons may be authored with natural SVG semantics:
|
||||
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
|
||||
# carrier, but preserve stroke so outline icons do not collapse to none.
|
||||
stroke_value = _attr_value(use_match, 'stroke')
|
||||
if stroke_value is not None:
|
||||
attrs['stroke'] = stroke_value
|
||||
|
||||
# Live preview direct edits may write an absolute transform matrix back to
|
||||
# the placeholder. Preserve it so the expanded icon matches the edited
|
||||
# browser geometry instead of falling back to the original x/y placement.
|
||||
transform_value = _attr_value(use_match, 'transform')
|
||||
if transform_value is not None:
|
||||
attrs['transform'] = transform_value
|
||||
|
||||
# Extract optional stroke-width override (stroke-style icons only).
|
||||
# Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold.
|
||||
stroke_width_value = _attr_value(use_match, 'stroke-width')
|
||||
if stroke_width_value is not None:
|
||||
attrs['stroke-width'] = stroke_width_value
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
|
||||
"""Resolve the caller-provided color for fill or stroke icon libraries."""
|
||||
if style == 'preserve':
|
||||
return 'preserve'
|
||||
|
||||
fill = str(attrs.get('fill', '')).strip()
|
||||
stroke = str(attrs.get('stroke', '')).strip()
|
||||
|
||||
if style == 'stroke':
|
||||
if fill and fill != 'none':
|
||||
return fill
|
||||
if stroke and stroke != 'none':
|
||||
return stroke
|
||||
return '#000000'
|
||||
|
||||
if fill:
|
||||
return fill
|
||||
if stroke and stroke != 'none':
|
||||
return stroke
|
||||
return '#000000'
|
||||
|
||||
|
||||
def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: BaseGeometry) -> str:
|
||||
"""
|
||||
Generate the icon's <g> element.
|
||||
|
||||
Args:
|
||||
attrs: Attributes of the use element
|
||||
elements: List of drawable SVG elements
|
||||
style: 'fill', 'stroke', or 'preserve'
|
||||
base_size: Icon's natural size, or full viewBox geometry for preserve assets
|
||||
|
||||
Returns:
|
||||
Complete <g> element string
|
||||
"""
|
||||
min_x, min_y, base_width, base_height = _base_geometry(base_size)
|
||||
x = attrs.get('x', 0)
|
||||
y = attrs.get('y', 0)
|
||||
width = attrs.get('width', base_width)
|
||||
height = attrs.get('height', base_height)
|
||||
color = resolve_icon_color(attrs, style)
|
||||
icon_name = attrs.get('icon', 'unknown')
|
||||
|
||||
scale_x = float(width) / base_width
|
||||
scale_y = float(height) / base_height
|
||||
|
||||
if attrs.get('transform'):
|
||||
# This transform is authoritative: the editor computes it from the
|
||||
# expanded <g>, so composing it with x/y would apply placement twice.
|
||||
transform = str(attrs['transform'])
|
||||
elif abs(scale_x - 1) < 1e-6 and abs(scale_y - 1) < 1e-6:
|
||||
transform = f'translate({_format_number(x)}, {_format_number(y)})'
|
||||
elif abs(scale_x - scale_y) < 1e-6:
|
||||
transform = f'translate({_format_number(x)}, {_format_number(y)}) scale({_format_number(scale_x)})'
|
||||
else:
|
||||
transform = (
|
||||
f'translate({_format_number(x)}, {_format_number(y)}) '
|
||||
f'scale({_format_number(scale_x)}, {_format_number(scale_y)})'
|
||||
)
|
||||
|
||||
elements_str = '\n '.join(elements)
|
||||
|
||||
if style == 'preserve':
|
||||
if min_x or min_y:
|
||||
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})'
|
||||
elements_str = f'<g transform="{inner_transform}">\n {elements_str}\n </g>'
|
||||
return f'''<!-- icon: {icon_name} -->
|
||||
<g transform="{transform}">
|
||||
{elements_str}
|
||||
</g>'''
|
||||
|
||||
if style == 'stroke':
|
||||
# Default to 2 — matches the source stroke-width baked into tabler-outline
|
||||
# (and any other stroke library) so omitting the attribute reproduces
|
||||
# pre-change visual output.
|
||||
stroke_width = attrs.get('stroke-width', '2')
|
||||
color_attrs = f'fill="none" stroke="{color}" stroke-width="{stroke_width}"'
|
||||
else:
|
||||
color_attrs = f'fill="{color}"'
|
||||
|
||||
return f'''<!-- icon: {icon_name} -->
|
||||
<g transform="{transform}" {color_attrs}>
|
||||
{elements_str}
|
||||
</g>'''
|
||||
|
||||
|
||||
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False, fallback_dir: Path | None = None) -> int:
|
||||
"""
|
||||
Process a single SVG file, replacing all icon placeholders.
|
||||
|
||||
Args:
|
||||
svg_path: SVG file path
|
||||
icons_dir: Icon directory path
|
||||
dry_run: Whether to only preview without modifying
|
||||
verbose: Whether to show detailed information
|
||||
|
||||
Returns:
|
||||
Number of icons replaced
|
||||
"""
|
||||
if not svg_path.exists():
|
||||
print(f"[ERROR] File not found: {svg_path}")
|
||||
return 0
|
||||
|
||||
content = svg_path.read_text(encoding='utf-8')
|
||||
|
||||
# Match self-closing <use data-icon="..."/> placeholders. Attribute
|
||||
# parsing below accepts both single and double quotes.
|
||||
use_pattern = r'<use\b(?=[^>]*\bdata-icon\s*=)[^>]*/>'
|
||||
matches = list(re.finditer(use_pattern, content, re.IGNORECASE | re.DOTALL))
|
||||
|
||||
if not matches:
|
||||
if verbose:
|
||||
print(f"[SKIP] No icon placeholders: {svg_path}")
|
||||
return 0
|
||||
|
||||
replaced_count = 0
|
||||
new_content = content
|
||||
|
||||
# Replace from back to front to avoid position offset
|
||||
for match in reversed(matches):
|
||||
use_str = match.group(0)
|
||||
attrs = parse_use_element(use_str)
|
||||
|
||||
icon_name = attrs.get('icon')
|
||||
if not icon_name:
|
||||
continue
|
||||
|
||||
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir, fallback_dir)
|
||||
if not icon_path.exists():
|
||||
suggestion = suggest_icon_name(str(icon_name), icons_dir, fallback_dir)
|
||||
hint = (
|
||||
f"; identifiers are case-sensitive; use '{suggestion}'"
|
||||
if suggestion else ""
|
||||
)
|
||||
print(
|
||||
f"[WARN] Icon not found: {icon_name}{hint} "
|
||||
f"(in {svg_path.name})"
|
||||
)
|
||||
continue
|
||||
|
||||
elements, style, base_size = extract_paths_from_icon(icon_path)
|
||||
color = resolve_icon_color(attrs, style)
|
||||
if not elements:
|
||||
print(
|
||||
f"[WARN] Icon has no embeddable shapes: {icon_name} "
|
||||
f"(in {svg_path.name})"
|
||||
)
|
||||
continue
|
||||
|
||||
replacement = generate_icon_group(attrs, elements, style, base_size)
|
||||
|
||||
if verbose or dry_run:
|
||||
print(f" [*] {icon_name}: x={attrs.get('x', 0)}, y={attrs.get('y', 0)}, "
|
||||
f"size={attrs.get('width', base_size)}, fill={color}, style={style}")
|
||||
|
||||
new_content = new_content[:match.start()] + replacement + new_content[match.end():]
|
||||
replaced_count += 1
|
||||
|
||||
if not dry_run and replaced_count > 0:
|
||||
svg_path.write_text(new_content, encoding='utf-8')
|
||||
|
||||
status = "[PREVIEW]" if dry_run else "[OK]"
|
||||
print(f"{status} {svg_path.name} ({replaced_count} icons)")
|
||||
|
||||
return replaced_count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Replace icon placeholders in SVG files with actual icon code',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
python3 scripts/svg_finalize/embed_icons.py svg_output/01_cover.svg
|
||||
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
|
||||
python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
|
||||
python3 scripts/svg_finalize/embed_icons.py --icons-dir my_icons/ output.svg
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument('files', nargs='+', help='SVG files to process')
|
||||
parser.add_argument('--icons-dir', type=Path, default=DEFAULT_ICONS_DIR,
|
||||
help=f'Icon directory path (default: {DEFAULT_ICONS_DIR})')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='Only show what would be replaced, without modifying files')
|
||||
parser.add_argument('--verbose', '-v', action='store_true',
|
||||
help='Show detailed information')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate icon directory
|
||||
if not args.icons_dir.exists():
|
||||
print(f"[ERROR] Icon directory not found: {args.icons_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[DIR] Icon directory: {args.icons_dir}")
|
||||
if args.dry_run:
|
||||
print("[PREVIEW] Preview mode (no files will be modified)")
|
||||
print()
|
||||
|
||||
total_replaced = 0
|
||||
total_files = 0
|
||||
|
||||
for file_pattern in args.files:
|
||||
svg_path = Path(file_pattern)
|
||||
if svg_path.exists():
|
||||
count = process_svg_file(svg_path, args.icons_dir, args.dry_run, args.verbose)
|
||||
total_replaced += count
|
||||
if count > 0:
|
||||
total_files += 1
|
||||
|
||||
print()
|
||||
print(f"[Summary] Total: {total_files} file(s), {total_replaced} icon(s)" +
|
||||
(" (preview)" if args.dry_run else " replaced"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SVG Image Embedding Tool
|
||||
Converts externally referenced images in SVG files to Base64 inline format.
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/embed_images.py <svg_file> [svg_file2] ...
|
||||
python3 scripts/svg_finalize/embed_images.py *.svg
|
||||
|
||||
Examples:
|
||||
python3 scripts/svg_finalize/embed_images.py examples/ppt169_demo/svg_output/01_cover.svg
|
||||
python3 scripts/svg_finalize/embed_images.py examples/ppt169_demo/svg_output/*.svg
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
_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
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def get_mime_type(filename: str, file_bytes: bytes | None = None) -> str:
|
||||
"""Return the MIME type based on file bytes first, then extension."""
|
||||
if file_bytes:
|
||||
if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return 'image/png'
|
||||
if file_bytes.startswith(b"\xff\xd8\xff"):
|
||||
return 'image/jpeg'
|
||||
if file_bytes.startswith((b"GIF87a", b"GIF89a")):
|
||||
return 'image/gif'
|
||||
if file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP":
|
||||
return 'image/webp'
|
||||
if file_bytes.lstrip().startswith(b"<svg"):
|
||||
return 'image/svg+xml'
|
||||
|
||||
ext = filename.lower().split('.')[-1]
|
||||
mime_map = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
}
|
||||
return mime_map.get(ext, 'application/octet-stream')
|
||||
|
||||
def get_file_size_str(size_bytes: int) -> str:
|
||||
"""Convert byte count to a human-readable file size string."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
|
||||
compress: bool = False,
|
||||
max_dimension: int | None = None) -> bytes:
|
||||
"""Optionally compress and/or downscale image bytes.
|
||||
|
||||
Returns the (possibly optimized) image bytes. Falls back to the
|
||||
original bytes if PIL is not available or optimization fails.
|
||||
"""
|
||||
if not compress and not max_dimension:
|
||||
return img_bytes
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
import io
|
||||
except ImportError:
|
||||
return img_bytes
|
||||
|
||||
try:
|
||||
img = PILImage.open(io.BytesIO(img_bytes))
|
||||
except Exception:
|
||||
return img_bytes
|
||||
|
||||
# Multi-frame images (animated GIF / WebP / APNG): resize/re-save below
|
||||
# keeps frame 0 only, silently flattening the animation. Pass the
|
||||
# original bytes through — animations are exempt from compression and
|
||||
# the size cap.
|
||||
if getattr(img, 'is_animated', False):
|
||||
if max_dimension:
|
||||
w, h = img.size
|
||||
if w > max_dimension or h > max_dimension:
|
||||
print(f" [WARN] Animated image kept as-is ({w}x{h} exceeds "
|
||||
f"max dimension {max_dimension}px); animations are "
|
||||
f"exempt from size limits")
|
||||
return img_bytes
|
||||
|
||||
changed = False
|
||||
|
||||
# Downscale if exceeding max_dimension
|
||||
if max_dimension:
|
||||
w, h = img.size
|
||||
if w > max_dimension or h > max_dimension:
|
||||
ratio = min(max_dimension / w, max_dimension / h)
|
||||
new_w, new_h = int(w * ratio), int(h * ratio)
|
||||
img = img.resize((new_w, new_h), PILImage.LANCZOS)
|
||||
changed = True
|
||||
|
||||
# Compress
|
||||
if compress or changed:
|
||||
buf = io.BytesIO()
|
||||
if mime_type == 'image/jpeg':
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
img.save(buf, format='JPEG', quality=85, optimize=True)
|
||||
elif mime_type == 'image/png':
|
||||
img.save(buf, format='PNG', optimize=True)
|
||||
else:
|
||||
# For other formats, just re-save
|
||||
fmt = img.format or 'PNG'
|
||||
img.save(buf, format=fmt)
|
||||
|
||||
optimized = buf.getvalue()
|
||||
# Only use optimized version if it's actually smaller
|
||||
if len(optimized) < len(img_bytes):
|
||||
return optimized
|
||||
|
||||
return img_bytes
|
||||
|
||||
|
||||
def embed_images_in_svg(svg_path: str, dry_run: bool = False,
|
||||
compress: bool = False,
|
||||
max_dimension: int | None = None) -> tuple[int, int]:
|
||||
"""
|
||||
Convert externally referenced images in an SVG file to Base64 inline format.
|
||||
|
||||
Args:
|
||||
svg_path: SVG file path
|
||||
dry_run: If True, only show which images would be processed without modifying the file
|
||||
compress: If True, compress images before embedding (JPEG quality=85, PNG optimize)
|
||||
max_dimension: If set, downscale images exceeding this dimension on either axis
|
||||
|
||||
Returns:
|
||||
tuple: (number of images processed, file size after embedding)
|
||||
"""
|
||||
svg_dir = os.path.dirname(os.path.abspath(svg_path))
|
||||
|
||||
with open(svg_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_size = len(content.encode('utf-8'))
|
||||
|
||||
# Match href="xxx.png" or href="xxx.jpg" etc. (exclude those already using data:)
|
||||
pattern = r'href="(?!data:)([^"]+\.(png|jpg|jpeg|gif|webp))"'
|
||||
|
||||
images_found = []
|
||||
images_embedded = 0
|
||||
|
||||
def replace_with_base64(match):
|
||||
nonlocal images_embedded
|
||||
img_path = match.group(1)
|
||||
|
||||
# Decode XML/HTML entities (e.g., & -> &)
|
||||
import html
|
||||
img_path_decoded = html.unescape(img_path)
|
||||
|
||||
# Handle relative paths
|
||||
if not os.path.isabs(img_path_decoded):
|
||||
full_path = os.path.join(svg_dir, img_path_decoded)
|
||||
else:
|
||||
full_path = img_path_decoded
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
print(f" [WARN] Image not found: {img_path}")
|
||||
images_found.append((img_path, "NOT FOUND", 0, None))
|
||||
return match.group(0)
|
||||
|
||||
img_size = os.path.getsize(full_path)
|
||||
|
||||
if dry_run:
|
||||
images_found.append((img_path, "WILL EMBED", img_size, None))
|
||||
return match.group(0)
|
||||
|
||||
with open(full_path, 'rb') as img_file:
|
||||
img_bytes = img_file.read()
|
||||
|
||||
mime_type = get_mime_type(img_path, img_bytes)
|
||||
optimized_bytes = _optimize_image_bytes(
|
||||
img_bytes, mime_type, compress=compress, max_dimension=max_dimension)
|
||||
b64_data = base64.b64encode(optimized_bytes).decode('utf-8')
|
||||
|
||||
images_embedded += 1
|
||||
saved = len(img_bytes) - len(optimized_bytes)
|
||||
if saved > 0 and (compress or max_dimension):
|
||||
pct = saved / len(img_bytes) * 100
|
||||
images_found.append((img_path, "EMBEDDED", img_size,
|
||||
f"{get_file_size_str(len(img_bytes))} → {get_file_size_str(len(optimized_bytes))}, saved {pct:.0f}%"))
|
||||
else:
|
||||
images_found.append((img_path, "EMBEDDED", img_size, None))
|
||||
|
||||
return f'href="data:{mime_type};base64,{b64_data}"'
|
||||
|
||||
new_content = re.sub(pattern, replace_with_base64, content)
|
||||
|
||||
new_size = len(new_content.encode('utf-8'))
|
||||
|
||||
# Print processed images
|
||||
if images_found:
|
||||
print(f"\n[FILE] {os.path.basename(svg_path)}")
|
||||
for img_path, status, size, opt_info in images_found:
|
||||
size_str = get_file_size_str(size) if size > 0 else ""
|
||||
if status == "EMBEDDED":
|
||||
if opt_info:
|
||||
print(f" [OK] {img_path} ({opt_info})")
|
||||
else:
|
||||
print(f" [OK] {img_path} ({size_str})")
|
||||
elif status == "WILL EMBED":
|
||||
print(f" [PREVIEW] {img_path} ({size_str}) [dry-run]")
|
||||
else:
|
||||
print(f" [FAIL] {img_path} ({status})")
|
||||
|
||||
print(f" [SIZE] {get_file_size_str(original_size)} -> {get_file_size_str(new_size)}")
|
||||
|
||||
if not dry_run and images_embedded > 0:
|
||||
with open(svg_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
processed_count = len(images_found) if dry_run else images_embedded
|
||||
return (processed_count, new_size)
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Convert externally referenced images in SVG files to Base64 inline format',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
%(prog)s 01_cover.svg # Process a single file
|
||||
%(prog)s *.svg # Process all SVGs in current directory
|
||||
%(prog)s --dry-run *.svg # Preview files to be processed
|
||||
'''
|
||||
)
|
||||
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 processed, without modifying files')
|
||||
parser.add_argument('--compress', action='store_true',
|
||||
help='Compress images before embedding (JPEG quality=85, PNG optimize)')
|
||||
parser.add_argument('--max-dimension', type=int, default=None,
|
||||
help='Downscale images exceeding this dimension on either axis (e.g., 2560)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
print("[INFO] Dry-run mode: only preview, no modification\n")
|
||||
if args.compress:
|
||||
print("[INFO] Compression enabled: JPEG quality=85, PNG optimize")
|
||||
if args.max_dimension:
|
||||
print(f"[INFO] Max dimension: {args.max_dimension}px")
|
||||
|
||||
total_images = 0
|
||||
total_files = 0
|
||||
|
||||
for svg_file in args.files:
|
||||
if not os.path.exists(svg_file):
|
||||
print(f"[ERROR] File not found: {svg_file}")
|
||||
continue
|
||||
|
||||
if not svg_file.endswith('.svg'):
|
||||
print(f"[SKIP] Skipping non-SVG file: {svg_file}")
|
||||
continue
|
||||
|
||||
images, _ = embed_images_in_svg(svg_file, dry_run=args.dry_run,
|
||||
compress=args.compress,
|
||||
max_dimension=args.max_dimension)
|
||||
if images > 0:
|
||||
total_images += images
|
||||
total_files += 1
|
||||
|
||||
print(f"\n{'=' * 50}")
|
||||
if args.dry_run:
|
||||
print(f"[PREVIEW] Will process {total_images} images in {total_files} files")
|
||||
else:
|
||||
print(f"[DONE] Embedded {total_images} images in {total_files} files")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/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 by PowerPoint SVG rendering paths that do not
|
||||
honor preserveAspectRatio consistently.
|
||||
|
||||
Principle:
|
||||
Some PowerPoint SVG rendering paths ignore the preserveAspectRatio attribute and directly
|
||||
stretch 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
|
||||
|
||||
_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
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
# 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='Normalize SVG image boxes for PowerPoint SVG rendering diagnostics',
|
||||
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()
|
||||
@@ -0,0 +1,919 @@
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
NSMAP = {"svg": SVG_NS}
|
||||
|
||||
# Ensure pretty element names without ns0 prefix on write
|
||||
ET.register_namespace("", SVG_NS)
|
||||
|
||||
|
||||
TEXT_STYLE_ATTRS = {
|
||||
# common text styling
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"font-style",
|
||||
"font-variant",
|
||||
"font-stretch",
|
||||
"letter-spacing",
|
||||
"word-spacing",
|
||||
"kerning",
|
||||
"text-anchor",
|
||||
"text-decoration",
|
||||
"dominant-baseline",
|
||||
"writing-mode",
|
||||
"direction",
|
||||
# color/paint
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"stroke-opacity",
|
||||
"opacity",
|
||||
"paint-order",
|
||||
# transforms/filters
|
||||
"transform",
|
||||
"clip-path",
|
||||
"filter",
|
||||
}
|
||||
|
||||
|
||||
num_re = re.compile(r"^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))")
|
||||
|
||||
|
||||
def parse_first_number(val: str | None) -> float | None:
|
||||
"""Parse the first numeric token from an SVG attribute value."""
|
||||
if val is None:
|
||||
return None
|
||||
m = num_re.match(val)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def format_number(n: float | None) -> str | None:
|
||||
"""Format a float for compact SVG attribute output."""
|
||||
if n is None:
|
||||
return None
|
||||
if abs(n - round(n)) < 1e-6:
|
||||
return str(int(round(n)))
|
||||
# Trim trailing zeros
|
||||
s = f"{n:.6f}".rstrip("0").rstrip(".")
|
||||
return s
|
||||
|
||||
|
||||
def parse_style(style_str: str | None) -> dict[str, str]:
|
||||
"""Parse an inline SVG style string into a mapping."""
|
||||
out: dict[str, str] = {}
|
||||
if not style_str:
|
||||
return out
|
||||
# split by ; and then :
|
||||
for chunk in style_str.split(";"):
|
||||
if not chunk.strip():
|
||||
continue
|
||||
if ":" in chunk:
|
||||
k, v = chunk.split(":", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def style_to_string(style_map: dict[str, str]) -> str:
|
||||
"""Serialize a style mapping back into an inline SVG style string."""
|
||||
if not style_map:
|
||||
return ""
|
||||
return ";".join(f"{k}:{v}" for k, v in style_map.items())
|
||||
|
||||
|
||||
def merge_styles(parent_style: str | None, child_style: str | None) -> str:
|
||||
"""Merge parent and child inline styles, preferring child values."""
|
||||
p = parse_style(parent_style)
|
||||
c = parse_style(child_style)
|
||||
p.update(c) # child overrides
|
||||
return style_to_string(p)
|
||||
|
||||
|
||||
def get_attr(elem: ET.Element | None, name: str, default: str | None = None) -> str | None:
|
||||
"""Read an attribute from an element with a default fallback."""
|
||||
return elem.get(name) if elem is not None and name in elem.attrib else default
|
||||
|
||||
|
||||
def compute_line_positions(
|
||||
text_el: ET.Element,
|
||||
tspan_el: ET.Element,
|
||||
cur_x: float | None,
|
||||
cur_y: float | None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""
|
||||
Compute absolute x,y for a tspan based on parent <text> current baseline and tspan's x/y/dx/dy.
|
||||
Returns (new_x, new_y).
|
||||
"""
|
||||
del text_el
|
||||
t_x_attr = get_attr(tspan_el, "x")
|
||||
t_y_attr = get_attr(tspan_el, "y")
|
||||
t_dx_attr = get_attr(tspan_el, "dx")
|
||||
t_dy_attr = get_attr(tspan_el, "dy")
|
||||
|
||||
nx = parse_first_number(t_x_attr) if t_x_attr is not None else cur_x
|
||||
if t_dx_attr is not None:
|
||||
dx = parse_first_number(t_dx_attr) or 0.0
|
||||
nx = (nx or 0.0) + dx
|
||||
|
||||
ny = parse_first_number(t_y_attr) if t_y_attr is not None else cur_y
|
||||
if t_dy_attr is not None:
|
||||
dy = parse_first_number(t_dy_attr) or 0.0
|
||||
ny = (ny or 0.0) + dy
|
||||
|
||||
return nx, ny
|
||||
|
||||
|
||||
def collect_text_content(el: ET.Element) -> str:
|
||||
"""Collect all text content from an element subtree."""
|
||||
# Gather all text within the element (flatten nested tspans if any)
|
||||
parts = []
|
||||
for s in el.itertext():
|
||||
if s:
|
||||
parts.append(s)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _has_non_xml_whitespace(text: str | None) -> bool:
|
||||
"""Return whether text contains content beyond XML layout whitespace."""
|
||||
return bool(text and text.strip(" \t\r\n"))
|
||||
|
||||
|
||||
def copy_text_attrs(
|
||||
src_el: ET.Element,
|
||||
dst_el: ET.Element,
|
||||
exclude: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Copy shared text styling attributes between SVG text elements."""
|
||||
exclude = exclude or set()
|
||||
# Copy style string first
|
||||
if "style" in src_el.attrib and "style" not in exclude:
|
||||
dst_el.set("style", src_el.attrib["style"])
|
||||
for k in TEXT_STYLE_ATTRS:
|
||||
if k in exclude:
|
||||
continue
|
||||
v = src_el.get(k)
|
||||
if v is not None:
|
||||
dst_el.set(k, v)
|
||||
# xml:space preservation
|
||||
xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space")
|
||||
if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude:
|
||||
dst_el.set("{http://www.w3.org/XML/1998/namespace}space", xml_space)
|
||||
|
||||
|
||||
PARAGRAPH_MARK_ATTR = "data-paragraph-line-height"
|
||||
PARAGRAPH_SPACE_BEFORE_ATTR = "data-paragraph-space-before"
|
||||
# Marks a line-break tspan as a SOFT break inside the current paragraph
|
||||
# (SVG used dy to simulate text wrapping; the downstream converter should
|
||||
# merge its runs into the previous <a:p> rather than start a new one).
|
||||
PARAGRAPH_SOFT_BREAK_ATTR = "data-paragraph-soft-break"
|
||||
# Marks an authored visual line boundary that remains a hard DrawingML break
|
||||
# in the default single-frame preserve mode.
|
||||
PARAGRAPH_LINE_BREAK_ATTR = "data-paragraph-line-break"
|
||||
|
||||
# Tolerance for detecting "base line-height" vs "paragraph gap": dy values
|
||||
# within ±DY_TOLERANCE_PX of each other are considered the same line-height.
|
||||
DY_TOLERANCE_PX = 0.5
|
||||
# Cap on dy / base ratio. Anything beyond this (e.g. a 5x gap) is rejected
|
||||
# as a real section break that shouldn't merge into one text frame.
|
||||
MAX_DY_MULTIPLIER = 3.0
|
||||
LIST_MARKER_RE = re.compile(
|
||||
r"^\s*(?:[•·・]\s*|[-–—*]\s+|\d+[.)、]\s+|[((]\d+[))]\s*)\S+"
|
||||
)
|
||||
|
||||
|
||||
def _starts_with_list_marker(line_group: list[ET.Element]) -> bool:
|
||||
"""Return True when a visual line starts with an ordered/unordered marker."""
|
||||
text = "".join(collect_text_content(tspan) for tspan in line_group)
|
||||
return bool(LIST_MARKER_RE.match(text))
|
||||
|
||||
|
||||
def _positional_tspan_attribute(tspan: ET.Element) -> str | None:
|
||||
"""Return the unsupported nested position attribute, if any."""
|
||||
for name in ("x", "y"):
|
||||
if tspan.get(name) is not None:
|
||||
return name
|
||||
raw_dy = tspan.get("dy")
|
||||
dy = parse_first_number(raw_dy) if raw_dy is not None else None
|
||||
if dy is not None and abs(dy) > 1e-6:
|
||||
return "dy"
|
||||
return None
|
||||
|
||||
|
||||
def nested_positional_tspan_errors(root: ET.Element) -> list[str]:
|
||||
"""Describe nested tspans whose baseline jumps cannot be exported."""
|
||||
errors: list[str] = []
|
||||
for text_el in root.iter(f"{{{SVG_NS}}}text"):
|
||||
text_label = (
|
||||
f"<text id={text_el.get('id')!r}>"
|
||||
if text_el.get("id")
|
||||
else "<text>"
|
||||
)
|
||||
for direct_child in list(text_el):
|
||||
if direct_child.tag != f"{{{SVG_NS}}}tspan":
|
||||
continue
|
||||
for descendant in direct_child.iter(f"{{{SVG_NS}}}tspan"):
|
||||
if descendant is direct_child:
|
||||
continue
|
||||
attribute = _positional_tspan_attribute(descendant)
|
||||
if attribute is None:
|
||||
continue
|
||||
errors.append(
|
||||
f"{text_label} contains a nested <tspan> with {attribute}; "
|
||||
"move x/y/non-zero dy to a direct child of <text>"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _build_paragraph_child_view(
|
||||
text_el: ET.Element,
|
||||
is_svg_tag,
|
||||
) -> tuple[list[ET.Element], ET.Element | None] | None:
|
||||
"""Return direct tspan children plus an optional synthetic leading line.
|
||||
|
||||
The synthetic line lets paragraph classification accept common SVG
|
||||
authoring where the first visual line is direct text under <text>. This
|
||||
helper does not mutate the tree; _emit_mergeable_paragraph commits the
|
||||
synthetic line only after all paragraph checks pass.
|
||||
"""
|
||||
direct_children = list(text_el)
|
||||
direct_tspans = [c for c in direct_children if is_svg_tag(c, "tspan")]
|
||||
if len(direct_tspans) != len(direct_children):
|
||||
return None
|
||||
|
||||
raw_lead = text_el.text or ""
|
||||
synthetic_first: ET.Element | None = None
|
||||
if raw_lead.strip():
|
||||
base_x_raw = get_attr(text_el, "x")
|
||||
if base_x_raw is None:
|
||||
return None
|
||||
if any((child.tail or "").strip() for child in direct_tspans):
|
||||
return None
|
||||
synthetic_first = ET.Element(f"{{{SVG_NS}}}tspan")
|
||||
synthetic_first.set("x", base_x_raw)
|
||||
synthetic_first.text = raw_lead.lstrip()
|
||||
|
||||
view = ([synthetic_first] if synthetic_first is not None else []) + direct_tspans
|
||||
return view, synthetic_first
|
||||
|
||||
|
||||
def _get_font_size_px(elem: ET.Element) -> float | None:
|
||||
"""Read font-size from an attribute or inline style."""
|
||||
size = parse_first_number(get_attr(elem, "font-size"))
|
||||
if size is not None:
|
||||
return size
|
||||
style_size = parse_style(get_attr(elem, "style")).get("font-size")
|
||||
return parse_first_number(style_size)
|
||||
|
||||
|
||||
def _effective_line_font_size_px(
|
||||
text_el: ET.Element,
|
||||
line_group: list[ET.Element],
|
||||
) -> float:
|
||||
"""Return the positioned line starter's effective font size."""
|
||||
line_size = _get_font_size_px(line_group[0])
|
||||
if line_size is not None:
|
||||
return line_size
|
||||
parent_size = _get_font_size_px(text_el)
|
||||
return parent_size if parent_size is not None else 16.0
|
||||
|
||||
|
||||
def _classify_paragraph_block(
|
||||
text_el: ET.Element,
|
||||
is_svg_tag,
|
||||
is_new_line_tspan,
|
||||
preserve_line_breaks: bool,
|
||||
) -> tuple[float, list[float], list[str], list[list[ET.Element]], ET.Element | None] | None:
|
||||
"""Detect a mergeable paragraph block.
|
||||
|
||||
Returns ``(base_line_height_px, extra_space_before_px_per_line,
|
||||
break_kind_per_line, line_groups, synthetic_first_line)`` if the children
|
||||
form a mergeable paragraph. Each list has one entry per direct-child tspan
|
||||
(line), including a synthetic first line when the source used leading text:
|
||||
|
||||
- extra_space_before_px_per_line[i]: extra px above base line-height,
|
||||
used as <a:spcBef> on the downstream <a:p>. First entry is 0.
|
||||
- break_kind_per_line[i]: ``paragraph`` starts a fresh <a:p>, ``soft``
|
||||
joins the previous line for reflow, and ``line`` preserves the visual
|
||||
boundary as a hard DrawingML break. First entry is ``paragraph``.
|
||||
|
||||
Conditions (all must hold):
|
||||
- No direct text under <text>, except simple leading text that can be
|
||||
promoted into a synthetic first-line <tspan>.
|
||||
- Every direct child is a <tspan>.
|
||||
- Every logical line starts with a new-line tspan.
|
||||
- Direct-child inline formatting tspans without x/y/dy are allowed only
|
||||
after a line starts; they are normalized into the previous line.
|
||||
- First line-break tspan has dy == 0 (or no dy).
|
||||
- All subsequent line-break tspans use positive dy (no <y>).
|
||||
- dy values cluster around a single minimum "base line-height";
|
||||
any larger dy must be ≤ MAX_DY_MULTIPLIER × base. Anything larger
|
||||
is treated as a section break and rejected.
|
||||
- Every line-break tspan that sets x repeats the parent <text>'s x.
|
||||
- A line-break tspan cannot add a non-zero dx offset.
|
||||
- No nested tspan inside any line carries x/y/non-zero dy.
|
||||
- Adjacent lines with different effective font sizes start new paragraphs.
|
||||
"""
|
||||
base_x = parse_first_number(get_attr(text_el, "x"))
|
||||
child_view = _build_paragraph_child_view(text_el, is_svg_tag)
|
||||
if child_view is None:
|
||||
return None
|
||||
direct_tspans, synthetic_first = child_view
|
||||
|
||||
if len(direct_tspans) < 2:
|
||||
return None
|
||||
|
||||
line_groups: list[list[ET.Element]] = []
|
||||
for tspan in direct_tspans:
|
||||
if is_new_line_tspan(tspan):
|
||||
line_groups.append([tspan])
|
||||
else:
|
||||
if not line_groups:
|
||||
return None
|
||||
line_groups[-1].append(tspan)
|
||||
|
||||
if len(line_groups) < 2:
|
||||
return None
|
||||
|
||||
# First pass: validate per-line structural rules and collect dy values.
|
||||
dy_values: list[float] = [] # one per line (0 for first)
|
||||
for idx, group in enumerate(line_groups):
|
||||
tspan = group[0]
|
||||
|
||||
t_y = get_attr(tspan, "y")
|
||||
if t_y is not None:
|
||||
return None
|
||||
|
||||
t_x_raw = get_attr(tspan, "x")
|
||||
if t_x_raw is not None:
|
||||
t_x = parse_first_number(t_x_raw)
|
||||
if base_x is None or t_x is None or abs(t_x - base_x) > 1e-6:
|
||||
return None
|
||||
t_dx_raw = get_attr(tspan, "dx")
|
||||
t_dx = parse_first_number(t_dx_raw) if t_dx_raw is not None else None
|
||||
if t_dx is not None and abs(t_dx) > 1e-6:
|
||||
return None
|
||||
|
||||
t_dy_raw = get_attr(tspan, "dy")
|
||||
t_dy = parse_first_number(t_dy_raw) if t_dy_raw is not None else None
|
||||
|
||||
if idx == 0:
|
||||
if t_dy is not None and abs(t_dy) > 1e-6:
|
||||
return None
|
||||
dy_values.append(0.0)
|
||||
else:
|
||||
if t_dy is None or t_dy <= 0:
|
||||
return None
|
||||
dy_values.append(t_dy)
|
||||
|
||||
# Second pass: pick the base line-height as the minimum positive dy and
|
||||
# express each line's dy as base + extra space-before.
|
||||
positive_dys = [d for d in dy_values[1:] if d > 0]
|
||||
if not positive_dys:
|
||||
return None
|
||||
base = min(positive_dys)
|
||||
font_size = _get_font_size_px(text_el)
|
||||
if font_size is not None and base > font_size * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX:
|
||||
return None
|
||||
|
||||
extras: list[float] = [0.0] # first line never has space-before
|
||||
break_kinds = ["paragraph"]
|
||||
line_font_sizes = [
|
||||
_effective_line_font_size_px(text_el, group)
|
||||
for group in line_groups
|
||||
]
|
||||
for idx, d in enumerate(dy_values[1:], start=1):
|
||||
if d + DY_TOLERANCE_PX < base:
|
||||
return None # below base — line overlap, not a paragraph
|
||||
if d > base * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX:
|
||||
return None # gap too large — treat as section break
|
||||
extra = d - base
|
||||
if extra < 0:
|
||||
extra = 0.0
|
||||
# dy at the base line-height = soft break (SVG was simulating wrap);
|
||||
# dy strictly greater than base = hard paragraph break. List markers
|
||||
# and font-size changes also start a fresh paragraph so semantically
|
||||
# distinct visual lines do not merge into one PowerPoint line.
|
||||
reflow_candidate = (
|
||||
abs(extra) <= DY_TOLERANCE_PX
|
||||
and not _starts_with_list_marker(line_groups[idx])
|
||||
and abs(line_font_sizes[idx] - line_font_sizes[idx - 1]) <= 1e-6
|
||||
)
|
||||
explicit_soft_break = line_groups[idx][0].get(
|
||||
PARAGRAPH_SOFT_BREAK_ATTR
|
||||
)
|
||||
if explicit_soft_break == "0":
|
||||
break_kind = "paragraph"
|
||||
elif explicit_soft_break == "1":
|
||||
break_kind = "soft"
|
||||
elif reflow_candidate:
|
||||
break_kind = "line" if preserve_line_breaks else "soft"
|
||||
else:
|
||||
break_kind = "paragraph"
|
||||
extras.append(0.0 if break_kind != "paragraph" else extra)
|
||||
break_kinds.append(break_kind)
|
||||
|
||||
return base, extras, break_kinds, line_groups, synthetic_first
|
||||
|
||||
|
||||
def _emit_mergeable_paragraph(
|
||||
text_el: ET.Element,
|
||||
base_dy: float,
|
||||
extras: list[float],
|
||||
break_kinds: list[str],
|
||||
line_groups: list[list[ET.Element]],
|
||||
synthetic_first: ET.Element | None = None,
|
||||
) -> None:
|
||||
"""Rewrite text_el in place so it stays a single <text> with paragraph rows.
|
||||
|
||||
The base line-height goes on the parent <text> via PARAGRAPH_MARK_ATTR.
|
||||
Each direct-child tspan is normalized: x/y/dx/dy stripped; inline-run
|
||||
styling and nested tspans are preserved. Per-tspan attrs:
|
||||
- PARAGRAPH_SOFT_BREAK_ATTR="1" on tspans that should be appended to
|
||||
the previous <a:p> downstream (SVG used dy to simulate wrap)
|
||||
- PARAGRAPH_LINE_BREAK_ATTR="1" on tspans that retain an authored line
|
||||
boundary inside the previous <a:p>
|
||||
- PARAGRAPH_SPACE_BEFORE_ATTR on tspans that open a new paragraph
|
||||
with an extra gap (omitted when 0)
|
||||
"""
|
||||
text_el.set(PARAGRAPH_MARK_ATTR, format_number(base_dy))
|
||||
if synthetic_first is not None:
|
||||
text_el.text = None
|
||||
text_el.insert(0, synthetic_first)
|
||||
|
||||
# Normalize authoring variants before the downstream converter reads the
|
||||
# paragraph: a line-break tspan may be followed by direct-child inline
|
||||
# formatting tspans. Wrap those original siblings in one unstyled line
|
||||
# container so every direct child of <text> is one logical visual line.
|
||||
# Keeping the authored runs as siblings is important: nesting later runs
|
||||
# under the positioned first run would incorrectly inherit its typography,
|
||||
# and moving them independently would lose the first run's tail whitespace.
|
||||
normalized_lines: list[ET.Element] = []
|
||||
for group in line_groups:
|
||||
line = group[0]
|
||||
if len(group) == 1:
|
||||
normalized_lines.append(line)
|
||||
continue
|
||||
|
||||
container = ET.Element(f"{{{SVG_NS}}}tspan")
|
||||
for k in ("x", "y", "dx", "dy"):
|
||||
line.attrib.pop(k, None)
|
||||
for run in group:
|
||||
container.append(run)
|
||||
normalized_lines.append(container)
|
||||
|
||||
for child in list(text_el):
|
||||
text_el.remove(child)
|
||||
for line in normalized_lines:
|
||||
text_el.append(line)
|
||||
|
||||
extras_iter = iter(extras)
|
||||
break_iter = iter(break_kinds)
|
||||
for tspan in normalized_lines:
|
||||
for k in ("x", "y", "dx", "dy"):
|
||||
if k in tspan.attrib:
|
||||
del tspan.attrib[k]
|
||||
for k in (
|
||||
PARAGRAPH_SOFT_BREAK_ATTR,
|
||||
PARAGRAPH_LINE_BREAK_ATTR,
|
||||
PARAGRAPH_SPACE_BEFORE_ATTR,
|
||||
):
|
||||
tspan.attrib.pop(k, None)
|
||||
try:
|
||||
extra = next(extras_iter)
|
||||
break_kind = next(break_iter)
|
||||
except StopIteration:
|
||||
extra = 0.0
|
||||
break_kind = "paragraph"
|
||||
if break_kind == "soft":
|
||||
tspan.set(PARAGRAPH_SOFT_BREAK_ATTR, "1")
|
||||
elif break_kind == "line":
|
||||
tspan.set(PARAGRAPH_LINE_BREAK_ATTR, "1")
|
||||
elif extra > 1e-6:
|
||||
tspan.set(PARAGRAPH_SPACE_BEFORE_ATTR, format_number(extra))
|
||||
|
||||
|
||||
def flatten_text_with_tspans(
|
||||
tree: ET.ElementTree,
|
||||
merge_paragraphs: bool = False,
|
||||
preserve_line_breaks: bool = False,
|
||||
) -> bool:
|
||||
"""Flatten multi-line tspan text into independent text nodes when needed.
|
||||
|
||||
When ``merge_paragraphs`` is True, mergeable paragraph blocks (same x,
|
||||
dy clustered around one base line-height) are kept as a single <text>.
|
||||
``preserve_line_breaks`` marks ordinary visual rows as hard line breaks
|
||||
instead of reflowable continuations. Default split behavior still promotes
|
||||
every positioned row to its own <text>.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
positional_errors = nested_positional_tspan_errors(root)
|
||||
if positional_errors:
|
||||
preview = "; ".join(positional_errors[:3])
|
||||
suffix = (
|
||||
""
|
||||
if len(positional_errors) <= 3
|
||||
else f"; +{len(positional_errors) - 3} more"
|
||||
)
|
||||
raise ValueError(f"Unsupported nested positional <tspan>: {preview}{suffix}")
|
||||
parent_map = {c: p for p in root.iter() for c in p}
|
||||
changed = False
|
||||
|
||||
def is_svg_tag(el: ET.Element, name: str) -> bool:
|
||||
return el.tag == f"{{{SVG_NS}}}{name}"
|
||||
|
||||
def is_new_line_tspan(tspan: ET.Element) -> bool:
|
||||
"""Determine whether a tspan represents a new line (has its own y or non-zero dy)."""
|
||||
t_dy_attr = get_attr(tspan, "dy")
|
||||
t_y_attr = get_attr(tspan, "y")
|
||||
t_x_attr = get_attr(tspan, "x")
|
||||
dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None
|
||||
# Has its own y attribute, or has non-zero dy, or has its own x attribute (indicating a new line)
|
||||
if t_y_attr is not None:
|
||||
return True
|
||||
if dy_val is not None and dy_val != 0:
|
||||
return True
|
||||
# If tspan has an x attribute and there are preceding sibling tspans, treat it as a new line
|
||||
if t_x_attr is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Collect candidates first to avoid modifying while iterating
|
||||
candidates = []
|
||||
for el in root.iter():
|
||||
if is_svg_tag(el, "text"):
|
||||
has_tspan_child = any(is_svg_tag(c, "tspan") for c in list(el))
|
||||
if has_tspan_child:
|
||||
candidates.append(el)
|
||||
|
||||
for text_el in candidates:
|
||||
parent = parent_map.get(text_el)
|
||||
if parent is None:
|
||||
continue
|
||||
|
||||
# First check whether any tspan needs flattening (dy != 0 or has its own y attribute)
|
||||
needs_flatten = False
|
||||
for child in list(text_el):
|
||||
if not is_svg_tag(child, "tspan"):
|
||||
continue
|
||||
if is_new_line_tspan(child):
|
||||
needs_flatten = True
|
||||
break
|
||||
|
||||
# If no tspan needs a line break, skip the entire text element
|
||||
if not needs_flatten:
|
||||
continue
|
||||
|
||||
# Single-frame fast path: conservative same-x/dy blocks stay in one
|
||||
# <text>. The downstream converter either preserves visual breaks or
|
||||
# reflows them. Split mode promotes each positioned line to <text>.
|
||||
if merge_paragraphs:
|
||||
paragraph = _classify_paragraph_block(
|
||||
text_el,
|
||||
is_svg_tag,
|
||||
is_new_line_tspan,
|
||||
preserve_line_breaks,
|
||||
)
|
||||
if paragraph is not None:
|
||||
base_dy, extras, break_kinds, line_groups, synthetic_first = paragraph
|
||||
_emit_mergeable_paragraph(
|
||||
text_el,
|
||||
base_dy,
|
||||
extras,
|
||||
break_kinds,
|
||||
line_groups,
|
||||
synthetic_first=synthetic_first,
|
||||
)
|
||||
changed = True
|
||||
continue
|
||||
|
||||
base_x = parse_first_number(get_attr(text_el, "x")) or 0.0
|
||||
base_y = parse_first_number(get_attr(text_el, "y")) or 0.0
|
||||
cur_x, cur_y = base_x, base_y
|
||||
|
||||
new_texts = []
|
||||
|
||||
# Collect tspan elements belonging to the same line
|
||||
current_line_tspans = []
|
||||
current_line_lead_text = text_el.text or None
|
||||
|
||||
for idx, child in enumerate(list(text_el)):
|
||||
if not is_svg_tag(child, "tspan"):
|
||||
continue
|
||||
|
||||
content = collect_text_content(child)
|
||||
|
||||
# Check whether this tspan starts a new line
|
||||
if is_new_line_tspan(child):
|
||||
# Save previously accumulated same-line tspans first
|
||||
if current_line_tspans or _has_non_xml_whitespace(
|
||||
current_line_lead_text
|
||||
):
|
||||
ne = _create_text_element_from_line(
|
||||
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
|
||||
)
|
||||
new_texts.append(ne)
|
||||
current_line_tspans = []
|
||||
current_line_lead_text = None
|
||||
|
||||
# Update position
|
||||
nx, ny = compute_line_positions(text_el, child, cur_x, cur_y)
|
||||
cur_x, cur_y = nx, ny
|
||||
|
||||
# Keep raw XML whitespace and tails until the shared downstream
|
||||
# text normalizer sees the whole line. A whitespace-only run can
|
||||
# still be the visible boundary between two formatted runs.
|
||||
if content or child.tail:
|
||||
current_line_tspans.append(child)
|
||||
|
||||
# Process the last line
|
||||
if current_line_tspans or _has_non_xml_whitespace(
|
||||
current_line_lead_text
|
||||
):
|
||||
ne = _create_text_element_from_line(
|
||||
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
|
||||
)
|
||||
new_texts.append(ne)
|
||||
|
||||
if new_texts:
|
||||
# Replace original <text> with the list of new <text> nodes
|
||||
try:
|
||||
idx = list(parent).index(text_el)
|
||||
except ValueError:
|
||||
idx = None
|
||||
|
||||
# Insert in place to preserve drawing order
|
||||
for i, ne in enumerate(new_texts):
|
||||
if idx is not None:
|
||||
parent.insert(idx + i, ne)
|
||||
else:
|
||||
parent.append(ne)
|
||||
|
||||
# Remove the original <text>
|
||||
parent.remove(text_el)
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def _has_tspan_children(elem: ET.Element) -> bool:
|
||||
"""Return True if elem contains any nested <tspan> children (inline runs)."""
|
||||
return any(c.tag == f"{{{SVG_NS}}}tspan" for c in list(elem))
|
||||
|
||||
|
||||
def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
|
||||
"""Deep-copy a tspan as an inline run, preserving nested tspan structure, head text, and tail text.
|
||||
|
||||
When strip_line_attrs is True, x/y/dy are dropped because the enclosing
|
||||
<text> owns the resolved line position. Drop dx only from a positioned
|
||||
line starter, where compute_line_positions already consumed it; preserve
|
||||
dx on later inline runs.
|
||||
Nested tspans are copied recursively without stripping (they are already inline-only).
|
||||
"""
|
||||
new = ET.Element(f"{{{SVG_NS}}}tspan")
|
||||
consumed_dx = (
|
||||
strip_line_attrs
|
||||
and _positional_tspan_attribute(src) is not None
|
||||
)
|
||||
for k, v in src.attrib.items():
|
||||
if strip_line_attrs and k in ("x", "y", "dy"):
|
||||
continue
|
||||
if k == "dx" and consumed_dx:
|
||||
continue
|
||||
new.set(k, v)
|
||||
new.text = src.text
|
||||
for child in list(src):
|
||||
if child.tag == f"{{{SVG_NS}}}tspan":
|
||||
new.append(_copy_inline_tspan(child, strip_line_attrs=False))
|
||||
new.tail = src.tail
|
||||
return new
|
||||
|
||||
|
||||
def _create_text_element_from_line(
|
||||
text_el: ET.Element,
|
||||
lead_text: str | None,
|
||||
tspans: list[ET.Element],
|
||||
x: float | None,
|
||||
y: float | None,
|
||||
) -> ET.Element:
|
||||
"""
|
||||
Create a text element from a line's content (may contain leading text and multiple tspans).
|
||||
If there is only one tspan with no nested tspan children and no leading text, the line
|
||||
collapses to a plain <text>...</text>. Otherwise the tspan structure (including any
|
||||
nested inline tspans) is preserved so per-run formatting survives the flatten step.
|
||||
"""
|
||||
ne = ET.Element(f"{{{SVG_NS}}}text")
|
||||
|
||||
# Copy attrs from parent <text>
|
||||
copy_text_attrs(text_el, ne, exclude={"x", "y"})
|
||||
ne.set("x", format_number(x))
|
||||
ne.set("y", format_number(y))
|
||||
|
||||
# Transform
|
||||
p_tf = text_el.get("transform")
|
||||
if p_tf:
|
||||
ne.set("transform", p_tf)
|
||||
|
||||
# Compact path: a single tspan with no nested inline runs or parent-owned
|
||||
# tail collapses to <text>text</text>. A tail must remain outside the tspan
|
||||
# so its parent typography and xml:space semantics remain intact.
|
||||
if (
|
||||
not lead_text
|
||||
and len(tspans) == 1
|
||||
and not _has_tspan_children(tspans[0])
|
||||
and not tspans[0].tail
|
||||
):
|
||||
tspan = tspans[0]
|
||||
content = collect_text_content(tspan)
|
||||
|
||||
xml_space_attr = "{http://www.w3.org/XML/1998/namespace}space"
|
||||
xml_space = tspan.get(xml_space_attr)
|
||||
if xml_space is not None:
|
||||
ne.set(xml_space_attr, xml_space)
|
||||
|
||||
# Merge style
|
||||
merged_style = merge_styles(text_el.get("style"), tspan.get("style"))
|
||||
if merged_style:
|
||||
ne.set("style", merged_style)
|
||||
|
||||
# Override specific attributes from tspan
|
||||
for attr in TEXT_STYLE_ATTRS:
|
||||
cv = tspan.get(attr)
|
||||
if cv is not None:
|
||||
ne.set(attr, cv)
|
||||
|
||||
# Combine transform
|
||||
c_tf = tspan.get("transform")
|
||||
if p_tf and c_tf:
|
||||
ne.set("transform", f"{p_tf} {c_tf}")
|
||||
elif c_tf:
|
||||
ne.set("transform", c_tf)
|
||||
|
||||
ne.text = content
|
||||
else:
|
||||
# Preserve tspan structure, including nested inline tspans and tail text
|
||||
if lead_text:
|
||||
ne.text = lead_text
|
||||
|
||||
for tspan in tspans:
|
||||
ne.append(_copy_inline_tspan(tspan, strip_line_attrs=True))
|
||||
|
||||
return ne
|
||||
|
||||
|
||||
def process_svg_file(
|
||||
src_path: str,
|
||||
dst_path: str,
|
||||
merge_paragraphs: bool = False,
|
||||
) -> bool:
|
||||
"""Flatten eligible tspan lines in one SVG file."""
|
||||
try:
|
||||
tree = ET.parse(src_path)
|
||||
except ET.ParseError as e:
|
||||
print(f"[WARN] Failed to parse {src_path}: {e}")
|
||||
return False
|
||||
|
||||
changed = flatten_text_with_tspans(tree, merge_paragraphs=merge_paragraphs)
|
||||
|
||||
# Ensure destination directory exists
|
||||
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||
|
||||
# Write out XML without XML declaration to mimic input style
|
||||
tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml")
|
||||
return changed
|
||||
|
||||
|
||||
def _compute_default_out_base(inp: str) -> str:
|
||||
"""Compute default output path for directory or file input."""
|
||||
if os.path.isdir(inp):
|
||||
# Default: if input ends with svg_output, use sibling svg_output_flattext;
|
||||
# otherwise append _flattext to the directory name at the same level.
|
||||
head, tail = os.path.split(os.path.normpath(inp))
|
||||
if tail == "svg_output":
|
||||
return os.path.join(head, "svg_output_flattext")
|
||||
return inp.rstrip("/\\") + "_flattext"
|
||||
else:
|
||||
base, ext = os.path.splitext(inp)
|
||||
return base + "_flattext" + ext
|
||||
|
||||
|
||||
def _interactive_get_paths() -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Interactive mode: prompt the user for input path (SVG file or directory)
|
||||
and optional output path. Returns (inp, out_base) or (None, None) if cancelled.
|
||||
"""
|
||||
print("[Interactive mode] No arguments provided; running interactively.")
|
||||
print("Please enter the path to process (SVG file or directory containing SVGs).")
|
||||
print("Enter q to quit.\n")
|
||||
|
||||
while True:
|
||||
raw = input("Input path (file/dir): ").strip()
|
||||
if raw.lower() in {"q", "quit", "exit"} or raw == "":
|
||||
return None, None
|
||||
inp = os.path.expanduser(raw)
|
||||
if os.path.exists(inp):
|
||||
break
|
||||
print("Path does not exist. Please re-enter or enter q to quit.")
|
||||
|
||||
default_out = _compute_default_out_base(inp)
|
||||
if os.path.isdir(inp):
|
||||
prompt = f"Output directory [default: {default_out}]: "
|
||||
else:
|
||||
prompt = f"Output file [default: {default_out}]: "
|
||||
|
||||
raw_out = input(prompt).strip()
|
||||
out_base = os.path.expanduser(raw_out) if raw_out else default_out
|
||||
|
||||
return inp, out_base
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
# CLI parsing with optional interactive mode
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Flatten <tspan> lines into multiple <text> nodes for better compatibility.",
|
||||
add_help=True,
|
||||
)
|
||||
parser.add_argument("input", nargs="?", help="Input path: SVG file or directory")
|
||||
parser.add_argument("output", nargs="?", help="Optional output file/dir")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="Run in interactive prompt mode to input paths",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--merge-paragraphs",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Opt-in: merge mergeable paragraph blocks (same x, dy clustered "
|
||||
"around one base line-height) into a single <text> annotated for "
|
||||
"downstream multi-<a:p> conversion. Default off — every line-break "
|
||||
"tspan becomes its own <text>, preserving SVG pixel fidelity."
|
||||
),
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.interactive or not args.input:
|
||||
inp, out_base = _interactive_get_paths()
|
||||
if not inp:
|
||||
print("Cancelled. Usage: python3 scripts/svg_finalize/flatten_tspan.py <input_dir_or_svg> [output_dir]")
|
||||
sys.exit(0)
|
||||
else:
|
||||
inp = args.input
|
||||
out_base = args.output
|
||||
|
||||
if os.path.isdir(inp):
|
||||
# If output base not provided, create a sibling folder named svg_output_flattext for svg_output
|
||||
if out_base is None:
|
||||
out_base = _compute_default_out_base(inp)
|
||||
|
||||
total = 0
|
||||
changed_count = 0
|
||||
out_base_abs = os.path.abspath(out_base)
|
||||
for root, dirs, files in os.walk(inp):
|
||||
# Avoid recursing into the output directory when it lives under input
|
||||
dirs[:] = [d for d in dirs if os.path.abspath(os.path.join(root, d)) != out_base_abs]
|
||||
rel_root = os.path.relpath(root, inp)
|
||||
for f in files:
|
||||
if not f.lower().endswith(".svg"):
|
||||
continue
|
||||
src = os.path.join(root, f)
|
||||
dst = os.path.join(out_base, rel_root, f) if rel_root != "." else os.path.join(out_base, f)
|
||||
total += 1
|
||||
changed = process_svg_file(src, dst, merge_paragraphs=args.merge_paragraphs)
|
||||
if changed:
|
||||
changed_count += 1
|
||||
print(f"Processed {total} SVG(s). With <tspan> flattened: {changed_count}.")
|
||||
print(f"Output written to: {out_base}")
|
||||
else:
|
||||
src = inp
|
||||
if out_base is None:
|
||||
out_base = _compute_default_out_base(src)
|
||||
changed = process_svg_file(src, out_base, merge_paragraphs=args.merge_paragraphs)
|
||||
print(f"Written: {out_base} (flattened: {changed})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Legacy SVG Rounded Rectangle Diagnostic
|
||||
|
||||
Historical diagnostic that converts <rect> elements with rx/ry to equivalent
|
||||
<path> elements. It is not part of finalize_svg.py or the supported export
|
||||
workflow; PowerPoint's manual "Convert to Shape" behavior is not supported.
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory>
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s output
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final -o svg_rounded
|
||||
|
||||
Examples:
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg
|
||||
|
||||
Output:
|
||||
- Directory mode: outputs to svg_rounded/ subdirectory
|
||||
- File mode: outputs to <filename>_rounded.svg
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Tuple
|
||||
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
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def rect_to_rounded_path(
|
||||
x: float,
|
||||
y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
rx: float,
|
||||
ry: float,
|
||||
) -> str:
|
||||
"""
|
||||
Convert a rounded rectangle to an SVG path string.
|
||||
Uses elliptical arc commands to draw rounded corners.
|
||||
"""
|
||||
# Limit corner radius to half of width/height
|
||||
rx = min(rx, width / 2)
|
||||
ry = min(ry, height / 2)
|
||||
|
||||
# Calculate key points
|
||||
x1 = x + rx
|
||||
x2 = x + width - rx
|
||||
y1 = y + ry
|
||||
y2 = y + height - ry
|
||||
|
||||
# Build path
|
||||
path = (
|
||||
f"M{x1:.2f},{y:.2f} "
|
||||
f"H{x2:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x + width:.2f},{y1:.2f} "
|
||||
f"V{y2:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x2:.2f},{y + height:.2f} "
|
||||
f"H{x1:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x:.2f},{y2:.2f} "
|
||||
f"V{y1:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x1:.2f},{y:.2f} "
|
||||
f"Z"
|
||||
)
|
||||
|
||||
# Clean up excess decimals
|
||||
path = re.sub(r'\.00(?=\s|,|[A-Za-z]|$)', '', path)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def parse_float(val: str, default: float = 0.0) -> float:
|
||||
"""Safely parse a float value."""
|
||||
if not val:
|
||||
return default
|
||||
try:
|
||||
# Remove units
|
||||
val = re.sub(r'(px|pt|em|%|rem)$', '', val.strip())
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def process_svg(content: str, verbose: bool = False) -> Tuple[str, int]:
|
||||
"""
|
||||
Process SVG content, converting rounded rectangles to paths.
|
||||
Returns (processed content, conversion count).
|
||||
"""
|
||||
converted_count = 0
|
||||
|
||||
# Save original XML declaration
|
||||
xml_declaration = ''
|
||||
if content.strip().startswith('<?xml'):
|
||||
match = re.match(r'(<\?xml[^?]*\?>)', content)
|
||||
if match:
|
||||
xml_declaration = match.group(1) + '\n'
|
||||
|
||||
# Register SVG namespaces
|
||||
ET.register_namespace('', 'http://www.w3.org/2000/svg')
|
||||
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
|
||||
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError as e:
|
||||
if verbose:
|
||||
print(f" XML parse error: {e}")
|
||||
return content, 0
|
||||
|
||||
# Get default namespace
|
||||
ns = ''
|
||||
if root.tag.startswith('{'):
|
||||
ns = root.tag.split('}')[0] + '}'
|
||||
|
||||
def get_tag_name(tag: str) -> str:
|
||||
"""Get tag name without namespace."""
|
||||
if tag.startswith('{'):
|
||||
return tag.split('}')[1]
|
||||
return tag
|
||||
|
||||
def process_element(elem: ET.Element) -> None:
|
||||
"""Process a single element."""
|
||||
nonlocal converted_count
|
||||
tag_name = get_tag_name(elem.tag)
|
||||
|
||||
# Process rounded rectangles
|
||||
if tag_name == 'rect':
|
||||
rx = parse_float(elem.get('rx', '0'))
|
||||
ry = parse_float(elem.get('ry', '0'))
|
||||
|
||||
# If only one is specified, the other takes the same value
|
||||
if rx == 0 and ry > 0:
|
||||
rx = ry
|
||||
elif ry == 0 and rx > 0:
|
||||
ry = rx
|
||||
|
||||
if rx > 0 or ry > 0:
|
||||
x = parse_float(elem.get('x', '0'))
|
||||
y = parse_float(elem.get('y', '0'))
|
||||
width = parse_float(elem.get('width', '0'))
|
||||
height = parse_float(elem.get('height', '0'))
|
||||
|
||||
if width > 0 and height > 0:
|
||||
# Generate path
|
||||
path_d = rect_to_rounded_path(x, y, width, height, rx, ry)
|
||||
|
||||
# rect-specific attributes
|
||||
rect_attrs = {'x', 'y', 'width', 'height', 'rx', 'ry'}
|
||||
|
||||
# Change element to path
|
||||
elem.tag = ns + 'path' if ns else 'path'
|
||||
elem.set('d', path_d)
|
||||
|
||||
# Remove rect-specific attributes
|
||||
for attr in rect_attrs:
|
||||
if attr in elem.attrib:
|
||||
del elem.attrib[attr]
|
||||
|
||||
converted_count += 1
|
||||
if verbose:
|
||||
print(f" Converted rounded rect: rx={rx}, ry={ry}")
|
||||
|
||||
# Recursively process child elements
|
||||
for child in elem:
|
||||
process_element(child)
|
||||
|
||||
# Process all elements
|
||||
process_element(root)
|
||||
|
||||
# Convert back to string
|
||||
result = ET.tostring(root, encoding='unicode')
|
||||
|
||||
# Add XML declaration (if originally present)
|
||||
if xml_declaration:
|
||||
result = xml_declaration + result
|
||||
|
||||
return result, converted_count
|
||||
|
||||
|
||||
def process_svg_file(input_path: Path, output_path: Path, verbose: bool = False) -> tuple[bool, int]:
|
||||
"""Process a single SVG file."""
|
||||
try:
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
processed, count = process_svg(content, verbose)
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(processed)
|
||||
|
||||
return True, count
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" Error: {e}")
|
||||
return False, 0
|
||||
|
||||
|
||||
def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Path], str]:
|
||||
"""Find SVG files in a project."""
|
||||
dir_map = {
|
||||
'output': 'svg_output',
|
||||
'final': 'svg_final',
|
||||
'flat': 'svg_output_flattext',
|
||||
'final_flat': 'svg_final_flattext',
|
||||
}
|
||||
|
||||
dir_name = dir_map.get(source, source)
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if (project_path / 'svg_output').exists():
|
||||
dir_name = 'svg_output'
|
||||
svg_dir = project_path / dir_name
|
||||
elif project_path.is_dir():
|
||||
svg_dir = project_path
|
||||
dir_name = project_path.name
|
||||
|
||||
if not svg_dir.exists():
|
||||
return [], ''
|
||||
|
||||
return sorted(svg_dir.glob('*.svg')), dir_name
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master - Legacy SVG Rounded Rectangle Diagnostic',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
%(prog)s examples/ppt169_demo
|
||||
%(prog)s examples/ppt169_demo -s final
|
||||
%(prog)s examples/ppt169_demo/svg_output/01_cover.svg
|
||||
|
||||
What it does:
|
||||
Converts <rect> elements with rx/ry to equivalent <path> elements.
|
||||
Legacy diagnostic only; the standard pipeline preserves SVG rounded rectangles.
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument('path', type=str, help='SVG file or project directory path')
|
||||
parser.add_argument('-s', '--source', type=str, default='output',
|
||||
help='SVG source: output/final/flat/final_flat or subdirectory name (default: output)')
|
||||
parser.add_argument('-o', '--output', type=str, default='svg_rounded',
|
||||
help='Output directory name (default: svg_rounded)')
|
||||
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
|
||||
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = Path(args.path)
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: Path not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
verbose = args.verbose and not args.quiet
|
||||
quiet = args.quiet
|
||||
|
||||
if not quiet:
|
||||
print("PPT Master - Legacy SVG Rounded Rectangle Diagnostic")
|
||||
print("=" * 50)
|
||||
|
||||
total_converted = 0
|
||||
|
||||
if input_path.is_file() and input_path.suffix.lower() == '.svg':
|
||||
# Single file mode
|
||||
output_path = input_path.with_stem(input_path.stem + '_rounded')
|
||||
|
||||
if not quiet:
|
||||
print(f" Input: {input_path}")
|
||||
print(f" Output: {output_path}")
|
||||
print()
|
||||
|
||||
success, count = process_svg_file(input_path, output_path, verbose)
|
||||
total_converted = count
|
||||
|
||||
if success:
|
||||
if not quiet:
|
||||
print(f"[DONE] Saved: {output_path}")
|
||||
else:
|
||||
print(f"[FAIL] Processing failed")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
# Directory/project mode
|
||||
svg_files, source_dir = find_svg_files(input_path, args.source)
|
||||
|
||||
if not svg_files:
|
||||
print("Error: No SVG files found")
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = input_path / args.output
|
||||
|
||||
if not quiet:
|
||||
print(f" Project path: {input_path}")
|
||||
print(f" SVG source: {source_dir}")
|
||||
print(f" Output directory: {args.output}")
|
||||
print(f" File count: {len(svg_files)}")
|
||||
print()
|
||||
|
||||
success_count = 0
|
||||
for i, svg_file in enumerate(svg_files, 1):
|
||||
output_path = output_dir / svg_file.name
|
||||
|
||||
if verbose:
|
||||
print(f" [{i}/{len(svg_files)}] {svg_file.name}")
|
||||
|
||||
success, count = process_svg_file(svg_file, output_path, verbose)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
total_converted += count
|
||||
if not verbose and not quiet:
|
||||
print(f" [{i}/{len(svg_files)}] {svg_file.name} OK")
|
||||
else:
|
||||
if not quiet:
|
||||
print(f" [{i}/{len(svg_files)}] {svg_file.name} FAILED")
|
||||
|
||||
if not quiet:
|
||||
print()
|
||||
print(f"[DONE] Succeeded: {success_count}/{len(svg_files)}")
|
||||
print(f" Output directory: {output_dir}")
|
||||
|
||||
# Show statistics
|
||||
if not quiet:
|
||||
print()
|
||||
print(f"Conversion stats: rounded rect -> path: {total_converted}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user