返回 ppt-master
total_md_split.py
根目录 / skills / ppt-master / scripts / total_md_split.py
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
373 lines PYTHON