| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Speaker Notes Splitting Tool |
| 4 | |
| 5 | Splits the total.md speaker notes file into multiple individual notes files, |
| 6 | each corresponding to one SVG page. |
| 7 | |
| 8 | Usage: |
| 9 | python3 scripts/total_md_split.py <project_path> |
| 10 | python3 scripts/total_md_split.py <project_path> -o output_dir |
| 11 | |
| 12 | Examples: |
| 13 | python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD |
| 14 | python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD -o notes |
| 15 | |
| 16 | Dependencies: |
| 17 | None (only uses standard library) |
| 18 | |
| 19 | Notes: |
| 20 | - Checks the one-to-one mapping between SVG files and speaker notes |
| 21 | - Outputs a notice if any SVG file has no corresponding notes |
| 22 | - Split documents do not include the level-1 heading |
| 23 | - Split document names match the SVG filenames with .md extension |
| 24 | """ |
| 25 | |
| 26 | import sys |
| 27 | import argparse |
| 28 | import re |
| 29 | from pathlib import Path |
| 30 | |
| 31 | from console_encoding import configure_utf8_stdio |
| 32 | from slide_roster import discover_slide_svgs |
| 33 | |
| 34 | configure_utf8_stdio() |
| 35 | |
| 36 | HEADING_RE = re.compile(r'^(#{1,6})\s*(.+?)\s*$') |
| 37 | HR_RE = re.compile(r'^\s*[-*]{3,}\s*$') |
| 38 | |
| 39 | |
| 40 | def normalize_title(title: str) -> str: |
| 41 | """Normalize titles for fuzzy matching with SVG stems.""" |
| 42 | if not title: |
| 43 | return '' |
| 44 | text = title.strip() |
| 45 | # Replace any non-alnum / non-CJK run with underscore |
| 46 | text = re.sub(r'[^0-9A-Za-z\u4e00-\u9fff]+', '_', text) |
| 47 | text = re.sub(r'_+', '_', text).strip('_') |
| 48 | return text.lower() |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | def extract_leading_number(text: str) -> int | None: |
| 54 | """Extract leading slide number if present.""" |
| 55 | if not text: |
| 56 | return None |
| 57 | |
| 58 | # Try 1: Start with digits (standard) |
| 59 | m = re.match(r'^(\d{1,3})', text.strip()) |
| 60 | if m: |
| 61 | return int(m.group(1)) |
| 62 | |
| 63 | # Try 2: Common prefixes (Slide X, Page X, 第X页) |
| 64 | # Case insensitive for English |
| 65 | text_lower = text.lower().strip() |
| 66 | |
| 67 | # Slide/Page X |
| 68 | m = re.match(r'^(?:slide|page|p)\s*[-_:]?\s*(\d{1,3})', text_lower) |
| 69 | if m: |
| 70 | return int(m.group(1)) |
| 71 | |
| 72 | # 第X页/张 |
| 73 | m = re.match(r'^第\s*(\d{1,3})\s*[页张]', text_lower) |
| 74 | if m: |
| 75 | return int(m.group(1)) |
| 76 | |
| 77 | return None |
| 78 | |
| 79 | |
| 80 | def build_match_maps(svg_stems: list[str]) -> tuple[set[str], dict[str, list[str]], dict[int, list[str]]]: |
| 81 | """Build exact, normalized, and numeric maps for SVG stem matching.""" |
| 82 | exact = set(svg_stems) |
| 83 | norm_map: dict[str, list[str]] = {} |
| 84 | num_map: dict[int, list[str]] = {} |
| 85 | for stem in svg_stems: |
| 86 | norm = normalize_title(stem) |
| 87 | if norm: |
| 88 | norm_map.setdefault(norm, []).append(stem) |
| 89 | num = extract_leading_number(stem) |
| 90 | if num is not None: |
| 91 | num_map.setdefault(num, []).append(stem) |
| 92 | return exact, norm_map, num_map |
| 93 | |
| 94 | |
| 95 | def match_title( |
| 96 | raw_title: str, |
| 97 | exact: set[str], |
| 98 | norm_map: dict[str, list[str]], |
| 99 | num_map: dict[int, list[str]], |
| 100 | svg_stems: list[str] | None = None, |
| 101 | ) -> str | None: |
| 102 | """Match a note heading to its corresponding SVG stem.""" |
| 103 | if raw_title in exact: |
| 104 | return raw_title |
| 105 | norm = normalize_title(raw_title) |
| 106 | if norm in norm_map and len(norm_map[norm]) == 1: |
| 107 | return norm_map[norm][0] |
| 108 | num = extract_leading_number(raw_title) |
| 109 | if num is not None and num in num_map and len(num_map[num]) == 1: |
| 110 | return num_map[num][0] |
| 111 | if norm and svg_stems: |
| 112 | candidates = [s for s in svg_stems if norm in normalize_title(s)] |
| 113 | if len(candidates) == 1: |
| 114 | return candidates[0] |
| 115 | return None |
| 116 | |
| 117 | |
| 118 | def find_svg_files(project_path: Path) -> list[Path]: |
| 119 | """ |
| 120 | Find SVG files in the project |
| 121 | |
| 122 | Args: |
| 123 | project_path: Project directory path |
| 124 | |
| 125 | Returns: |
| 126 | List of SVG files in numeric filename order |
| 127 | """ |
| 128 | svg_dir = project_path / 'svg_output' |
| 129 | |
| 130 | if not svg_dir.exists(): |
| 131 | print(f"Error: {svg_dir} directory does not exist") |
| 132 | return [] |
| 133 | |
| 134 | return discover_slide_svgs(svg_dir) |
| 135 | |
| 136 | |
| 137 | def parse_total_md( |
| 138 | md_path: Path, |
| 139 | svg_stems: list[str] | None = None, |
| 140 | verbose: bool = True, |
| 141 | ) -> dict[str, str]: |
| 142 | """ |
| 143 | Parse total.md file and extract speaker notes content for each level-1 heading |
| 144 | |
| 145 | Args: |
| 146 | md_path: Path to total.md file |
| 147 | |
| 148 | Returns: |
| 149 | Dictionary where key is the level-1 heading (without #) and value is the notes content |
| 150 | """ |
| 151 | if not md_path.exists(): |
| 152 | print(f"Error: {md_path} file does not exist") |
| 153 | return {} |
| 154 | |
| 155 | try: |
| 156 | with open(md_path, 'r', encoding='utf-8') as f: |
| 157 | content = f.read() |
| 158 | except Exception as e: |
| 159 | print(f"Error: Unable to read file {md_path}: {e}") |
| 160 | return {} |
| 161 | |
| 162 | svg_stems = svg_stems or [] |
| 163 | exact, norm_map, num_map = build_match_maps(svg_stems) |
| 164 | |
| 165 | # Parse by headings (supports # / ## / ###) |
| 166 | notes: dict[str, str] = {} |
| 167 | current_key: str | None = None |
| 168 | current_lines: list[str] = [] |
| 169 | unmatched_headings: list[str] = [] |
| 170 | |
| 171 | lines = content.splitlines() |
| 172 | for line in lines: |
| 173 | m = HEADING_RE.match(line) |
| 174 | if m: |
| 175 | raw_title = m.group(2).strip() |
| 176 | matched = match_title(raw_title, exact, norm_map, num_map, svg_stems) |
| 177 | if matched: |
| 178 | if current_key is not None: |
| 179 | text = '\n'.join(current_lines).strip() |
| 180 | if current_key in notes and text: |
| 181 | notes[current_key] = (notes[current_key].rstrip() + "\n\n" + text).strip() |
| 182 | elif current_key not in notes: |
| 183 | notes[current_key] = text |
| 184 | current_key = matched |
| 185 | current_lines = [] |
| 186 | continue |
| 187 | unmatched_headings.append(raw_title) |
| 188 | |
| 189 | if HR_RE.match(line): |
| 190 | continue |
| 191 | if current_key is not None: |
| 192 | current_lines.append(line) |
| 193 | |
| 194 | if current_key is not None: |
| 195 | text = '\n'.join(current_lines).strip() |
| 196 | if current_key in notes and text: |
| 197 | notes[current_key] = (notes[current_key].rstrip() + "\n\n" + text).strip() |
| 198 | elif current_key not in notes: |
| 199 | notes[current_key] = text |
| 200 | |
| 201 | if verbose and unmatched_headings: |
| 202 | print("\n[Notice] Found unmatched headings (ignored):") |
| 203 | for t in unmatched_headings[:10]: |
| 204 | print(f" - {t}") |
| 205 | if len(unmatched_headings) > 10: |
| 206 | print(f" ... and {len(unmatched_headings) - 10} more") |
| 207 | |
| 208 | return notes |
| 209 | |
| 210 | |
| 211 | def check_svg_note_mapping(svg_files: list[Path], notes: dict[str, str]) -> tuple[bool, list[str]]: |
| 212 | """ |
| 213 | Check the mapping between SVG files and speaker notes |
| 214 | |
| 215 | Args: |
| 216 | svg_files: List of SVG files |
| 217 | notes: Notes dictionary (key is heading) |
| 218 | |
| 219 | Returns: |
| 220 | (whether all matched, list of missing notes headings) |
| 221 | """ |
| 222 | missing_notes = [] |
| 223 | |
| 224 | for svg_path in svg_files: |
| 225 | # Extract SVG filename (without extension) |
| 226 | svg_stem = svg_path.stem |
| 227 | |
| 228 | # Check if a corresponding heading exists in the notes |
| 229 | if svg_stem not in notes: |
| 230 | missing_notes.append(svg_stem) |
| 231 | |
| 232 | return len(missing_notes) == 0, missing_notes |
| 233 | |
| 234 | |
| 235 | def split_notes(notes: dict[str, str], output_dir: Path, verbose: bool = True) -> bool: |
| 236 | """ |
| 237 | Split and save notes dictionary into multiple files |
| 238 | |
| 239 | Args: |
| 240 | notes: Notes dictionary (key is heading, value is content) |
| 241 | output_dir: Output directory |
| 242 | verbose: Whether to output detailed information |
| 243 | |
| 244 | Returns: |
| 245 | Whether successful |
| 246 | """ |
| 247 | if not notes: |
| 248 | print("Error: No notes content found") |
| 249 | return False |
| 250 | |
| 251 | output_dir.mkdir(parents=True, exist_ok=True) |
| 252 | |
| 253 | success_count = 0 |
| 254 | |
| 255 | for title, content in notes.items(): |
| 256 | # Generate output filename (same name as SVG file, with .md extension) |
| 257 | output_path = output_dir / f"{title}.md" |
| 258 | |
| 259 | try: |
| 260 | with open(output_path, 'w', encoding='utf-8') as f: |
| 261 | f.write(content) |
| 262 | |
| 263 | if verbose: |
| 264 | print(f" Generated: {output_path.name}") |
| 265 | |
| 266 | success_count += 1 |
| 267 | |
| 268 | except Exception as e: |
| 269 | if verbose: |
| 270 | print(f" Error: Unable to write file {output_path}: {e}") |
| 271 | |
| 272 | if verbose: |
| 273 | print(f"\n[Done] Successfully generated {success_count}/{len(notes)} file(s)") |
| 274 | |
| 275 | return success_count == len(notes) |
| 276 | |
| 277 | |
| 278 | def main() -> None: |
| 279 | """Run the CLI entry point.""" |
| 280 | parser = argparse.ArgumentParser( |
| 281 | description='PPT Master - Speaker Notes Splitting Tool', |
| 282 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 283 | epilog=''' |
| 284 | Examples: |
| 285 | %(prog)s projects/<svg_title>_ppt169_YYYYMMDD |
| 286 | %(prog)s projects/<svg_title>_ppt169_YYYYMMDD -o notes |
| 287 | %(prog)s projects/<svg_title>_ppt169_YYYYMMDD -q |
| 288 | |
| 289 | Features: |
| 290 | - Reads the total.md speaker notes file |
| 291 | - Checks the mapping between SVG files and notes |
| 292 | - Splits notes into multiple individual files |
| 293 | - Output filenames match SVG filenames |
| 294 | ''' |
| 295 | ) |
| 296 | |
| 297 | parser.add_argument('project_path', type=str, help='Project directory path') |
| 298 | parser.add_argument('-o', '--output', type=str, default=None, help='Output directory path (default: notes directory under project)') |
| 299 | parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode') |
| 300 | |
| 301 | args = parser.parse_args() |
| 302 | |
| 303 | project_path = Path(args.project_path) |
| 304 | if not project_path.exists(): |
| 305 | print(f"Error: Path does not exist: {project_path}") |
| 306 | sys.exit(1) |
| 307 | |
| 308 | # Determine output directory |
| 309 | if args.output: |
| 310 | output_dir = Path(args.output) |
| 311 | else: |
| 312 | output_dir = project_path / 'notes' |
| 313 | |
| 314 | verbose = not args.quiet |
| 315 | |
| 316 | if verbose: |
| 317 | print("PPT Master - Speaker Notes Splitting Tool") |
| 318 | print("=" * 50) |
| 319 | print(f" Project path: {project_path}") |
| 320 | print(f" Output directory: {output_dir}") |
| 321 | print() |
| 322 | |
| 323 | # Find SVG files |
| 324 | svg_files = find_svg_files(project_path) |
| 325 | |
| 326 | if not svg_files: |
| 327 | print("Error: No SVG files found") |
| 328 | sys.exit(1) |
| 329 | |
| 330 | if verbose: |
| 331 | print(f" Found {len(svg_files)} SVG file(s)") |
| 332 | |
| 333 | # Parse total.md |
| 334 | total_md_path = project_path / 'notes' / 'total.md' |
| 335 | svg_stems = [p.stem for p in svg_files] |
| 336 | notes = parse_total_md(total_md_path, svg_stems, verbose) |
| 337 | |
| 338 | if not notes: |
| 339 | print("Error: No notes content found") |
| 340 | sys.exit(1) |
| 341 | |
| 342 | if verbose: |
| 343 | print(f" Found {len(notes)} notes section(s)") |
| 344 | print() |
| 345 | |
| 346 | # Check mapping |
| 347 | all_match, missing_notes = check_svg_note_mapping(svg_files, notes) |
| 348 | |
| 349 | if not all_match: |
| 350 | print("Error: SVG files and notes do not match") |
| 351 | print(f" Missing notes: {', '.join(missing_notes)}") |
| 352 | print("\nPlease regenerate the notes file to ensure every SVG has corresponding notes.") |
| 353 | sys.exit(1) |
| 354 | |
| 355 | if verbose: |
| 356 | print("[OK] SVG files and notes have one-to-one correspondence") |
| 357 | print() |
| 358 | |
| 359 | # Split notes |
| 360 | success = split_notes(notes, output_dir, verbose) |
| 361 | |
| 362 | if success: |
| 363 | if verbose: |
| 364 | print(f"\n[Done] Notes splitting complete") |
| 365 | sys.exit(0) |
| 366 | else: |
| 367 | print(f"\n[Failed] Notes splitting failed") |
| 368 | sys.exit(1) |
| 369 | |
| 370 | |
| 371 | if __name__ == '__main__': |
| 372 | main() |
| 373 |