#!/usr/bin/env python3 """ PPT Master - Project Utilities Module Provides common functions for project information parsing and validation, reusable by other tools. """ import argparse import re from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Tuple # Canvas format definitions (unified source) try: from config import CANVAS_FORMATS except ImportError: # Fallback: maintain minimal usable configuration to avoid runtime crashes CANVAS_FORMATS = { 'ppt169': { 'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720', 'aspect_ratio': '16:9' }, 'ppt43': { 'name': 'PPT 4:3', 'dimensions': '1024×768', 'viewbox': '0 0 1024 768', 'aspect_ratio': '4:3' }, 'wechat': { 'name': 'WeChat Article Header', 'dimensions': '900×383', 'viewbox': '0 0 900 383', 'aspect_ratio': '2.35:1' }, 'xiaohongshu': { 'name': '小红书', 'dimensions': '1242×1660', 'viewbox': '0 0 1242 1660', 'aspect_ratio': '3:4' }, 'moments': { 'name': 'Moments/Instagram', 'dimensions': '1080×1080', 'viewbox': '0 0 1080 1080', 'aspect_ratio': '1:1' }, 'story': { 'name': 'Story/Vertical', 'dimensions': '1080×1920', 'viewbox': '0 0 1080 1920', 'aspect_ratio': '9:16' }, 'banner': { 'name': 'Horizontal Banner', 'dimensions': '1920×1080', 'viewbox': '0 0 1920 1080', 'aspect_ratio': '16:9' }, 'a4': { 'name': 'A4 Print', 'dimensions': '1240×1754', 'viewbox': '0 0 1240 1754', 'aspect_ratio': '√2:1' } } CANVAS_FORMAT_ALIASES = { 'xhs': 'xiaohongshu', 'wechat_moment': 'moments', 'wechat-moment': 'moments', '朋友圈': 'moments', '小红书': 'xiaohongshu', } def normalize_canvas_format(format_key: str) -> str: """Normalize canvas format key name (supports common aliases).""" if not format_key: return '' key = format_key.strip().lower() return CANVAS_FORMAT_ALIASES.get(key, key) def parse_project_name(dir_name: str) -> Dict[str, str]: """ Parse project information from the project directory name. Args: dir_name: Project directory name Returns: Dictionary containing name, format, date """ result = { 'name': dir_name, 'format': 'unknown', 'format_name': 'Unknown format', 'date': 'unknown', 'date_formatted': 'Unknown date' } dir_name_lower = dir_name.lower() # Extract date (format: _YYYYMMDD) date_match = re.search(r'_(\d{8})$', dir_name) if date_match: date_str = date_match.group(1) result['date'] = date_str try: date_obj = datetime.strptime(date_str, '%Y%m%d') result['date_formatted'] = date_obj.strftime('%Y-%m-%d') except ValueError: pass # Prefer parsing standard format: name_format_YYYYMMDD full_match = re.match(r'^(?P.+)_(?P[a-z0-9_-]+)_(?P\d{8})$', dir_name_lower) if full_match: raw_format = full_match.group('format') normalized_format = normalize_canvas_format(raw_format) if normalized_format in CANVAS_FORMATS: result['format'] = normalized_format result['format_name'] = CANVAS_FORMATS[normalized_format]['name'] result['name'] = dir_name[:len(full_match.group('name'))] return result # Fallback: only match trailing `_format` to avoid deleting parts of the project name sorted_formats = sorted(CANVAS_FORMATS.keys(), key=len, reverse=True) for fmt_key in sorted_formats: if re.search(rf'_{re.escape(fmt_key)}(?:_\d{{8}})?$', dir_name_lower): result['format'] = fmt_key result['format_name'] = CANVAS_FORMATS[fmt_key]['name'] break # Extract project name (only remove trailing date and format suffix) name = re.sub(r'_\d{8}$', '', dir_name) if result['format'] != 'unknown': name = re.sub(rf'_{re.escape(result["format"])}$', '', name, flags=re.IGNORECASE) result['name'] = name return result def get_project_info(project_path: str) -> Dict: """ Get detailed project information. Args: project_path: Project directory path Returns: Project information dictionary """ project_path = Path(project_path) # Parse directory name parsed = parse_project_name(project_path.name) info = { 'path': str(project_path), 'dir_name': project_path.name, 'name': parsed['name'], 'format': parsed['format'], 'format_name': parsed['format_name'], 'date': parsed['date'], 'date_formatted': parsed['date_formatted'], 'exists': project_path.exists(), 'svg_count': 0, 'has_spec': False, 'has_readme': False, 'has_source': False, 'source_count': 0, 'spec_file': None, 'svg_files': [] } if not project_path.exists(): return info # Check README.md info['has_readme'] = (project_path / 'README.md').exists() # Check design specification files (current standard + legacy names) spec_files = ['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md'] for spec_file in spec_files: if (project_path / spec_file).exists(): info['has_spec'] = True info['spec_file'] = spec_file break # Check source documents legacy_source_file = project_path / '来源文档.md' sources_dir = project_path / 'sources' info['has_source'] = legacy_source_file.exists() or sources_dir.exists() if sources_dir.exists(): info['source_count'] = len([p for p in sources_dir.iterdir() if p.is_file()]) # Count SVG files svg_output = project_path / 'svg_output' if svg_output.exists(): svg_files = sorted(svg_output.glob('*.svg')) info['svg_count'] = len(svg_files) info['svg_files'] = [f.name for f in svg_files] # Get canvas format details if info['format'] in CANVAS_FORMATS: info['canvas_info'] = CANVAS_FORMATS[info['format']] return info def validate_project_structure(project_path: str, verbose: bool = False) -> Tuple[bool, List[str], List[str]]: """ Validate project structure completeness. Args: project_path: Project directory path verbose: Whether to show detailed fix suggestions Returns: (is_valid, error_list, warning_list) """ project_path = Path(project_path) errors = [] warnings = [] # Try to import error helper try: from error_helper import ErrorHelper use_helper = True except ImportError: use_helper = False # Check if directory exists if not project_path.exists(): msg = f"Project directory does not exist: {project_path}" if use_helper and verbose: msg += "\n" + ErrorHelper.format_error_message('missing_directory', {'project_path': str(project_path)}) errors.append(msg) return False, errors, warnings if not project_path.is_dir(): errors.append(f"Not a valid directory: {project_path}") return False, errors, warnings # Check required files if not (project_path / 'README.md').exists(): msg = "Missing required file: README.md" if use_helper and verbose: msg += "\n" + ErrorHelper.format_error_message('missing_readme', {'project_path': str(project_path)}) errors.append(msg) # Check design specification file spec_files = ['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md'] has_spec = any((project_path / f).exists() for f in spec_files) if not has_spec: msg = "Missing design specification file (suggested filename: design_spec.md)" if use_helper and verbose: msg += "\n" + ErrorHelper.format_error_message('missing_spec') warnings.append(msg) # Check svg_output directory svg_output = project_path / 'svg_output' if not svg_output.exists(): msg = "Missing svg_output directory" if use_helper and verbose: msg += "\n" + \ ErrorHelper.format_error_message('missing_svg_output') errors.append(msg) elif not svg_output.is_dir(): errors.append("svg_output is not a directory") else: # Check for SVG files svg_files = list(svg_output.glob('*.svg')) if not svg_files: msg = "svg_output directory is empty, no SVG files found" if use_helper and verbose: msg += "\n" + \ ErrorHelper.format_error_message('empty_svg_output') warnings.append(msg) else: # Validate SVG file naming (consistent with project_manager.py) for svg_file in svg_files: if not re.match(r'^(slide_\d+_\w+|P?\d+_.+)\.svg$', svg_file.name): msg = f"Non-standard SVG file naming: {svg_file.name}" if use_helper and verbose: msg += "\n" + ErrorHelper.format_error_message('invalid_svg_naming', {'file_name': svg_file.name}) warnings.append(msg) # Check directory naming format dir_name = project_path.name if not re.search(r'_\d{8}$', dir_name): msg = f"Directory name missing date suffix (_YYYYMMDD): {dir_name}" if use_helper and verbose: msg += "\n" + \ ErrorHelper.format_error_message('missing_date_suffix') warnings.append(msg) is_valid = len(errors) == 0 return is_valid, errors, warnings def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] = None) -> List[str]: """ Validate the viewBox settings of SVG files. Args: svg_files: List of SVG files expected_format: Expected canvas format (e.g. 'ppt169') Returns: List of warnings """ warnings = [] viewbox_pattern = re.compile(r'viewBox="([^"]+)"') viewboxes = set() # Determine expected viewBox expected_viewbox = None if expected_format and expected_format in CANVAS_FORMATS: expected_viewbox = CANVAS_FORMATS[expected_format]['viewbox'] for svg_file in svg_files[:10]: # Check first 10 files try: with open(svg_file, 'r', encoding='utf-8') as f: content = f.read(2000) # Only read first 2000 characters match = viewbox_pattern.search(content) if match: viewbox = match.group(1) viewboxes.add(viewbox) # If expected format is specified, check for match if expected_viewbox and viewbox != expected_viewbox: warnings.append( f"{svg_file.name}: viewBox '{viewbox}' does not match expected format " f"'{expected_format}' (expected: '{expected_viewbox}')" ) else: warnings.append(f"{svg_file.name}: viewBox attribute not found") except Exception as e: warnings.append(f"{svg_file.name}: Failed to read - {e}") # Check for multiple different viewBoxes if len(viewboxes) > 1: warnings.append(f"Multiple different viewBox settings detected: {viewboxes}") return warnings def find_all_projects(base_dir: str) -> List[Path]: """ Find all projects under the specified directory. Args: base_dir: Base directory path Returns: List of project directories """ base_path = Path(base_dir) if not base_path.exists(): return [] projects = [] for item in base_path.iterdir(): if item.is_dir() and not item.name.startswith('.'): # Check if it's a valid project directory (contains svg_output or design spec) has_svg_output = (item / 'svg_output').exists() has_spec = any((item / f).exists() for f in ['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md']) if has_svg_output or has_spec: projects.append(item) return sorted(projects) def format_file_size(size_bytes: int) -> str: """ Format file size. Args: size_bytes: File size in bytes Returns: Formatted file size string """ for unit in ['B', 'KB', 'MB', 'GB']: if size_bytes < 1024.0: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024.0 return f"{size_bytes:.1f} TB" def get_project_stats(project_path: str) -> Dict: """ Get project statistics. Args: project_path: Project directory path Returns: Statistics dictionary """ project_path = Path(project_path) stats = { 'total_files': 0, 'svg_files': 0, 'md_files': 0, 'html_files': 0, 'total_size': 0, 'svg_size': 0 } if not project_path.exists(): return stats for file in project_path.rglob('*'): if file.is_file(): stats['total_files'] += 1 file_size = file.stat().st_size stats['total_size'] += file_size if file.suffix == '.svg': stats['svg_files'] += 1 stats['svg_size'] += file_size elif file.suffix == '.md': stats['md_files'] += 1 elif file.suffix == '.html': stats['html_files'] += 1 return stats def build_parser() -> argparse.ArgumentParser: """Build the command-line parser for the diagnostic entry point.""" parser = argparse.ArgumentParser(description="Inspect and validate a PPT Master project.") parser.add_argument("project_path", help="Project directory") return parser def main(argv: list[str] | None = None) -> int: """Run the diagnostic CLI entry point.""" parser = build_parser() args = parser.parse_args(argv) project_path = args.project_path info = get_project_info(project_path) print(f"\nProject Info: {info['dir_name']}") print("=" * 60) print(f"Project Name: {info['name']}") print(f"Canvas Format: {info['format_name']} ({info['format']})") print(f"Created: {info['date_formatted']}") print(f"SVG Files: {info['svg_count']}") print(f"README: {'Yes' if info['has_readme'] else 'No'}") print(f"Design Spec: {'Yes' if info['has_spec'] else 'No'}") print("\nValidation Results:") print("-" * 60) is_valid, errors, warnings = validate_project_structure(project_path) if errors: print("[ERROR]") for error in errors: print(f" - {error}") if warnings: print("[WARN]") for warning in warnings: print(f" - {warning}") if is_valid and not warnings: print("[OK] Project structure is complete, no issues found") return 0 if is_valid else 1 if __name__ == '__main__': raise SystemExit(main())