返回 ppt-master
project_utils.py
根目录 / skills / ppt-master / scripts / project_utils.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Project Utilities Module
4
5 Provides common functions for project information parsing and validation,
6 reusable by other tools.
7 """
8
9 import argparse
10 import re
11 from pathlib import Path
12 from datetime import datetime
13 from typing import Dict, List, Optional, Tuple
14
15 from console_encoding import configure_utf8_stdio
16 from slide_roster import discover_slide_svgs
17 from svg_to_pptx.canvas_contract import (
18 CanvasContractError,
19 parse_project_viewbox,
20 read_project_viewbox,
21 )
22
23 configure_utf8_stdio()
24
25 # Canvas format definitions (unified source)
26 try:
27 from config import CANVAS_FORMATS
28 except ImportError:
29 # Fallback: maintain minimal usable configuration to avoid runtime crashes
30 CANVAS_FORMATS = {
31 'ppt169': {
32 'name': 'PPT 16:9',
33 'dimensions': '1280×720',
34 'viewbox': '0 0 1280 720',
35 'aspect_ratio': '16:9'
36 },
37 'ppt43': {
38 'name': 'PPT 4:3',
39 'dimensions': '1024×768',
40 'viewbox': '0 0 1024 768',
41 'aspect_ratio': '4:3'
42 },
43 'wechat': {
44 'name': 'WeChat Article Header',
45 'dimensions': '900×383',
46 'viewbox': '0 0 900 383',
47 'aspect_ratio': '2.35:1'
48 },
49 'xiaohongshu': {
50 'name': '小红书',
51 'dimensions': '1242×1660',
52 'viewbox': '0 0 1242 1660',
53 'aspect_ratio': '3:4'
54 },
55 'moments': {
56 'name': 'Moments/Instagram',
57 'dimensions': '1080×1080',
58 'viewbox': '0 0 1080 1080',
59 'aspect_ratio': '1:1'
60 },
61 'story': {
62 'name': 'Story/Vertical',
63 'dimensions': '1080×1920',
64 'viewbox': '0 0 1080 1920',
65 'aspect_ratio': '9:16'
66 },
67 'banner': {
68 'name': 'Horizontal Banner',
69 'dimensions': '1920×1080',
70 'viewbox': '0 0 1920 1080',
71 'aspect_ratio': '16:9'
72 },
73 'a4': {
74 'name': 'A4 Print',
75 'dimensions': '1240×1754',
76 'viewbox': '0 0 1240 1754',
77 'aspect_ratio': '√2:1'
78 }
79 }
80
81 CANVAS_FORMAT_ALIASES = {
82 'xhs': 'xiaohongshu',
83 'wechat_moment': 'moments',
84 'wechat-moment': 'moments',
85 '朋友圈': 'moments',
86 '小红书': 'xiaohongshu',
87 }
88
89 _DESIGN_SPEC_NAMES = (
90 'design_spec.md',
91 '设计规范与内容大纲.md',
92 'design_specification.md',
93 '设计规范.md',
94 )
95 _COMMUNICATION_TRACE_KEYS = (
96 'audience',
97 'objective',
98 'core_message',
99 )
100
101
102 def normalize_canvas_format(format_key: str) -> str:
103 """Normalize canvas format key name (supports common aliases)."""
104 if not format_key:
105 return ''
106 key = format_key.strip().lower()
107 return CANVAS_FORMAT_ALIASES.get(key, key)
108
109
110 def parse_project_name(dir_name: str) -> Dict[str, str]:
111 """
112 Parse project information from the project directory name.
113
114 Args:
115 dir_name: Project directory name
116
117 Returns:
118 Dictionary containing name, format, date
119 """
120 result = {
121 'name': dir_name,
122 'format': 'unknown',
123 'format_name': 'Unknown format',
124 'date': 'unknown',
125 'date_formatted': 'Unknown date'
126 }
127
128 dir_name_lower = dir_name.lower()
129
130 # Extract date (format: _YYYYMMDD)
131 date_match = re.search(r'_(\d{8})$', dir_name)
132 if date_match:
133 date_str = date_match.group(1)
134 result['date'] = date_str
135 try:
136 date_obj = datetime.strptime(date_str, '%Y%m%d')
137 result['date_formatted'] = date_obj.strftime('%Y-%m-%d')
138 except ValueError:
139 pass
140
141 # Prefer parsing standard format: name_format_YYYYMMDD
142 full_match = re.match(r'^(?P<name>.+)_(?P<format>[a-z0-9_-]+)_(?P<date>\d{8})$', dir_name_lower)
143 if full_match:
144 raw_format = full_match.group('format')
145 normalized_format = normalize_canvas_format(raw_format)
146 if normalized_format in CANVAS_FORMATS:
147 result['format'] = normalized_format
148 result['format_name'] = CANVAS_FORMATS[normalized_format]['name']
149 result['name'] = dir_name[:len(full_match.group('name'))]
150 return result
151
152 # Fallback: only match trailing `_format` to avoid deleting parts of the project name
153 sorted_formats = sorted(CANVAS_FORMATS.keys(), key=len, reverse=True)
154 for fmt_key in sorted_formats:
155 if re.search(rf'_{re.escape(fmt_key)}(?:_\d{{8}})?$', dir_name_lower):
156 result['format'] = fmt_key
157 result['format_name'] = CANVAS_FORMATS[fmt_key]['name']
158 break
159
160 # Extract project name (only remove trailing date and format suffix)
161 name = re.sub(r'_\d{8}$', '', dir_name)
162 if result['format'] != 'unknown':
163 name = re.sub(rf'_{re.escape(result["format"])}$', '', name, flags=re.IGNORECASE)
164 result['name'] = name
165
166 return result
167
168
169 def get_project_info(project_path: str) -> Dict:
170 """
171 Get detailed project information.
172
173 Args:
174 project_path: Project directory path
175
176 Returns:
177 Project information dictionary
178 """
179 project_path = Path(project_path)
180
181 # Parse directory name
182 parsed = parse_project_name(project_path.name)
183
184 info = {
185 'path': str(project_path),
186 'dir_name': project_path.name,
187 'name': parsed['name'],
188 'format': parsed['format'],
189 'format_name': parsed['format_name'],
190 'date': parsed['date'],
191 'date_formatted': parsed['date_formatted'],
192 'exists': project_path.exists(),
193 'svg_count': 0,
194 'has_spec': False,
195 'has_readme': False,
196 'has_source': False,
197 'source_count': 0,
198 'spec_file': None,
199 'svg_files': []
200 }
201
202 if not project_path.exists():
203 return info
204
205 # Check README.md
206 info['has_readme'] = (project_path / 'README.md').exists()
207
208 # Check design specification files (current standard + legacy names)
209 for spec_file in _DESIGN_SPEC_NAMES:
210 if (project_path / spec_file).exists():
211 info['has_spec'] = True
212 info['spec_file'] = spec_file
213 break
214
215 # Check source documents
216 legacy_source_file = project_path / '来源文档.md'
217 sources_dir = project_path / 'sources'
218 info['has_source'] = legacy_source_file.exists() or sources_dir.exists()
219
220 if sources_dir.exists():
221 info['source_count'] = len([p for p in sources_dir.iterdir() if p.is_file()])
222
223 # Count SVG files
224 svg_output = project_path / 'svg_output'
225 if svg_output.exists():
226 svg_files = discover_slide_svgs(svg_output)
227 info['svg_count'] = len(svg_files)
228 info['svg_files'] = [f.name for f in svg_files]
229
230 # Get canvas format details
231 if info['format'] in CANVAS_FORMATS:
232 info['canvas_info'] = CANVAS_FORMATS[info['format']]
233
234 return info
235
236
237 def validate_communication_trace(
238 project_path: str | Path,
239 *,
240 check_lock: bool = True,
241 check_design: bool = True,
242 ) -> List[str]:
243 """Validate either or both communication-trace surfaces."""
244 root = Path(project_path)
245 design_spec = next(
246 (root / name for name in _DESIGN_SPEC_NAMES if (root / name).is_file()),
247 None,
248 )
249 if design_spec is None or not (check_lock or check_design):
250 return []
251
252 errors: List[str] = []
253 lock_path = root / 'spec_lock.md'
254 if check_lock:
255 if not lock_path.is_file():
256 return [
257 'Communication trace: missing spec_lock.md with a '
258 '## communication section.',
259 ]
260 try:
261 lock_text = lock_path.read_text(encoding='utf-8-sig')
262 except OSError as exc:
263 return [f'Communication trace: unable to read specification files: {exc}']
264
265 communication_match = re.search(
266 r'^##[ \t]+communication[ \t]*$',
267 lock_text,
268 flags=re.IGNORECASE | re.MULTILINE,
269 )
270 if communication_match is None:
271 errors.append(
272 'Communication trace: spec_lock.md must contain a '
273 '## communication section.',
274 )
275 else:
276 next_section = re.search(
277 r'^##[ \t]+',
278 lock_text[communication_match.end():],
279 flags=re.MULTILINE,
280 )
281 section_end = (
282 communication_match.end() + next_section.start()
283 if next_section
284 else len(lock_text)
285 )
286 communication_block = lock_text[
287 communication_match.end():section_end
288 ]
289 missing_keys = [
290 key
291 for key in _COMMUNICATION_TRACE_KEYS
292 if re.search(
293 rf'^-[ \t]+{re.escape(key)}[ \t]*:',
294 communication_block,
295 flags=re.MULTILINE,
296 ) is None
297 ]
298 if missing_keys:
299 errors.append(
300 'Communication trace: spec_lock.md ## communication is '
301 f'missing key line(s): {", ".join(missing_keys)}.',
302 )
303
304 if not check_design:
305 return errors
306 try:
307 design_text = design_spec.read_text(encoding='utf-8-sig')
308 except OSError as exc:
309 return [f'Communication trace: unable to read specification files: {exc}']
310
311 outline_match = re.search(
312 r'^##[ \t]+IX\.[ \t]+Content Outline\b.*$',
313 design_text,
314 flags=re.IGNORECASE | re.MULTILINE,
315 )
316 if outline_match is None:
317 errors.append(
318 'Communication trace: design_spec.md must contain '
319 '## IX. Content Outline.',
320 )
321 return errors
322 next_section = re.search(
323 r'^##[ \t]+',
324 design_text[outline_match.end():],
325 flags=re.MULTILINE,
326 )
327 outline_end = (
328 outline_match.end() + next_section.start()
329 if next_section
330 else len(design_text)
331 )
332 outline = design_text[outline_match.end():outline_end]
333 slide_matches = list(re.finditer(
334 r'^#{3,6}[ \t]+Slide[ \t]+([0-9]+|NN)\b.*$',
335 outline,
336 flags=re.IGNORECASE | re.MULTILINE,
337 ))
338 if not slide_matches:
339 errors.append(
340 'Communication trace: design_spec.md §IX contains no Slide blocks.',
341 )
342 return errors
343
344 missing_moves = []
345 for index, slide_match in enumerate(slide_matches):
346 block_end = (
347 slide_matches[index + 1].start()
348 if index + 1 < len(slide_matches)
349 else len(outline)
350 )
351 slide_block = outline[slide_match.end():block_end]
352 if re.search(
353 r'^[ \t]*-[ \t]+(?:\*\*)?Audience move(?:\*\*)?[ \t]*:',
354 slide_block,
355 flags=re.IGNORECASE | re.MULTILINE,
356 ) is None:
357 missing_moves.append(slide_match.group(1))
358 if missing_moves:
359 errors.append(
360 'Communication trace: every design_spec.md §IX Slide block must '
361 'contain an Audience move line; missing on Slide '
362 f'{", ".join(missing_moves)}.',
363 )
364 return errors
365
366
367 def validate_project_structure(
368 project_path: str,
369 verbose: bool = False,
370 *,
371 validate_communication: bool = True,
372 ) -> Tuple[bool, List[str], List[str]]:
373 """
374 Validate project structure completeness.
375
376 Args:
377 project_path: Project directory path
378 verbose: Whether to show detailed fix suggestions
379 validate_communication: Whether to run the communication trace check
380
381 Returns:
382 (is_valid, error_list, warning_list)
383 """
384 project_path = Path(project_path)
385 errors = []
386 warnings = []
387
388 # Try to import error helper
389 try:
390 from error_helper import ErrorHelper
391 use_helper = True
392 except ImportError:
393 use_helper = False
394
395 # Check if directory exists
396 if not project_path.exists():
397 msg = f"Project directory does not exist: {project_path}"
398 if use_helper and verbose:
399 msg += "\n" + ErrorHelper.format_error_message('missing_directory',
400 {'project_path': str(project_path)})
401 errors.append(msg)
402 return False, errors, warnings
403
404 if not project_path.is_dir():
405 errors.append(f"Not a valid directory: {project_path}")
406 return False, errors, warnings
407
408 # Check required files
409 if not (project_path / 'README.md').exists():
410 msg = "Missing required file: README.md"
411 if use_helper and verbose:
412 msg += "\n" + ErrorHelper.format_error_message('missing_readme',
413 {'project_path': str(project_path)})
414 errors.append(msg)
415
416 # Check design specification file
417 has_spec = any((project_path / name).exists() for name in _DESIGN_SPEC_NAMES)
418 if not has_spec:
419 msg = "Missing design specification file (suggested filename: design_spec.md)"
420 if use_helper and verbose:
421 msg += "\n" + ErrorHelper.format_error_message('missing_spec')
422 warnings.append(msg)
423 elif validate_communication:
424 errors.extend(validate_communication_trace(project_path))
425
426 # Check svg_output directory
427 svg_output = project_path / 'svg_output'
428 if not svg_output.exists():
429 msg = "Missing svg_output directory"
430 if use_helper and verbose:
431 msg += "\n" + \
432 ErrorHelper.format_error_message('missing_svg_output')
433 errors.append(msg)
434 elif not svg_output.is_dir():
435 errors.append("svg_output is not a directory")
436 else:
437 # Check for SVG files
438 svg_files = list(svg_output.glob('*.svg'))
439 if not svg_files:
440 msg = "svg_output directory is empty, no SVG files found"
441 if use_helper and verbose:
442 msg += "\n" + \
443 ErrorHelper.format_error_message('empty_svg_output')
444 warnings.append(msg)
445 else:
446 # Validate SVG file naming (consistent with project_manager.py)
447 for svg_file in svg_files:
448 if not re.match(r'^(slide_\d+_\w+|P?\d+_.+)\.svg$', svg_file.name):
449 msg = f"Non-standard SVG file naming: {svg_file.name}"
450 if use_helper and verbose:
451 msg += "\n" + ErrorHelper.format_error_message('invalid_svg_naming',
452 {'file_name': svg_file.name})
453 warnings.append(msg)
454
455 # Check directory naming format
456 dir_name = project_path.name
457 if not re.search(r'_\d{8}$', dir_name):
458 msg = f"Directory name missing date suffix (_YYYYMMDD): {dir_name}"
459 if use_helper and verbose:
460 msg += "\n" + \
461 ErrorHelper.format_error_message('missing_date_suffix')
462 warnings.append(msg)
463
464 is_valid = len(errors) == 0
465 return is_valid, errors, warnings
466
467
468 def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] = None) -> List[str]:
469 """
470 Validate the viewBox settings of SVG files.
471
472 Args:
473 svg_files: List of SVG files
474 expected_format: Expected canvas format (e.g. 'ppt169')
475
476 Returns:
477 List of warnings
478 """
479 warnings = []
480 viewboxes = {}
481
482 # Determine expected viewBox
483 expected_viewbox = None
484 if expected_format and expected_format in CANVAS_FORMATS:
485 expected_viewbox = parse_project_viewbox(
486 CANVAS_FORMATS[expected_format]['viewbox'],
487 context=f"canvas format {expected_format!r}",
488 )
489
490 for svg_file in svg_files:
491 try:
492 viewbox = read_project_viewbox(svg_file)
493 except CanvasContractError as exc:
494 warnings.append(str(exc))
495 continue
496 viewboxes[svg_file.name] = viewbox
497 if expected_viewbox and viewbox != expected_viewbox:
498 warnings.append(
499 f"{svg_file.name}: root viewBox '{viewbox.canonical}' must match "
500 f"project format '{expected_format}' ({expected_viewbox.canonical})"
501 )
502
503 # Check for multiple different viewBoxes
504 distinct = {viewbox for viewbox in viewboxes.values()}
505 if len(distinct) > 1:
506 details = ", ".join(
507 f"{name}={viewbox.canonical}"
508 for name, viewbox in sorted(viewboxes.items())
509 )
510 warnings.append(
511 "All project SVG root viewBoxes must match; found " + details
512 )
513
514 return warnings
515
516
517 def find_all_projects(base_dir: str) -> List[Path]:
518 """
519 Find all projects under the specified directory.
520
521 Args:
522 base_dir: Base directory path
523
524 Returns:
525 List of project directories
526 """
527 base_path = Path(base_dir)
528 if not base_path.exists():
529 return []
530
531 projects = []
532 for item in base_path.iterdir():
533 if item.is_dir() and not item.name.startswith('.'):
534 # Check if it's a valid project directory (contains svg_output or design spec)
535 has_svg_output = (item / 'svg_output').exists()
536 has_spec = any((item / f).exists() for f in
537 ['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md'])
538
539 if has_svg_output or has_spec:
540 projects.append(item)
541
542 return sorted(projects)
543
544
545 def format_file_size(size_bytes: int) -> str:
546 """
547 Format file size.
548
549 Args:
550 size_bytes: File size in bytes
551
552 Returns:
553 Formatted file size string
554 """
555 for unit in ['B', 'KB', 'MB', 'GB']:
556 if size_bytes < 1024.0:
557 return f"{size_bytes:.1f} {unit}"
558 size_bytes /= 1024.0
559 return f"{size_bytes:.1f} TB"
560
561
562 def get_project_stats(project_path: str) -> Dict:
563 """
564 Get project statistics.
565
566 Args:
567 project_path: Project directory path
568
569 Returns:
570 Statistics dictionary
571 """
572 project_path = Path(project_path)
573 stats = {
574 'total_files': 0,
575 'svg_files': 0,
576 'md_files': 0,
577 'html_files': 0,
578 'total_size': 0,
579 'svg_size': 0
580 }
581
582 if not project_path.exists():
583 return stats
584
585 for file in project_path.rglob('*'):
586 if file.is_file():
587 stats['total_files'] += 1
588 file_size = file.stat().st_size
589 stats['total_size'] += file_size
590
591 if file.suffix == '.svg':
592 stats['svg_files'] += 1
593 stats['svg_size'] += file_size
594 elif file.suffix == '.md':
595 stats['md_files'] += 1
596 elif file.suffix == '.html':
597 stats['html_files'] += 1
598
599 return stats
600
601
602 def build_parser() -> argparse.ArgumentParser:
603 """Build the command-line parser for the diagnostic entry point."""
604 parser = argparse.ArgumentParser(description="Inspect and validate a PPT Master project.")
605 parser.add_argument("project_path", help="Project directory")
606 return parser
607
608
609 def main(argv: list[str] | None = None) -> int:
610 """Run the diagnostic CLI entry point."""
611 parser = build_parser()
612 args = parser.parse_args(argv)
613 project_path = args.project_path
614 info = get_project_info(project_path)
615
616 print(f"\nProject Info: {info['dir_name']}")
617 print("=" * 60)
618 print(f"Project Name: {info['name']}")
619 print(f"Canvas Format: {info['format_name']} ({info['format']})")
620 print(f"Created: {info['date_formatted']}")
621 print(f"SVG Files: {info['svg_count']}")
622 print(f"README: {'Yes' if info['has_readme'] else 'No'}")
623 print(f"Design Spec: {'Yes' if info['has_spec'] else 'No'}")
624
625 print("\nValidation Results:")
626 print("-" * 60)
627 is_valid, errors, warnings = validate_project_structure(project_path)
628
629 if errors:
630 print("[ERROR]")
631 for error in errors:
632 print(f" - {error}")
633
634 if warnings:
635 print("[WARN]")
636 for warning in warnings:
637 print(f" - {warning}")
638
639 if is_valid and not warnings:
640 print("[OK] Project structure is complete, no issues found")
641 return 0 if is_valid else 1
642
643
644 if __name__ == '__main__':
645 raise SystemExit(main())
646
646 lines PYTHON