Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Legacy SVG Rounded Rectangle Diagnostic
|
||||
|
||||
Historical diagnostic that converts <rect> elements with rx/ry to equivalent
|
||||
<path> elements. It is not part of finalize_svg.py or the supported export
|
||||
workflow; PowerPoint's manual "Convert to Shape" behavior is not supported.
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory>
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s output
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final -o svg_rounded
|
||||
|
||||
Examples:
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg
|
||||
|
||||
Output:
|
||||
- Directory mode: outputs to svg_rounded/ subdirectory
|
||||
- File mode: outputs to <filename>_rounded.svg
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Tuple
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def rect_to_rounded_path(
|
||||
x: float,
|
||||
y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
rx: float,
|
||||
ry: float,
|
||||
) -> str:
|
||||
"""
|
||||
Convert a rounded rectangle to an SVG path string.
|
||||
Uses elliptical arc commands to draw rounded corners.
|
||||
"""
|
||||
# Limit corner radius to half of width/height
|
||||
rx = min(rx, width / 2)
|
||||
ry = min(ry, height / 2)
|
||||
|
||||
# Calculate key points
|
||||
x1 = x + rx
|
||||
x2 = x + width - rx
|
||||
y1 = y + ry
|
||||
y2 = y + height - ry
|
||||
|
||||
# Build path
|
||||
path = (
|
||||
f"M{x1:.2f},{y:.2f} "
|
||||
f"H{x2:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x + width:.2f},{y1:.2f} "
|
||||
f"V{y2:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x2:.2f},{y + height:.2f} "
|
||||
f"H{x1:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x:.2f},{y2:.2f} "
|
||||
f"V{y1:.2f} "
|
||||
f"A{rx:.2f},{ry:.2f} 0 0 1 {x1:.2f},{y:.2f} "
|
||||
f"Z"
|
||||
)
|
||||
|
||||
# Clean up excess decimals
|
||||
path = re.sub(r'\.00(?=\s|,|[A-Za-z]|$)', '', path)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def parse_float(val: str, default: float = 0.0) -> float:
|
||||
"""Safely parse a float value."""
|
||||
if not val:
|
||||
return default
|
||||
try:
|
||||
# Remove units
|
||||
val = re.sub(r'(px|pt|em|%|rem)$', '', val.strip())
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def process_svg(content: str, verbose: bool = False) -> Tuple[str, int]:
|
||||
"""
|
||||
Process SVG content, converting rounded rectangles to paths.
|
||||
Returns (processed content, conversion count).
|
||||
"""
|
||||
converted_count = 0
|
||||
|
||||
# Save original XML declaration
|
||||
xml_declaration = ''
|
||||
if content.strip().startswith('<?xml'):
|
||||
match = re.match(r'(<\?xml[^?]*\?>)', content)
|
||||
if match:
|
||||
xml_declaration = match.group(1) + '\n'
|
||||
|
||||
# Register SVG namespaces
|
||||
ET.register_namespace('', 'http://www.w3.org/2000/svg')
|
||||
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
|
||||
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError as e:
|
||||
if verbose:
|
||||
print(f" XML parse error: {e}")
|
||||
return content, 0
|
||||
|
||||
# Get default namespace
|
||||
ns = ''
|
||||
if root.tag.startswith('{'):
|
||||
ns = root.tag.split('}')[0] + '}'
|
||||
|
||||
def get_tag_name(tag: str) -> str:
|
||||
"""Get tag name without namespace."""
|
||||
if tag.startswith('{'):
|
||||
return tag.split('}')[1]
|
||||
return tag
|
||||
|
||||
def process_element(elem: ET.Element) -> None:
|
||||
"""Process a single element."""
|
||||
nonlocal converted_count
|
||||
tag_name = get_tag_name(elem.tag)
|
||||
|
||||
# Process rounded rectangles
|
||||
if tag_name == 'rect':
|
||||
rx = parse_float(elem.get('rx', '0'))
|
||||
ry = parse_float(elem.get('ry', '0'))
|
||||
|
||||
# If only one is specified, the other takes the same value
|
||||
if rx == 0 and ry > 0:
|
||||
rx = ry
|
||||
elif ry == 0 and rx > 0:
|
||||
ry = rx
|
||||
|
||||
if rx > 0 or ry > 0:
|
||||
x = parse_float(elem.get('x', '0'))
|
||||
y = parse_float(elem.get('y', '0'))
|
||||
width = parse_float(elem.get('width', '0'))
|
||||
height = parse_float(elem.get('height', '0'))
|
||||
|
||||
if width > 0 and height > 0:
|
||||
# Generate path
|
||||
path_d = rect_to_rounded_path(x, y, width, height, rx, ry)
|
||||
|
||||
# rect-specific attributes
|
||||
rect_attrs = {'x', 'y', 'width', 'height', 'rx', 'ry'}
|
||||
|
||||
# Change element to path
|
||||
elem.tag = ns + 'path' if ns else 'path'
|
||||
elem.set('d', path_d)
|
||||
|
||||
# Remove rect-specific attributes
|
||||
for attr in rect_attrs:
|
||||
if attr in elem.attrib:
|
||||
del elem.attrib[attr]
|
||||
|
||||
converted_count += 1
|
||||
if verbose:
|
||||
print(f" Converted rounded rect: rx={rx}, ry={ry}")
|
||||
|
||||
# Recursively process child elements
|
||||
for child in elem:
|
||||
process_element(child)
|
||||
|
||||
# Process all elements
|
||||
process_element(root)
|
||||
|
||||
# Convert back to string
|
||||
result = ET.tostring(root, encoding='unicode')
|
||||
|
||||
# Add XML declaration (if originally present)
|
||||
if xml_declaration:
|
||||
result = xml_declaration + result
|
||||
|
||||
return result, converted_count
|
||||
|
||||
|
||||
def process_svg_file(input_path: Path, output_path: Path, verbose: bool = False) -> tuple[bool, int]:
|
||||
"""Process a single SVG file."""
|
||||
try:
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
processed, count = process_svg(content, verbose)
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(processed)
|
||||
|
||||
return True, count
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" Error: {e}")
|
||||
return False, 0
|
||||
|
||||
|
||||
def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Path], str]:
|
||||
"""Find SVG files in a project."""
|
||||
dir_map = {
|
||||
'output': 'svg_output',
|
||||
'final': 'svg_final',
|
||||
'flat': 'svg_output_flattext',
|
||||
'final_flat': 'svg_final_flattext',
|
||||
}
|
||||
|
||||
dir_name = dir_map.get(source, source)
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if (project_path / 'svg_output').exists():
|
||||
dir_name = 'svg_output'
|
||||
svg_dir = project_path / dir_name
|
||||
elif project_path.is_dir():
|
||||
svg_dir = project_path
|
||||
dir_name = project_path.name
|
||||
|
||||
if not svg_dir.exists():
|
||||
return [], ''
|
||||
|
||||
return sorted(svg_dir.glob('*.svg')), dir_name
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master - Legacy SVG Rounded Rectangle Diagnostic',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
%(prog)s examples/ppt169_demo
|
||||
%(prog)s examples/ppt169_demo -s final
|
||||
%(prog)s examples/ppt169_demo/svg_output/01_cover.svg
|
||||
|
||||
What it does:
|
||||
Converts <rect> elements with rx/ry to equivalent <path> elements.
|
||||
Legacy diagnostic only; the standard pipeline preserves SVG rounded rectangles.
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument('path', type=str, help='SVG file or project directory path')
|
||||
parser.add_argument('-s', '--source', type=str, default='output',
|
||||
help='SVG source: output/final/flat/final_flat or subdirectory name (default: output)')
|
||||
parser.add_argument('-o', '--output', type=str, default='svg_rounded',
|
||||
help='Output directory name (default: svg_rounded)')
|
||||
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
|
||||
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = Path(args.path)
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: Path not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
verbose = args.verbose and not args.quiet
|
||||
quiet = args.quiet
|
||||
|
||||
if not quiet:
|
||||
print("PPT Master - Legacy SVG Rounded Rectangle Diagnostic")
|
||||
print("=" * 50)
|
||||
|
||||
total_converted = 0
|
||||
|
||||
if input_path.is_file() and input_path.suffix.lower() == '.svg':
|
||||
# Single file mode
|
||||
output_path = input_path.with_stem(input_path.stem + '_rounded')
|
||||
|
||||
if not quiet:
|
||||
print(f" Input: {input_path}")
|
||||
print(f" Output: {output_path}")
|
||||
print()
|
||||
|
||||
success, count = process_svg_file(input_path, output_path, verbose)
|
||||
total_converted = count
|
||||
|
||||
if success:
|
||||
if not quiet:
|
||||
print(f"[DONE] Saved: {output_path}")
|
||||
else:
|
||||
print(f"[FAIL] Processing failed")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
# Directory/project mode
|
||||
svg_files, source_dir = find_svg_files(input_path, args.source)
|
||||
|
||||
if not svg_files:
|
||||
print("Error: No SVG files found")
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = input_path / args.output
|
||||
|
||||
if not quiet:
|
||||
print(f" Project path: {input_path}")
|
||||
print(f" SVG source: {source_dir}")
|
||||
print(f" Output directory: {args.output}")
|
||||
print(f" File count: {len(svg_files)}")
|
||||
print()
|
||||
|
||||
success_count = 0
|
||||
for i, svg_file in enumerate(svg_files, 1):
|
||||
output_path = output_dir / svg_file.name
|
||||
|
||||
if verbose:
|
||||
print(f" [{i}/{len(svg_files)}] {svg_file.name}")
|
||||
|
||||
success, count = process_svg_file(svg_file, output_path, verbose)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
total_converted += count
|
||||
if not verbose and not quiet:
|
||||
print(f" [{i}/{len(svg_files)}] {svg_file.name} OK")
|
||||
else:
|
||||
if not quiet:
|
||||
print(f" [{i}/{len(svg_files)}] {svg_file.name} FAILED")
|
||||
|
||||
if not quiet:
|
||||
print()
|
||||
print(f"[DONE] Succeeded: {success_count}/{len(svg_files)}")
|
||||
print(f" Output directory: {output_dir}")
|
||||
|
||||
# Show statistics
|
||||
if not quiet:
|
||||
print()
|
||||
print(f"Conversion stats: rounded rect -> path: {total_converted}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user