Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

373 lines
14 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - SVG Post-processing Tool (Unified Entry Point)
Processes SVG files from svg_output/ and produces the visual preview in
svg_final/, embedding supported raster/SVG assets. Native PPTX export continues
to read svg_output/ by default; svg_final/ may be opened directly or inserted
as an SVG image. EMF/WMF assets retain their external-reference exception.
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 align-images
Examples:
python3 scripts/finalize_svg.py projects/my_project
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
Processing options:
embed-icons - Expand project icons and static same-document <use>
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)
"""
import sys
import shutil
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
# Import finalize helpers from the internal package.
sys.path.insert(0, str(Path(__file__).parent))
from resource_paths import icon_search_dirs_for_project # noqa: E402
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
from svg_to_pptx.geometry_properties import (
GeometryStyleError,
materialize_inline_geometry_in_file,
)
from svg_to_pptx.use_expander import (
UseExpansionError,
expand_local_use_references_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 finalize_project(
project_dir: Path,
options: dict[str, bool],
dry_run: bool = False,
quiet: bool = False,
compress: bool = True,
max_dimension: int | None = 2560,
image_scale: float = 2.0,
) -> 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
image_scale: Target image pixels per SVG display pixel
"""
svg_output = project_dir / 'svg_output'
svg_final = project_dir / 'svg_final'
icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir)
# 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()
# Core normalization: downstream image/rect processors read XML geometry.
geometry_count = 0
for svg_file in svg_final.glob('*.svg'):
try:
geometry_count += materialize_inline_geometry_in_file(svg_file)
except (OSError, ET.ParseError, GeometryStyleError) as exc:
safe_print(
f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
)
return False
# Step 2: Expand project icons, then standard same-document use references.
if options.get('embed_icons'):
if not quiet:
safe_print("[1/3] Expanding icons + local use references...")
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
for svg_file in svg_final.glob('*.svg'):
try:
geometry_count += materialize_inline_geometry_in_file(svg_file)
except (OSError, ET.ParseError, GeometryStyleError) as exc:
safe_print(
f"[ERROR] {svg_file.name}: expanded icon geometry "
f"materialization failed: {exc}"
)
return False
local_use_count = 0
for svg_file in svg_final.glob('*.svg'):
try:
local_use_count += expand_local_use_references_in_file(svg_file)
except (OSError, ET.ParseError, UseExpansionError) as exc:
safe_print(
f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
)
return False
if not quiet:
if icons_count > 0:
safe_print(f" {icons_count} icon(s) embedded")
else:
safe_print(" No icons")
if local_use_count > 0:
safe_print(f" {local_use_count} local use reference(s) expanded")
else:
safe_print(" No local use references")
if not quiet and geometry_count:
safe_print(
f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
)
# 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/3] 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,
image_scale=image_scale,
)
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/3] 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")
# 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 align-images
%(prog)s projects/my_project -q # Quiet mode
Processing options (for --only):
embed-icons Expand project icons and static same-document <use>
align-images Align (slice/meet) + Base64-embed all <image> (single pass)
flatten-text Flatten text
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',
],
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', 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 before embedding')
parser.add_argument('--max-dimension', type=int, default=2560,
help='Downscale images exceeding this dimension on either axis (default: 2560)')
parser.add_argument('--image-scale', type=float, default=2.0,
help='Target image pixels per SVG display pixel (default: 2.0)')
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,
}
else:
# Execute all by default
options = {
'embed_icons': True,
'align_images': True,
'flatten_text': True,
}
if args.max_dimension < 1:
safe_print("[ERROR] --max-dimension must be >= 1")
sys.exit(1)
if args.image_scale < 1:
safe_print("[ERROR] --image-scale must be >= 1")
sys.exit(1)
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
compress=args.compress,
max_dimension=args.max_dimension,
image_scale=args.image_scale)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()