返回 ppt-master
server.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - SVG Editor Server
4
5 Flask backend for the SVG annotation editor.
6 Serves the web UI and provides API endpoints for reading/writing SVG annotations.
7
8 Usage:
9 python3 scripts/svg_editor/server.py <project_dir>
10
11 Examples:
12 python3 scripts/svg_editor/server.py projects/my-project
13 python3 scripts/svg_editor/server.py projects/my-project --port 8080
14 python3 scripts/svg_editor/server.py projects/my-project --live
15
16 Dependencies:
17 flask>=3.0.0
18 """
19
20 import argparse
21 import atexit
22 import html
23 import json
24 import logging
25 import os
26 import re
27 import signal
28 import subprocess
29 import sys
30 import threading
31 import time
32 import urllib.error
33 import urllib.request
34 import webbrowser
35 import xml.etree.ElementTree as ET
36 from pathlib import Path
37 from typing import Iterable, Optional
38
39 from flask import Flask, jsonify, request, send_from_directory
40
41 logger = logging.getLogger('svg_editor')
42
43 # Per-project runtime files live under <project_path>/live_preview/.
44 LIVE_PREVIEW_DIR_NAME = 'live_preview'
45 LOCK_FILE_NAME = 'lock.json'
46 LEGACY_LOCK_FILE_NAME = '.live_preview.lock'
47
48 # Local — sys.path injection for sibling module (code-style.md §3)
49 _SCRIPTS_DIR = Path(__file__).resolve().parent
50 if str(_SCRIPTS_DIR) not in sys.path:
51 sys.path.insert(0, str(_SCRIPTS_DIR))
52
53 _FINALIZE_DIR = _SCRIPTS_DIR.parent / 'svg_finalize'
54 if str(_FINALIZE_DIR) not in sys.path:
55 sys.path.insert(0, str(_FINALIZE_DIR))
56
57 # scripts/ root for cross-server shared helpers
58 _ROOT_SCRIPTS_DIR = _SCRIPTS_DIR.parent
59 if str(_ROOT_SCRIPTS_DIR) not in sys.path:
60 sys.path.insert(0, str(_ROOT_SCRIPTS_DIR))
61
62 from console_encoding import configure_utf8_stdio # noqa: E402
63 from resource_paths import icon_search_dirs_for_project # noqa: E402
64 from slide_roster import discover_slide_svgs # noqa: E402
65 from server_common import ( # noqa: E402
66 claim_lock as _claim_lock,
67 clear_lock as _clear_lock,
68 find_free_port as _find_free_port,
69 lock_pid as _lock_pid,
70 popen_detached as _popen_detached,
71 process_alive as _process_alive,
72 read_lock as _read_lock,
73 release_lock as _release_lock,
74 validate_port as _validate_port,
75 )
76
77 configure_utf8_stdio()
78
79 from annotations import ( # noqa: E402
80 assign_temp_ids,
81 is_editable_attr,
82 parse_annotations,
83 promote_tspan_to_text,
84 set_annotation,
85 set_attributes,
86 set_text,
87 strip_unused_temp_ids,
88 )
89 from embed_icons import ( # noqa: E402
90 parse_use_element,
91 resolve_icon_path,
92 extract_paths_from_icon,
93 generate_icon_group,
94 )
95 from svg_to_pptx.geometry_properties import ( # noqa: E402
96 GeometryStyleError,
97 INLINE_GEOMETRY_PROPERTIES,
98 materialize_inline_geometry_properties,
99 )
100
101 _USE_ICON_PATTERN = re.compile(r'<use\s+[^>]*data-icon="[^"]*"[^>]*/>')
102 _XLINK_HREF = '{http://www.w3.org/1999/xlink}href'
103
104 # Per-path mtime caches: key = absolute path str, value = (mtime, payload).
105 # Entry is evicted/replaced when the file's mtime changes, so stale data
106 # cannot leak. Locks guard concurrent access under Flask's threaded server.
107 _SLIDE_CACHE_LOCK = threading.Lock()
108 _SLIDE_CACHE: dict = {} # path -> (mtime, (content, warnings))
109
110 _LIST_CACHE_LOCK = threading.Lock()
111 _LIST_CACHE: dict = {} # path -> (mtime, annotation_count_on_disk)
112
113 DEFAULT_PORT = 5050
114 PUBLIC_HOST = '127.0.0.1'
115 STARTUP_TIMEOUT = 15
116
117
118 def _server_url(port: int, path: str = '') -> str:
119 """Return the loopback URL shown to users and used by readiness probes."""
120 suffix = path if path.startswith('/') or not path else f'/{path}'
121 return f'http://{PUBLIC_HOST}:{port}{suffix}'
122
123
124 def _xml_attr(value: object) -> str:
125 """Escape a value for safe insertion into generated preview SVG markup."""
126 return html.escape(str(value), quote=True)
127
128
129 def _cache_get(cache: dict, lock: threading.Lock, path: str, mtime: float):
130 with lock:
131 entry = cache.get(path)
132 if entry is None or entry[0] != mtime:
133 return None
134 return entry[1]
135
136
137 def _cache_put(cache: dict, lock: threading.Lock, path: str, mtime: float, value) -> None:
138 with lock:
139 cache[path] = (mtime, value)
140
141
142 def _normalize_preview_hrefs(root: ET.Element) -> None:
143 """Normalize legacy XLink references in the browser-only SVG copy.
144
145 ElementTree otherwise serializes an unregistered/legacy namespace with an
146 arbitrary prefix. The HTML SVG parser only gives special namespace handling
147 to ``xlink:href``; an arbitrary prefix can therefore render as an inert
148 attribute after ``innerHTML`` insertion. SVG 2 ``href`` works for both
149 images and local ``use`` references and avoids that parser boundary.
150 """
151 for elem in root.iter():
152 legacy_href = elem.get(_XLINK_HREF)
153 if legacy_href is None:
154 continue
155 if elem.get('href') is None:
156 elem.set('href', legacy_href)
157 elem.attrib.pop(_XLINK_HREF, None)
158
159
160 # Lock / liveness helpers are shared with confirm_ui via server_common.
161
162
163 def _inline_icons(
164 content: str,
165 icons_dir: Path,
166 fallback_dir: Optional[Path] = None,
167 ) -> tuple[str, list[dict]]:
168 """Replace <use data-icon="..."/> with rendered <g> for browser preview.
169
170 Resolve icons from the project directory first, then the shared library.
171 Returns (rewritten_content, warnings). Each warning is
172 ``{"icon": <name>, "reason": <str>}`` so the frontend can surface
173 "icon X not found" to the user instead of silently dropping it.
174 """
175 warnings: list[dict] = []
176 matches = list(_USE_ICON_PATTERN.finditer(content))
177 if not matches:
178 return content, warnings
179 new_content = content
180 for match in reversed(matches):
181 use_str = match.group(0)
182 icon_name: str = ''
183 try:
184 attrs = parse_use_element(use_str)
185 icon_name = str(attrs.get('icon') or '')
186 if not icon_name:
187 warnings.append({'icon': '', 'reason': 'missing data-icon attribute'})
188 continue
189 icon_path, _ = resolve_icon_path(icon_name, icons_dir, fallback_dir)
190 color = str(attrs.get('fill', '#000000'))
191 elements, style, base_size = extract_paths_from_icon(icon_path, color)
192 except Exception as exc:
193 warnings.append({'icon': icon_name, 'reason': f'{type(exc).__name__}: {exc}'})
194 logger.warning('icon inline failed: name=%r reason=%s', icon_name, exc)
195 continue
196 if not elements:
197 warnings.append({'icon': icon_name, 'reason': 'no renderable paths in icon'})
198 continue
199 replacement = generate_icon_group(attrs, elements, style, base_size)
200 id_match = re.search(r'\bid="([^"]+)"', use_str)
201 if id_match:
202 preview_attrs = [
203 f'id="{_xml_attr(id_match.group(1))}"',
204 f'data-icon="{_xml_attr(icon_name)}"',
205 ]
206 for key in ('x', 'y', 'width', 'height'):
207 if key in attrs:
208 preview_attrs.append(f'data-use-{key}="{_xml_attr(attrs[key])}"')
209 if 'transform' in attrs:
210 preview_attrs.append('data-use-has-transform="1"')
211 replacement = replacement.replace(
212 '<g ', f'<g {" ".join(preview_attrs)} ', 1,
213 )
214 new_content = new_content[:match.start()] + replacement + new_content[match.end():]
215 return new_content, warnings
216
217
218 def _strip_edited_inline_geometry(
219 elem: ET.Element,
220 attr_names: Iterable[str],
221 ) -> None:
222 """Remove style declarations superseded by edited geometry attributes."""
223 style = elem.get('style')
224 if not style:
225 return
226 tag = elem.tag.rsplit('}', 1)[-1] if '}' in elem.tag else elem.tag
227 supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset())
228 edited = {
229 str(name).lower()
230 for name in attr_names
231 if str(name).lower() in supported
232 }
233 if not edited:
234 return
235
236 retained = []
237 changed = False
238 for raw_declaration in style.split(';'):
239 declaration = raw_declaration.strip()
240 if not declaration:
241 continue
242 if ':' not in declaration:
243 retained.append(declaration)
244 continue
245 raw_name, _raw_value = declaration.split(':', 1)
246 if raw_name.strip().lower() in edited:
247 changed = True
248 continue
249 retained.append(declaration)
250
251 if not changed:
252 return
253 if retained:
254 elem.set('style', '; '.join(retained))
255 else:
256 elem.attrib.pop('style', None)
257
258
259 # ---------------------------------------------------------------------------
260 # Staged-edit value validation (POST /api/slide/<name>/edit).
261 # The browser may expose raw element attributes, so validation protects only
262 # invariants and dangerous value forms rather than a tiny style whitelist.
263 # ---------------------------------------------------------------------------
264
265 # Reject CSS-injection vectors in color-like values; mirrors frontend checks.
266 _UNSAFE_COLOR_RE = re.compile(r'[;:@\\]|url\s*\(', re.IGNORECASE)
267 # Generic SVG attribute names: namespaces, hyphenated attrs, and data-* attrs.
268 _SAFE_ATTR_NAME_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_.:-]*$')
269 _MAX_ATTR_VALUE_LEN = 256
270 _MAX_EDIT_TEXT_LEN = 5000
271 _ADDABLE_BATCH_ATTRS = frozenset({
272 'fill', 'stroke', 'opacity',
273 'font-size', 'font-family', 'font-weight', 'text-anchor',
274 'x', 'y',
275 })
276
277
278 def _is_safe_color(value: str) -> bool:
279 return len(value) < _MAX_ATTR_VALUE_LEN and not _UNSAFE_COLOR_RE.search(value)
280
281
282 def _validate_edit_attrs(attrs: dict, existing_attrs: set[str]) -> Optional[str]:
283 """Return an error string if any attr/value is disallowed, else None."""
284 for key, value in attrs.items():
285 if not isinstance(key, str) or not _SAFE_ATTR_NAME_RE.match(key):
286 return f'invalid attribute name: {key}'
287 if not is_editable_attr(key):
288 return f'attribute not editable: {key}'
289 if key not in existing_attrs and key != 'transform' and key not in _ADDABLE_BATCH_ATTRS:
290 return f'attribute does not exist on element: {key}'
291 if value is None:
292 if key not in existing_attrs:
293 return f'attribute does not exist on element: {key}'
294 continue
295 if not isinstance(value, str):
296 return f'value must be a string: {key}'
297 if len(value) > _MAX_ATTR_VALUE_LEN:
298 return f'value too long: {key}'
299 if key in ('fill', 'stroke', 'color', 'stop-color', 'flood-color', 'lighting-color'):
300 if not _is_safe_color(value):
301 return f'unsafe color value: {key}'
302 if key == 'transform' and re.search(r'nan|inf', value, re.IGNORECASE):
303 return f'invalid transform value: {key}'
304 if any(c in value for c in '<>"'):
305 return f'invalid value: {key}'
306 if re.search(r'javascript\s*:|data\s*:|url\s*\(', value, re.IGNORECASE):
307 return f'unsafe value: {key}'
308 return None
309
310
311 EDIT_LOG_NAME = 'edits.jsonl'
312 ANNOTATION_LOG_NAME = 'annotations.jsonl'
313
314
315 def _append_live_preview_log(project_path: Path, filename: str, record: dict) -> None:
316 """Append one live-preview history record under ``live_preview/``."""
317 try:
318 log_dir = _runtime_dir(project_path)
319 log_dir.mkdir(parents=True, exist_ok=True)
320 with open(log_dir / filename, 'a', encoding='utf-8') as fh:
321 fh.write(json.dumps(record, ensure_ascii=False) + '\n')
322 except OSError as exc:
323 logger.warning('live preview log append failed: %s', exc)
324
325
326 def _append_edit_log(project_path: Path, record: dict) -> None:
327 """Append one applied direct-edit record to the preview history."""
328 _append_live_preview_log(project_path, EDIT_LOG_NAME, record)
329
330
331 def _append_annotation_log(project_path: Path, record: dict) -> None:
332 """Append one annotation lifecycle record to the preview history."""
333 _append_live_preview_log(project_path, ANNOTATION_LOG_NAME, record)
334
335
336 def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]:
337 for elem in root.iter():
338 if elem.get('id') == element_id:
339 return elem
340 return None
341
342
343 def _apply_edit_record(root: ET.Element, record: dict) -> tuple[bool, Optional[str]]:
344 element_id = record.get('element_id')
345 if not isinstance(element_id, str):
346 return False, 'invalid-record'
347 promote = record.get('promote_tspan')
348 if promote:
349 if not isinstance(promote, dict):
350 return False, 'invalid-promote'
351 ok, reason = promote_tspan_to_text(
352 root,
353 element_id,
354 str(promote.get('x') or ''),
355 str(promote.get('y') or ''),
356 )
357 if not ok:
358 return ok, reason
359 if 'text' in record:
360 ok, reason = set_text(root, element_id, str(record.get('text') or ''))
361 if not ok:
362 return ok, reason
363 attrs = record.get('attrs')
364 if attrs:
365 target = _find_by_id(root, element_id)
366 if target is None:
367 return False, 'not-found'
368 _strip_edited_inline_geometry(target, attrs.keys())
369 ok, reason = set_attributes(root, element_id, attrs)
370 if not ok:
371 return ok, reason
372 return True, None
373
374
375 def _apply_edit_records(root: ET.Element, records: list[dict]) -> tuple[bool, Optional[str]]:
376 for record in records:
377 ok, reason = _apply_edit_record(root, record)
378 if not ok:
379 return ok, reason
380 return True, None
381
382
383 def _edit_signature(record: dict) -> tuple:
384 """Identity used to coalesce consecutive staged edits.
385
386 Two edits fold together only when they touch the exact same element and the
387 exact same field set (text flag + sorted attr keys). 'Nudge fill 5×'
388 collapses to one undo step; 'change fill then font-size' stays two.
389 """
390 attr_keys = tuple(sorted((record.get('attrs') or {}).keys()))
391 promote_keys = tuple(sorted((record.get('promote_tspan') or {}).keys()))
392 return (record.get('element_id'), 'text' in record, attr_keys, promote_keys)
393
394
395 def _coalesce_into(prev: dict, cur: dict) -> None:
396 """Fold cur's new values into prev, keeping prev's original old values.
397
398 Callers guarantee matching signatures, so prev and cur carry the same
399 (kind, key) change set; only the 'new' side advances. prev's 'old' is the
400 value from before the first edit in the run, which is what undo and the
401 edit log should report.
402 """
403 if 'text' in cur:
404 prev['text'] = cur['text']
405 if cur.get('attrs'):
406 merged = dict(prev.get('attrs') or {})
407 merged.update(cur['attrs'])
408 prev['attrs'] = merged
409 if cur.get('promote_tspan'):
410 prev['promote_tspan'] = cur['promote_tspan']
411 old_by_field = {(c['kind'], c['key']): c['old'] for c in prev['changes']}
412 prev['changes'] = [
413 {
414 'kind': c['kind'], 'key': c['key'],
415 'old': old_by_field.get((c['kind'], c['key']), c['old']),
416 'new': c['new'],
417 }
418 for c in cur['changes']
419 ]
420
421
422 def create_app(
423 project_dir: str,
424 idle_timeout: int = 900,
425 live: bool = False,
426 lock_file: Optional[Path] = None,
427 ) -> Flask:
428 """Create and configure the Flask app for a given project directory."""
429 project_path = Path(project_dir).resolve()
430 svg_dir = project_path / 'svg_output'
431 images_dir = project_path / 'images'
432 assets_dir = project_path / 'assets'
433 icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_path)
434
435 app = Flask(__name__, static_folder='static', static_url_path='/static')
436 app.config['PROJECT_PATH'] = project_path
437 app.config['SVG_DIR'] = svg_dir
438 app.config['LIVE_MODE'] = live
439 app.config['LOCK_FILE'] = lock_file
440
441 # In-memory annotation store: {filename: {element_id: annotation_text}}
442 app.config['ANNOTATIONS'] = {}
443
444 # Per-file staged direct edits. They affect the browser preview immediately,
445 # but are written to svg_output/ only by /api/save-all.
446 app.config['PENDING_EDITS'] = {}
447
448 # Idle timeout: auto-shutdown if no one connects within idle_timeout seconds
449 app.config['LAST_REQUEST_TIME'] = time.time()
450
451 @app.before_request
452 def _update_activity():
453 app.config['LAST_REQUEST_TIME'] = time.time()
454
455 def _exit_with_lock_release(code: int = 0) -> None:
456 lf = app.config.get('LOCK_FILE')
457 if lf is not None:
458 _release_lock(lf)
459 # os._exit: atexit handlers do not run; we just released the lock
460 # manually above so a clean restart still finds the slot free.
461 os._exit(code)
462
463 def _idle_watchdog():
464 if idle_timeout <= 0:
465 return
466 while True:
467 time.sleep(10)
468 elapsed = time.time() - app.config['LAST_REQUEST_TIME']
469 if elapsed > idle_timeout:
470 logger.info('idle for %ds, shutting down', idle_timeout)
471 # Flask dev server has no clean shutdown; data is safe
472 # because idle timeout only fires when no requests are in flight.
473 _exit_with_lock_release(0)
474
475 watchdog = threading.Thread(target=_idle_watchdog, daemon=True)
476 watchdog.start()
477
478 @app.route('/api/shutdown', methods=['POST'])
479 def shutdown():
480 data = request.get_json(silent=True) or {}
481 reason = data.get('reason') or 'shutdown'
482
483 def _stop():
484 time.sleep(0.5) # Let HTTP response flush before killing the process
485 logger.info('shutting down (%s)', reason)
486 # os._exit: save-all already wrote to disk; 0.5s delay ensures response is sent.
487 _exit_with_lock_release(0)
488 threading.Thread(target=_stop, daemon=True).start()
489 return jsonify({'status': 'ok'})
490
491 @app.route('/')
492 def index():
493 return send_from_directory(app.static_folder, 'index.html')
494
495 @app.route('/api/config')
496 def get_config():
497 return jsonify({
498 'live': app.config['LIVE_MODE'],
499 })
500
501 @app.route('/api/health')
502 def health():
503 """Expose a cheap readiness probe for the daemon launcher."""
504 try:
505 slide_count = len(list(svg_dir.glob('*.svg'))) if svg_dir.exists() else 0
506 except OSError:
507 slide_count = 0
508 resp = jsonify({
509 'status': 'ok',
510 'service': 'live_preview',
511 'pid': os.getpid(),
512 'project': str(project_path),
513 'live': app.config['LIVE_MODE'],
514 'svg_output': str(svg_dir),
515 'slides': slide_count,
516 })
517 resp.headers['Cache-Control'] = 'no-store'
518 return resp
519
520 @app.route('/images/<path:filename>')
521 def serve_image(filename: str):
522 """Serve images referenced by SVGs as `../images/*.png`.
523
524 Resolution against an absolute images_dir + relative_to() check is the
525 authoritative path-traversal guard.
526 """
527 if not images_dir.exists():
528 return jsonify({'error': 'images directory not found'}), 404
529 target = (images_dir / filename).resolve()
530 try:
531 target.relative_to(images_dir.resolve())
532 except ValueError:
533 return jsonify({'error': 'invalid path'}), 400
534 if not target.exists() or not target.is_file():
535 return jsonify({'error': 'not found'}), 404
536 return send_from_directory(str(images_dir), filename)
537
538 @app.route('/assets/<path:filename>')
539 def serve_asset(filename: str):
540 """Serve media extracted by pptx_to_svg.py as `../assets/*`."""
541 if not assets_dir.exists():
542 return jsonify({'error': 'assets directory not found'}), 404
543 target = (assets_dir / filename).resolve()
544 try:
545 target.relative_to(assets_dir.resolve())
546 except ValueError:
547 return jsonify({'error': 'invalid path'}), 400
548 if not target.exists() or not target.is_file():
549 return jsonify({'error': 'not found'}), 404
550 return send_from_directory(str(assets_dir), filename)
551
552 @app.route('/<path:filename>')
553 def serve_bare_asset(filename: str):
554 """Resolve a template SVG's bare image href (e.g. `href="cover_bg.png"`).
555
556 Mirror templates copy hrefs verbatim, so a bare filename reaches the
557 browser as `/<filename>` (no `../images/` prefix). Resolve it against the
558 project's images/ then assets/. Every real route (`/api/*`, `/images/*`,
559 `/assets/*`, `/static/*`, `/`) is more specific and matches first; this
560 only catches the leftover bare references and 404s otherwise.
561 """
562 for base in (images_dir, assets_dir):
563 if not base.exists():
564 continue
565 target = (base / filename).resolve()
566 try:
567 target.relative_to(base.resolve())
568 except ValueError:
569 continue
570 if target.exists() and target.is_file():
571 return send_from_directory(str(base), filename)
572 return jsonify({'error': 'not found'}), 404
573
574 @app.route('/api/slides')
575 def get_slides():
576 svg_dir = app.config['SVG_DIR']
577 if not svg_dir.exists():
578 return jsonify({'slides': []})
579
580 annotations = app.config['ANNOTATIONS']
581 slides = []
582 for svg_file in discover_slide_svgs(svg_dir):
583 path_str = str(svg_file)
584 try:
585 mtime = svg_file.stat().st_mtime
586 except OSError as exc:
587 logger.warning('stat failed: %s: %s', path_str, exc)
588 continue
589
590 ok = True
591 error_msg: Optional[str] = None
592 disk_count = _cache_get(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime)
593 if disk_count is None:
594 try:
595 tree = ET.parse(path_str)
596 disk_count = len(parse_annotations(tree.getroot()))
597 except ET.ParseError as exc:
598 ok = False
599 error_msg = f'XML parse error: {exc}'
600 disk_count = 0
601 logger.warning('slide parse failed: %s: %s', svg_file.name, exc)
602 _cache_put(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime, disk_count)
603
604 if svg_file.name in annotations:
605 annotation_count = len(annotations[svg_file.name])
606 else:
607 annotation_count = disk_count
608
609 slides.append({
610 'name': svg_file.name,
611 'annotated': annotation_count > 0,
612 'annotation_count': annotation_count,
613 'ok': ok,
614 'error': error_msg,
615 'mtime': mtime,
616 })
617
618 return jsonify({'slides': slides})
619
620 def _safe_svg_path(name: str):
621 """Validate slide name and return safe path. Returns None if invalid.
622
623 The early string checks reject obvious bad inputs; the resolve()+relative_to()
624 check is the authoritative path traversal guard.
625 """
626 if '/' in name or '\\' in name or '..' in name:
627 return None
628 svg_file = (svg_dir / name).resolve()
629 try:
630 svg_file.relative_to(svg_dir.resolve())
631 except ValueError:
632 return None
633 return svg_file
634
635 def _get_annotation_snapshot(name: str):
636 """Return the page's complete staged annotation state, loading it once."""
637 annotations = app.config['ANNOTATIONS']
638 if name in annotations:
639 return annotations[name], None
640
641 svg_file = _safe_svg_path(name)
642 if svg_file is None:
643 return None, (jsonify({'error': 'Invalid slide name'}), 400)
644 if not svg_file.exists():
645 return None, (jsonify({'error': 'Slide not found'}), 404)
646
647 try:
648 root = ET.parse(str(svg_file)).getroot()
649 except ET.ParseError as exc:
650 logger.warning('slide parse failed: %s: %s', name, exc)
651 return None, (jsonify({'error': f'Failed to parse SVG: {exc}'}), 500)
652
653 assign_temp_ids(root)
654 annotations[name] = {
655 item['element_id']: item['annotation']
656 for item in parse_annotations(root)
657 }
658 return annotations[name], None
659
660 @app.route('/api/slide/<name>')
661 def get_slide(name: str):
662 svg_file = _safe_svg_path(name)
663 if svg_file is None:
664 return jsonify({'error': 'Invalid slide name'}), 400
665 if not svg_file.exists():
666 return jsonify({'error': 'Slide not found'}), 404
667
668 path_str = str(svg_file)
669 try:
670 mtime = svg_file.stat().st_mtime
671 except OSError as exc:
672 logger.warning('stat failed: %s: %s', path_str, exc)
673 return jsonify({'error': f'Failed to stat SVG: {exc}'}), 500
674
675 pending_edits = app.config['PENDING_EDITS'].get(name) or []
676 cached = None if pending_edits else _cache_get(
677 _SLIDE_CACHE, _SLIDE_CACHE_LOCK, path_str, mtime,
678 )
679 if cached is not None:
680 content, warnings, disk_annotations, id_to_tag = cached
681 else:
682 try:
683 tree = ET.parse(path_str)
684 root = tree.getroot()
685 except ET.ParseError as exc:
686 logger.warning('slide parse failed: %s: %s', name, exc)
687 return jsonify({'error': f'Failed to parse SVG: {exc}'}), 500
688
689 try:
690 materialize_inline_geometry_properties(root)
691 except GeometryStyleError as exc:
692 logger.warning('slide geometry materialization failed: %s: %s', name, exc)
693 return jsonify({'error': f'Invalid inline geometry: {exc}'}), 400
694
695 assign_temp_ids(root)
696 if pending_edits:
697 ok, reason = _apply_edit_records(root, pending_edits)
698 if not ok:
699 return jsonify({'error': f'Failed to apply pending edits: {reason}'}), 500
700 disk_annotations = parse_annotations(root)
701 id_to_tag: dict[str, str] = {}
702 for elem in root.iter():
703 eid = elem.get('id')
704 if eid:
705 tag = elem.tag
706 if '}' in tag:
707 tag = tag.split('}', 1)[1]
708 id_to_tag[eid] = tag
709 _normalize_preview_hrefs(root)
710 content = ET.tostring(root, encoding='unicode', xml_declaration=False)
711 content, warnings = _inline_icons(
712 content,
713 icons_dir,
714 icons_fallback_dir,
715 )
716 if not pending_edits:
717 _cache_put(
718 _SLIDE_CACHE, _SLIDE_CACHE_LOCK, path_str, mtime,
719 (content, warnings, disk_annotations, id_to_tag),
720 )
721
722 if name in app.config['ANNOTATIONS']:
723 merged = dict(app.config['ANNOTATIONS'][name])
724 else:
725 merged = {
726 ann['element_id']: ann['annotation']
727 for ann in disk_annotations
728 }
729
730 annotations_list = [
731 {
732 'element_id': eid,
733 'tag': id_to_tag.get(eid, ''),
734 'annotation': ann_text,
735 }
736 for eid, ann_text in merged.items()
737 ]
738
739 return jsonify({
740 'name': name,
741 'content': content,
742 'annotations': annotations_list,
743 'warnings': warnings,
744 'mtime': mtime,
745 'undo_depth': len(pending_edits),
746 })
747
748 @app.route('/api/slide/<name>/annotate', methods=['POST'])
749 def post_annotate(name: str):
750 data = request.get_json()
751 if not data or 'element_id' not in data or 'annotation' not in data:
752 return jsonify({'error': 'Missing element_id or annotation'}), 400
753
754 element_id = data['element_id']
755 annotation = data['annotation']
756
757 if not isinstance(element_id, str) or not isinstance(annotation, str):
758 return jsonify({'error': 'element_id and annotation must be strings'}), 400
759
760 if len(element_id) > 200:
761 return jsonify({'error': 'element_id too long (max 200 chars)'}), 400
762
763 if len(annotation) > 10000:
764 return jsonify({'error': 'Annotation too long (max 10000 chars)'}), 400
765
766 annotations, error = _get_annotation_snapshot(name)
767 if error is not None:
768 return error
769
770 annotations[element_id] = annotation
771
772 return jsonify({
773 'status': 'ok',
774 'annotations_count': len(annotations),
775 })
776
777 @app.route('/api/slide/<name>/annotate/<element_id>', methods=['DELETE'])
778 def delete_annotate(name: str, element_id: str):
779 annotations, error = _get_annotation_snapshot(name)
780 if error is not None:
781 return error
782 if element_id in annotations:
783 del annotations[element_id]
784
785 return jsonify({
786 'status': 'ok',
787 'annotations_count': len(annotations),
788 })
789
790 @app.route('/api/slide/<name>/edit', methods=['POST'])
791 def post_edit(name: str):
792 """Stage a direct (AI-free) edit to one element.
793
794 Body: ``{element_id, text?: str, attrs?: {fill, font-size, ...}}``.
795 The edit is visible in preview, but disk writes happen only in
796 /api/save-all alongside annotation persistence.
797 """
798 svg_file = _safe_svg_path(name)
799 if svg_file is None:
800 return jsonify({'error': 'Invalid slide name'}), 400
801 if not svg_file.exists():
802 return jsonify({'error': 'Slide not found'}), 404
803
804 data = request.get_json(silent=True) or {}
805 element_id = data.get('element_id')
806 if not isinstance(element_id, str) or not element_id or len(element_id) > 200:
807 return jsonify({'error': 'Missing or invalid element_id'}), 400
808
809 new_text = data.get('text')
810 attrs = data.get('attrs')
811 promote = data.get('promote_tspan')
812 if new_text is None and not attrs and not promote:
813 return jsonify({'error': 'Nothing to edit (no text or attrs)'}), 400
814
815 if new_text is not None:
816 if not isinstance(new_text, str) or len(new_text) > _MAX_EDIT_TEXT_LEN:
817 return jsonify({'error': 'Invalid or too-long text'}), 400
818 if attrs is not None:
819 if not isinstance(attrs, dict):
820 return jsonify({'error': 'attrs must be an object'}), 400
821 if promote is not None:
822 if not isinstance(promote, dict):
823 return jsonify({'error': 'promote_tspan must be an object'}), 400
824 for key in ('x', 'y'):
825 value = promote.get(key)
826 if not isinstance(value, str) or not re.fullmatch(r'-?\d+(?:\.\d+)?', value):
827 return jsonify({'error': f'invalid promote_tspan.{key}'}), 400
828
829 try:
830 tree = ET.parse(str(svg_file))
831 root = tree.getroot()
832 except ET.ParseError as exc:
833 return jsonify({'error': f'Failed to parse SVG: {exc}'}), 500
834
835 try:
836 materialize_inline_geometry_properties(root)
837 except GeometryStyleError as exc:
838 return jsonify({'error': f'Invalid inline geometry: {exc}'}), 400
839
840 assign_temp_ids(root)
841 pending = app.config['PENDING_EDITS'].get(name) or []
842 ok, reason = _apply_edit_records(root, pending)
843 if not ok:
844 return jsonify({'error': f'Failed to replay pending edits: {reason}'}), 500
845
846 # Locate the target up front to capture old values before mutating —
847 # these feed the staged record and eventual edit log.
848 target = _find_by_id(root, element_id)
849 if target is None:
850 return jsonify({'error': 'Element not found'}), 404
851 if attrs is not None:
852 attr_err = _validate_edit_attrs(attrs, set(target.attrib.keys()))
853 if attr_err:
854 return jsonify({'error': attr_err}), 400
855
856 changes = []
857 staged: dict = {'element_id': element_id}
858 if new_text is not None:
859 old_text = target.text or ''
860 ok, reason = set_text(root, element_id, new_text)
861 if not ok:
862 return jsonify({'error': f'Text edit failed: {reason}'}), (
863 404 if reason == 'not-found' else 400
864 )
865 changes.append({'kind': 'text', 'key': None, 'old': old_text, 'new': new_text})
866 staged['text'] = new_text
867 if attrs:
868 old_attrs = {k: target.get(k) for k in attrs}
869 _strip_edited_inline_geometry(target, attrs.keys())
870 ok, reason = set_attributes(root, element_id, attrs)
871 if not ok:
872 return jsonify({'error': f'Attribute edit failed: {reason}'}), (
873 404 if reason == 'not-found' else 400
874 )
875 for k, v in attrs.items():
876 changes.append({'kind': 'attr', 'key': k, 'old': old_attrs[k], 'new': v})
877 staged['attrs'] = attrs
878 if promote:
879 tag = target.tag.split('}', 1)[1] if '}' in target.tag else target.tag
880 old_state = {
881 'tag': tag,
882 'x': target.get('x'),
883 'y': target.get('y'),
884 'dy': target.get('dy'),
885 'transform': target.get('transform'),
886 }
887 ok, reason = promote_tspan_to_text(root, element_id, promote['x'], promote['y'])
888 if not ok:
889 return jsonify({'error': f'Tspan promotion failed: {reason}'}), (
890 404 if reason == 'not-found' else 400
891 )
892 changes.append({
893 'kind': 'structure',
894 'key': 'promote-tspan',
895 'old': old_state,
896 'new': {'tag': 'text', 'x': promote['x'], 'y': promote['y']},
897 })
898 staged['promote_tspan'] = promote
899
900 staged['changes'] = changes
901 pending = app.config['PENDING_EDITS'].setdefault(name, [])
902 # Coalesce a run of edits to the same element+fields into one undo step
903 # so repeated nudges/color tries don't pile up replay work or log noise.
904 if pending and _edit_signature(pending[-1]) == _edit_signature(staged):
905 _coalesce_into(pending[-1], staged)
906 else:
907 pending.append(staged)
908 return jsonify({'status': 'ok', 'undo_depth': len(pending)})
909
910 @app.route('/api/slide/<name>/undo', methods=['POST'])
911 def post_undo(name: str):
912 """Drop the most recent staged direct edit on this slide (LIFO)."""
913 svg_file = _safe_svg_path(name)
914 if svg_file is None:
915 return jsonify({'error': 'Invalid slide name'}), 400
916 if not svg_file.exists():
917 return jsonify({'error': 'Slide not found'}), 404
918
919 stack = app.config['PENDING_EDITS'].get(name) or []
920 if not stack:
921 return jsonify({'status': 'empty', 'undo_depth': 0})
922 stack.pop()
923 return jsonify({'status': 'ok', 'undo_depth': len(stack)})
924
925 @app.route('/api/save-all', methods=['POST'])
926 def save_all():
927 annotations = app.config['ANNOTATIONS']
928 pending_edits = app.config['PENDING_EDITS']
929 modified = []
930
931 filenames = sorted(set(annotations.keys()) | set(pending_edits.keys()))
932 for filename in filenames:
933 has_staged_annotations = filename in annotations
934 anns = annotations.get(filename, {})
935 edits = pending_edits.get(filename, [])
936 # anns may be empty when the user deleted all annotations — still
937 # need to write so the on-disk data-edit-* attributes are cleared.
938
939 svg_file = _safe_svg_path(filename)
940 if svg_file is None or not svg_file.exists():
941 continue
942
943 try:
944 tree = ET.parse(str(svg_file))
945 root = tree.getroot()
946 except ET.ParseError:
947 continue
948
949 assign_temp_ids(root)
950
951 ok, reason = _apply_edit_records(root, edits)
952 if not ok:
953 return jsonify({'error': f'Failed to apply edits in {filename}: {reason}'}), 400
954
955 old_annotations = {
956 item['element_id']: item['annotation']
957 for item in parse_annotations(root)
958 }
959 if not has_staged_annotations:
960 anns = old_annotations
961
962 # Clear all existing annotations from the file before writing current state
963 for elem in root.iter():
964 elem.attrib.pop('data-edit-target', None)
965 elem.attrib.pop('data-edit-annotation', None)
966
967 for element_id, annotation_text in anns.items():
968 set_annotation(root, element_id, annotation_text)
969
970 # Strip transient _edit_N ids from elements that are NOT user-annotated.
971 # Only annotated elements need to keep their id so the AI can locate them
972 # via check_annotations.py; the rest are pollution.
973 annotated_ids = set(anns.keys())
974 strip_unused_temp_ids(root, annotated_ids)
975
976 tree.write(str(svg_file), encoding='UTF-8', xml_declaration=True)
977 ts = time.time()
978 for element_id, annotation_text in anns.items():
979 old_text = old_annotations.get(element_id)
980 action = 'annotation_saved' if old_text is None else 'annotation_updated'
981 if old_text == annotation_text:
982 action = 'annotation_saved'
983 _append_annotation_log(project_path, {
984 'ts': ts, 'file': filename, 'element_id': element_id,
985 'action': action, 'old': old_text, 'new': annotation_text,
986 })
987 for element_id, old_text in old_annotations.items():
988 if element_id not in anns:
989 _append_annotation_log(project_path, {
990 'ts': ts, 'file': filename, 'element_id': element_id,
991 'action': 'annotation_removed', 'old': old_text, 'new': None,
992 })
993 for edit in edits:
994 for chg in edit.get('changes', []):
995 _append_edit_log(project_path, {
996 'ts': ts, 'file': filename, 'element_id': edit.get('element_id'),
997 'action': 'edit', 'kind': chg.get('kind'), 'key': chg.get('key'),
998 'old': chg.get('old'), 'new': chg.get('new'),
999 })
1000 modified.append(filename)
1001
1002 app.config['ANNOTATIONS'] = {}
1003 app.config['PENDING_EDITS'] = {}
1004
1005 return jsonify({'status': 'ok', 'files_modified': modified})
1006
1007 return app
1008
1009
1010 def _runtime_dir(project_path: Path) -> Path:
1011 return project_path / LIVE_PREVIEW_DIR_NAME
1012
1013
1014 def _lock_file(project_path: Path) -> Path:
1015 return _runtime_dir(project_path) / LOCK_FILE_NAME
1016
1017
1018 def _legacy_live_lock(project_path: Path) -> Optional[dict]:
1019 """Return a live legacy root lock, if one exists."""
1020 legacy_lock = project_path / LEGACY_LOCK_FILE_NAME
1021 existing = _read_lock(legacy_lock)
1022 if existing and _process_alive(_lock_pid(existing)):
1023 return existing
1024 return None
1025
1026
1027 def _shutdown_existing(project_path: Path) -> int:
1028 """Stop a live-preview server for this project (idempotent)."""
1029 lock_file = _lock_file(project_path)
1030 existing = _read_lock(lock_file)
1031 legacy_lock_file = project_path / LEGACY_LOCK_FILE_NAME
1032 if not existing:
1033 existing = _read_lock(legacy_lock_file)
1034 lock_file = legacy_lock_file
1035 if not existing:
1036 logger.info('no live preview server running — nothing to stop')
1037 return 0
1038
1039 pid = _lock_pid(existing)
1040 try:
1041 port = int(existing.get('port', 0) or 0)
1042 except (TypeError, ValueError):
1043 port = 0
1044 if not _process_alive(pid):
1045 _clear_lock(lock_file)
1046 logger.info('live preview already stopped; cleared stale lock')
1047 return 0
1048
1049 if port:
1050 try:
1051 req = urllib.request.Request(
1052 _server_url(port, '/api/shutdown'),
1053 data=b'{"reason": "cli-shutdown"}',
1054 headers={'Content-Type': 'application/json'},
1055 method='POST',
1056 )
1057 urllib.request.urlopen(req, timeout=3)
1058 except OSError:
1059 pass
1060
1061 for _ in range(20):
1062 if not _process_alive(pid):
1063 break
1064 time.sleep(0.1)
1065 if _process_alive(pid):
1066 try:
1067 os.kill(pid, signal.SIGTERM)
1068 except OSError:
1069 pass
1070 _clear_lock(lock_file)
1071 logger.info('live preview server stopped (pid=%s)', pid)
1072 return 0
1073
1074
1075 def _wait_for_ready(
1076 port: int,
1077 proc: subprocess.Popen,
1078 project_path: Path,
1079 timeout: int = STARTUP_TIMEOUT,
1080 ) -> bool:
1081 """Wait until this project's detached live-preview server responds."""
1082 deadline = time.time() + timeout
1083 health_url = _server_url(port, '/api/health')
1084 last_error = ''
1085 while time.time() < deadline:
1086 if proc.poll() is not None:
1087 logger.error('live preview exited during startup (code=%s)', proc.returncode)
1088 return False
1089 try:
1090 with urllib.request.urlopen(health_url, timeout=1) as response:
1091 data = json.load(response)
1092 if (
1093 response.status == 200
1094 and isinstance(data, dict)
1095 and data.get('service') == 'live_preview'
1096 and data.get('project') == str(project_path)
1097 and data.get('pid') == proc.pid
1098 ):
1099 return True
1100 last_error = 'health response belongs to another service or project'
1101 except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
1102 last_error = str(exc)
1103 time.sleep(0.25)
1104 logger.error(
1105 'live preview did not become ready at %s within %ss%s',
1106 health_url,
1107 timeout,
1108 f' (last error: {last_error})' if last_error else '',
1109 )
1110 return False
1111
1112
1113 def _open_browser(url: str) -> bool:
1114 """Best-effort browser launch after the local server is reachable."""
1115 try:
1116 if os.name == 'nt':
1117 os.startfile(url) # type: ignore[attr-defined]
1118 return True
1119 return bool(webbrowser.open(url))
1120 except OSError as exc:
1121 logger.warning('browser auto-open failed: %s', exc)
1122 except webbrowser.Error as exc:
1123 logger.warning('browser auto-open failed: %s', exc)
1124 return False
1125
1126
1127 def _reuse_running_server(
1128 existing: dict,
1129 *,
1130 open_browser: bool,
1131 requested_port: Optional[int] = None,
1132 ) -> int:
1133 """Idempotent relaunch: point at the already-running preview instead of failing.
1134
1135 A relaunch while the server is alive is the normal second-preview flow
1136 (the first server keeps serving after the browser tab is closed), so it
1137 must re-open the browser and exit 0 — not error out.
1138 """
1139 pid = existing.get('pid', '?')
1140 try:
1141 port = int(existing.get('port', 0) or 0)
1142 except (TypeError, ValueError):
1143 port = 0
1144 if not port:
1145 logger.error(
1146 'live preview is already running for this project (pid=%s) but its '
1147 'lock records no usable port; run --shutdown, then start again',
1148 pid,
1149 )
1150 return 1
1151 if requested_port is not None and port != requested_port:
1152 logger.error(
1153 'live preview is already running for this project on port %s; '
1154 'explicit --port %s cannot reuse it. Run --shutdown, then start again',
1155 port,
1156 requested_port,
1157 )
1158 return 1
1159 url = _server_url(port)
1160 logger.info(
1161 'live preview already running for this project (pid=%s), reusing: %s',
1162 pid, url,
1163 )
1164 if open_browser and not _open_browser(url):
1165 logger.info('browser did not auto-open; open %s manually', url)
1166 return 0
1167
1168
1169 def _open_browser_async(url: str, delay: float = 0.4) -> None:
1170 """Open the browser shortly after Flask starts binding its socket."""
1171 def _open() -> None:
1172 time.sleep(delay)
1173 _open_browser(url)
1174
1175 threading.Thread(target=_open, daemon=True).start()
1176
1177
1178 def build_parser() -> argparse.ArgumentParser:
1179 parser = argparse.ArgumentParser(
1180 description='PPT Master SVG Editor',
1181 formatter_class=argparse.RawDescriptionHelpFormatter,
1182 )
1183 parser.add_argument('project_dir', help='Path to project directory (contains svg_output/)')
1184 parser.add_argument(
1185 '--port',
1186 type=int,
1187 default=None,
1188 help=f'Exact port to listen on (default: first free port from {DEFAULT_PORT})',
1189 )
1190 parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
1191 parser.add_argument(
1192 '--daemon',
1193 action='store_true',
1194 help='Start the server in the background and return after it is reachable',
1195 )
1196 parser.add_argument(
1197 '--live',
1198 action='store_true',
1199 help='Run as Executor live preview: allow empty svg_output/ and keep serving after annotation submit',
1200 )
1201 parser.add_argument(
1202 '--timeout',
1203 type=int,
1204 default=None,
1205 help='Idle timeout in seconds (default: 900; live mode default: 7200; 0 = disabled)',
1206 )
1207 parser.add_argument(
1208 '--shutdown',
1209 action='store_true',
1210 help='Stop a live-preview server left running for this project, then exit (idempotent).',
1211 )
1212 return parser
1213
1214
1215 def main(argv: Optional[list[str]] = None) -> int:
1216 parser = build_parser()
1217 args = parser.parse_args(argv)
1218
1219 logging.basicConfig(
1220 level=logging.INFO,
1221 format='[%(asctime)s] [%(levelname)s] svg_editor: %(message)s',
1222 datefmt='%H:%M:%S',
1223 )
1224
1225 if args.port is not None:
1226 try:
1227 args.port = _validate_port(args.port)
1228 except ValueError as exc:
1229 logger.error('%s', exc)
1230 return 2
1231
1232 project_path = Path(args.project_dir).resolve()
1233 if args.shutdown:
1234 return _shutdown_existing(project_path)
1235
1236 svg_output = project_path / 'svg_output'
1237 if not svg_output.exists():
1238 if args.live:
1239 svg_output.mkdir(parents=True, exist_ok=True)
1240 else:
1241 logger.error('%s does not exist', svg_output)
1242 return 1
1243 elif not svg_output.is_dir():
1244 logger.error('%s is not a directory', svg_output)
1245 return 1
1246
1247 legacy_existing = _legacy_live_lock(project_path)
1248 if legacy_existing:
1249 return _reuse_running_server(
1250 legacy_existing,
1251 open_browser=not args.no_browser,
1252 requested_port=args.port,
1253 )
1254
1255 runtime_dir = _runtime_dir(project_path)
1256 lock_file = _lock_file(project_path)
1257
1258 if args.daemon:
1259 existing = _read_lock(lock_file)
1260 if existing and _process_alive(_lock_pid(existing)):
1261 return _reuse_running_server(
1262 existing,
1263 open_browser=not args.no_browser,
1264 requested_port=args.port,
1265 )
1266
1267 try:
1268 runtime_dir.mkdir(parents=True, exist_ok=True)
1269 except OSError as exc:
1270 logger.error('cannot create live preview runtime directory: %s (%s)', runtime_dir, exc)
1271 return 1
1272 log_path = runtime_dir / 'server.log'
1273 try:
1274 port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT)
1275 except RuntimeError as exc:
1276 logger.error('%s', exc)
1277 return 1
1278 idle_timeout = args.timeout
1279 if idle_timeout is None:
1280 idle_timeout = 7200 if args.live else 900
1281 cmd = [
1282 sys.executable,
1283 str(Path(__file__).resolve()),
1284 str(project_path),
1285 '--port',
1286 str(port),
1287 '--timeout',
1288 str(idle_timeout),
1289 '--no-browser',
1290 ]
1291 if args.live:
1292 cmd.append('--live')
1293 try:
1294 with log_path.open('a', encoding='utf-8') as log:
1295 proc = _popen_detached(
1296 cmd,
1297 stdout=log,
1298 stderr=subprocess.STDOUT,
1299 stdin=subprocess.DEVNULL,
1300 logger=logger,
1301 )
1302 except OSError as exc:
1303 logger.error('cannot write live preview log: %s (%s)', log_path, exc)
1304 return 1
1305 url = _server_url(port)
1306 if not _wait_for_ready(port, proc, project_path):
1307 if proc.poll() is None:
1308 proc.terminate()
1309 logger.error('live preview failed to become reachable: %s (log: %s)', url, log_path)
1310 return 1
1311 logger.info('started live preview in background: %s (pid=%s)', url, proc.pid)
1312 logger.info('log: %s', log_path)
1313 if not args.no_browser and not _open_browser(url):
1314 logger.info('browser did not auto-open; open %s manually', url)
1315 return 0
1316
1317 # Pick a free port: another project's preview/confirm server may already
1318 # hold the default, so bind the next free one instead of crashing — each
1319 # project then serves its own data on its own port (no cross-project mix-up).
1320 try:
1321 port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT)
1322 except RuntimeError as exc:
1323 logger.error('%s', exc)
1324 return 1
1325
1326 # Per-project mutual exclusion. The major driver of orphaned servers is
1327 # --live mode (which used to disable idle timeout entirely) combined with
1328 # silent restarts; reusing the running server on duplicate launches catches
1329 # the accumulation at its source. Stale locks (dead pid) are overwritten
1330 # by _claim_lock.
1331 try:
1332 runtime_dir.mkdir(parents=True, exist_ok=True)
1333 except OSError as exc:
1334 logger.error('cannot create live preview runtime directory: %s (%s)', runtime_dir, exc)
1335 return 1
1336 existing = _claim_lock(lock_file, port)
1337 if existing:
1338 return _reuse_running_server(
1339 existing,
1340 open_browser=not args.no_browser,
1341 requested_port=args.port,
1342 )
1343 # atexit covers normal interpreter shutdown (Ctrl+C / SystemExit);
1344 # /api/shutdown and idle timeout call _release_lock directly before
1345 # os._exit since atexit handlers do not run on os._exit.
1346 atexit.register(_release_lock, lock_file)
1347
1348 # SIGTERM would otherwise terminate without running atexit, leaving a
1349 # stale lock that future launches have to recover from. Translate it
1350 # into SystemExit so the atexit handler above runs. SIGINT (Ctrl+C) is
1351 # already handled by werkzeug's reloader-free shutdown path.
1352 def _on_sigterm(signum: int, _frame) -> None:
1353 logger.info('received signal %s, exiting', signum)
1354 sys.exit(0)
1355 try:
1356 signal.signal(signal.SIGTERM, _on_sigterm)
1357 except (ValueError, OSError):
1358 # ValueError: not in main thread; OSError: unsupported on platform.
1359 pass
1360
1361 idle_timeout = args.timeout
1362 if idle_timeout is None:
1363 # Long but finite default for --live so a forgotten preview eventually
1364 # dies. Set --timeout 0 to keep the historical never-expire behavior.
1365 idle_timeout = 7200 if args.live else 900
1366
1367 app = create_app(
1368 str(project_path),
1369 idle_timeout=idle_timeout,
1370 live=args.live,
1371 lock_file=lock_file,
1372 )
1373
1374 url = _server_url(port)
1375 if not args.no_browser:
1376 _open_browser_async(url)
1377
1378 mode = "live preview (auto-startup)" if args.live else "live preview"
1379 svg_count = len(list(svg_output.glob('*.svg')))
1380 logger.info('running at %s (%s)', url, mode)
1381 logger.info('project: %s', project_path)
1382 logger.info('svg_output: %s (%d slides)', svg_output, svg_count)
1383 logger.info('idle timeout: %ds (0 = disabled)', idle_timeout)
1384 app.run(host=PUBLIC_HOST, port=port, debug=False)
1385 return 0
1386
1387
1388 if __name__ == '__main__':
1389 raise SystemExit(main())
1390
1390 lines PYTHON