zcbot/skills/ppt/scripts/finalize_svg.py

350 lines
13 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - SVG Post-processing Tool (Unified Entry Point)
Processes SVG files from svg_output/ and outputs them to svg_final/.
By default, all processing steps are executed. You can also specify
individual steps via arguments.
Architecture note: this module's outputs feed svg_final/ on disk AND its
sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
are memory-reused by svg_to_pptx during native conversion. Deleting any
step here may also break native pptx output, not just svg_final/.
See docs/technical-design.md "Post-Processing Pipeline" before modifying.
Usage:
# Execute all processing steps (recommended)
python3 scripts/finalize_svg.py <project_directory>
# Execute only specific steps
python3 scripts/finalize_svg.py <project_directory> --only embed-icons fix-rounded
Examples:
python3 scripts/finalize_svg.py projects/my_project
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
Processing options:
embed-icons - Replace <use data-icon="..."/> with actual icon SVG
align-images - Align (slice/meet) and Base64-embed all <image> in one pass.
Replaces the former crop-images + fix-aspect + embed-images
trio. The old names remain accepted as aliases for the
merged step, so existing --only invocations keep working.
flatten-text - Convert <tspan> to independent <text> (for special renderers)
fix-rounded - Convert <rect rx="..."/> to <path> (for PPT shape conversion)
"""
import os
import sys
try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import shutil
import argparse
from pathlib import Path
# Import finalize helpers from the internal package.
sys.path.insert(0, str(Path(__file__).parent))
from svg_finalize.align_embed_images import (
align_and_embed_images_in_svg,
count_office_vector_refs_in_svg,
)
from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
def safe_print(text: str) -> None:
"""Print text while tolerating Windows terminal encoding limits."""
try:
print(text)
except UnicodeEncodeError:
replacements = {
chr(0x23F3): "[..]",
chr(0x2705): "[DONE]",
chr(0x274C): "[ERROR]",
chr(0x26A0) + chr(0xFE0F): "[WARN]",
chr(0x1F4C1): "[DIR]",
chr(0x1F4C4): "[FILE]",
chr(0x1F4E6): "[OK]",
}
for source, target in replacements.items():
text = text.replace(source, target)
print(text)
def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
"""Flatten text in a single SVG file (in-place modification)"""
try:
from svg_finalize.flatten_tspan import flatten_text_with_tspans
from xml.etree import ElementTree as ET
tree = ET.parse(str(svg_file))
changed = flatten_text_with_tspans(tree)
if changed:
tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
if verbose:
safe_print(f" [OK] {svg_file.name}: text flattened")
return changed
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return False
def process_rounded_rect(svg_file: Path, verbose: bool = False) -> int:
"""Convert rounded rectangles in a single SVG file (in-place modification)"""
try:
from svg_finalize.svg_rect_to_path import process_svg
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose=False)
if count > 0:
with open(svg_file, 'w', encoding='utf-8') as f:
f.write(processed)
if verbose:
safe_print(f" [OK] {svg_file.name}: {count} rounded rectangle(s)")
return count
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return 0
def finalize_project(
project_dir: Path,
options: dict[str, bool],
dry_run: bool = False,
quiet: bool = False,
compress: bool = False,
max_dimension: int | None = None,
) -> bool:
"""
Finalize SVG files in the project
Args:
project_dir: Project directory path
options: Processing options dictionary
dry_run: Preview only, do not execute
quiet: Quiet mode, reduce output
compress: Compress images before embedding
max_dimension: Downscale images exceeding this dimension
"""
from project_utils import svg_final_dir
svg_output = project_dir / 'svg_output'
svg_final = svg_final_dir(project_dir) # <project>/.build/svg_final (hidden, regenerable)
# Project-first: embed from the deck's own icons/ (synced library icons +
# any custom icons), falling back to the global library per-icon.
global_icons_dir = Path(__file__).parent.parent / 'templates' / 'icons'
project_icons_dir = project_dir / 'icons'
icons_dir = project_icons_dir if project_icons_dir.is_dir() else global_icons_dir
icons_fallback_dir = global_icons_dir if icons_dir != global_icons_dir else None
# Check if svg_output exists
if not svg_output.exists():
safe_print(f"[ERROR] svg_output directory not found: {svg_output}")
return False
# Get list of SVG files
svg_files = list(svg_output.glob('*.svg'))
if not svg_files:
safe_print(f"[ERROR] No SVG files in svg_output")
return False
if not quiet:
print()
safe_print(f"[DIR] Project: {project_dir.name}")
safe_print(f"[FILE] {len(svg_files)} SVG file(s)")
if dry_run:
safe_print("[PREVIEW] Preview mode, no operations will be performed")
return True
# Step 1: Copy directory
if svg_final.exists():
shutil.rmtree(svg_final)
shutil.copytree(svg_output, svg_final)
if not quiet:
print()
# Step 2: Embed icons
if options.get('embed_icons'):
if not quiet:
safe_print("[1/4] Embedding icons...")
icons_count = 0
for svg_file in svg_final.glob('*.svg'):
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False, fallback_dir=icons_fallback_dir)
icons_count += count
if not quiet:
if icons_count > 0:
safe_print(f" {icons_count} icon(s) embedded")
else:
safe_print(" No icons")
# Step 3: Align (slice/meet) and Base64-embed all <image> in one pass.
# Replaces the former crop-images / fix-aspect / embed-images trio: the
# spatial transform (slice → crop, meet → fit-box) and the asset embed
# are mutually exclusive branches per image, sequenced together so each
# SVG is only parsed and serialized once and each bitmap is only read
# from disk once.
if options.get('align_images'):
if not quiet:
safe_print("[2/4] Aligning + embedding images...")
img_count = 0
img_errors = 0
office_vector_count = 0
for svg_file in svg_final.glob('*.svg'):
office_vector_count += count_office_vector_refs_in_svg(svg_file)
count, errs = align_and_embed_images_in_svg(
svg_file,
dry_run=False,
verbose=False,
compress=compress,
max_dimension=max_dimension,
)
img_count += count
img_errors += errs
if not quiet:
if img_count > 0:
msg = f" {img_count} image(s) aligned + embedded"
if img_errors:
msg += f" ({img_errors} error(s))"
safe_print(msg)
if office_vector_count:
safe_print(
f" {office_vector_count} Office vector(s) left external "
"for native PPTX passthrough"
)
elif office_vector_count:
safe_print(
f" {office_vector_count} Office vector(s) left external "
"for native PPTX passthrough"
)
else:
safe_print(" No images")
# Step 4: Flatten text
if options.get('flatten_text'):
if not quiet:
safe_print("[3/4] Flattening text...")
flatten_count = 0
for svg_file in svg_final.glob('*.svg'):
if process_flatten_text(svg_file, verbose=False):
flatten_count += 1
if not quiet:
if flatten_count > 0:
safe_print(f" {flatten_count} file(s) processed")
else:
safe_print(" No processing needed")
# Step 5: Convert rounded rects to Path
if options.get('fix_rounded'):
if not quiet:
safe_print("[4/4] Converting rounded rects to Path...")
rounded_count = 0
for svg_file in svg_final.glob('*.svg'):
count = process_rounded_rect(svg_file, verbose=False)
rounded_count += count
if not quiet:
if rounded_count > 0:
safe_print(f" {rounded_count} rounded rectangle(s) converted")
else:
safe_print(" No rounded rectangles")
# Done
if not quiet:
print()
safe_print("[OK] Done!")
print()
print("Next steps:")
print(f" python scripts/svg_to_pptx.py \"{project_dir}\"")
return True
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='PPT Master - SVG Post-processing Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/my_project # Execute all processing (default)
%(prog)s projects/my_project --only embed-icons fix-rounded
%(prog)s projects/my_project -q # Quiet mode
Processing options (for --only):
embed-icons Embed icons
align-images Align (slice/meet) + Base64-embed all <image> (single pass)
flatten-text Flatten text
fix-rounded Convert rounded rects to Path
Aliases (still accepted):
crop-images, fix-aspect, embed-images → all map to align-images
'''
)
parser.add_argument('project_dir', type=Path, help='Project directory path')
parser.add_argument(
'--only', nargs='+', metavar='OPTION',
choices=[
'embed-icons',
'align-images',
# Backwards-compatible aliases — all three map to align-images now.
'crop-images', 'fix-aspect', 'embed-images',
'flatten-text', 'fix-rounded',
],
help=('Execute only specified processing steps (default: all). '
'crop-images / fix-aspect / embed-images are accepted as '
'aliases for the merged align-images step.'),
)
parser.add_argument('--dry-run', '-n', action='store_true',
help='Preview only, do not execute')
parser.add_argument('--quiet', '-q', action='store_true',
help='Quiet mode, reduce output')
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 not args.project_dir.exists():
safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}")
sys.exit(1)
# Aliases: any of crop-images / fix-aspect / embed-images implies the
# merged align-images step. Older invocations stay valid.
_ALIGN_ALIASES = {'align-images', 'crop-images', 'fix-aspect', 'embed-images'}
# Determine processing options
if args.only:
only = set(args.only)
options = {
'embed_icons': 'embed-icons' in only,
'align_images': bool(only & _ALIGN_ALIASES),
'flatten_text': 'flatten-text' in only,
'fix_rounded': 'fix-rounded' in only,
}
else:
# Execute all by default
options = {
'embed_icons': True,
'align_images': True,
'flatten_text': True,
'fix_rounded': True,
}
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
compress=args.compress,
max_dimension=args.max_dimension)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()