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