Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -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., &amp; -> &)
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()