Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-07-08T16:01:18Z"
|
||||
"syncedAt": "2026-07-09T16:01:35Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "3c0fd70fc04a9bf26e7c510df9a1b7d393ac65c4",
|
||||
"commit": "34d92a50266d2b70be5ac8ac147bd270f52d4a12",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-07-08T16:00:01Z"
|
||||
"syncedAt": "2026-07-09T15:59:59Z"
|
||||
}
|
||||
|
||||
@@ -232,11 +232,7 @@ Never used one of these? Don't worry — in this project they play exactly one r
|
||||
|
||||
### 3. Set Up
|
||||
|
||||
**Option A — Download ZIP** (no Git required; best for a quick trial): click **Code → Download ZIP** on the [GitHub page](https://github.com/hugohe3/ppt-master), then unzip.
|
||||
|
||||
If you plan to keep using PPT Master and update it over time, use Git clone instead.
|
||||
|
||||
**Option B — Git clone** (recommended; requires [Git](https://git-scm.com/downloads) installed):
|
||||
**Option A — Git clone** (recommended; requires [Git](https://git-scm.com/downloads) installed): the preferred path, since a clone can pull the latest version at any time.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/hugohe3/ppt-master.git
|
||||
@@ -249,6 +245,8 @@ Then install dependencies:
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Option B — Download ZIP** (no Git required; best for a quick trial): click **Code → Download ZIP** on the [GitHub page](https://github.com/hugohe3/ppt-master), then unzip, and install dependencies with `pip install -r requirements.txt`. A ZIP has no Git history, so it can't `git pull` — see Updating Later. If that download is too large or fails, grab the skill-only package `ppt-master-skill-*.zip` (~50 MB, fully functional but without the bundled example decks) from the [Releases](https://github.com/hugohe3/ppt-master/releases) page instead.
|
||||
|
||||
#### Updating Later
|
||||
|
||||
**Git clone installs:**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "37427f940014c7d1aa122a3faf9cc16d64d573b6",
|
||||
"commit": "b0beba5b659c664bdbf0c07227fbdee313698dd7",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-07-08T16:00:01Z"
|
||||
"syncedAt": "2026-07-09T15:59:59Z"
|
||||
}
|
||||
|
||||
@@ -365,13 +365,13 @@ Steps:
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
|
||||
```
|
||||
Page opens at `http://localhost:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Stage 1: it shows a "deriving…" state and polls for Stage 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** for a fresh `status: stage1-confirmed` before dropping to the chat fallback. **On success (exit 0 with a stage-1 result), do not pause or report — go straight to step 3 in the same turn.**
|
||||
3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample); enumerable `icons` / `formula_policy` (recommended `id`). The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page, do **not** relaunch:
|
||||
Page opens at the launch-log URL such as `http://127.0.0.1:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Stage 1: it shows a "deriving…" state and polls for Stage 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** for a fresh `status: stage1-confirmed` before dropping to the chat fallback. **On success (exit 0 with a stage-1 result), do not pause or report — go straight to step 3 in the same turn.**
|
||||
3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample); enumerable `icons` / `formula_policy` (recommended `id`). The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page; if Windows cleaned up the server, `--wait-only` auto-recovers it on the recorded/default port so the browser reconnects:
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2
|
||||
```
|
||||
This returns when the page writes the stage-2 `result.json` (`status: stage2-confirmed`). On a non-zero exit, re-check `result.json` once before falling back to chat.
|
||||
4. **Re-derive Stage 3 from the confirmed anchors + design system, then wait for the final confirmation.** Read the stage-2 `result.json`. Author the image and execution recommendations and **overwrite** `recommendations.json` with `"stage": "stage3"`: `image_usage` as one or more source ids (`["ai"]`, `["ai","provided"]`, `["web","placeholder"]`, or `["none"]`; `none` is exclusive); `image_strategy.candidates` as **exactly three non-custom** rendering × palette recommendations from h.5 when `image_usage` includes `ai` (the page adds the fourth Custom card itself); enumerable `image_ai_path` / `generation_mode` and `refine_spec` (recommended `id` / boolean). If the recommendation involves several image sources, keep the source list structured in `recommend.image_usage` and write the usage rationale / page-role guidance into `image_notes` (for example, "封面和章节页用 AI 主视觉,产品页优先用户素材,行业背景页可用网络参考"). Write `image_ai_path` only when `image_usage` includes `ai`. Spot-illustration lean is **not** a candidate field here: it derives from the locked `visual_style`'s illustration propensity and is expressed only in the recommendation rationale / `image_notes`, never as a new confirmation field. Generated-image style palettes are **color behavior only**; final image colors follow the confirmed Stage-2 `color`. Custom image-strategy dimensions are handled by the built-in Custom card, are prose-only, and should not promise a gallery reference image. Then attach to the already-running page, do **not** relaunch (same 600000 ms budget):
|
||||
4. **Re-derive Stage 3 from the confirmed anchors + design system, then wait for the final confirmation.** Read the stage-2 `result.json`. Author the image and execution recommendations and **overwrite** `recommendations.json` with `"stage": "stage3"`: `image_usage` as one or more source ids (`["ai"]`, `["ai","provided"]`, `["web","placeholder"]`, or `["none"]`; `none` is exclusive); `image_strategy.candidates` as **exactly three non-custom** rendering × palette recommendations from h.5 when `image_usage` includes `ai` (the page adds the fourth Custom card itself); enumerable `image_ai_path` / `generation_mode` and `refine_spec` (recommended `id` / boolean). If the recommendation involves several image sources, keep the source list structured in `recommend.image_usage` and write the usage rationale / page-role guidance into `image_notes` (for example, "封面和章节页用 AI 主视觉,产品页优先用户素材,行业背景页可用网络参考"). Write `image_ai_path` only when `image_usage` includes `ai`. Spot-illustration lean is **not** a candidate field here: it derives from the locked `visual_style`'s illustration propensity and is expressed only in the recommendation rationale / `image_notes`, never as a new confirmation field. Generated-image style palettes are **color behavior only**; final image colors follow the confirmed Stage-2 `color`. Custom image-strategy dimensions are handled by the built-in Custom card, are prose-only, and should not promise a gallery reference image. Then attach to the already-running page; `--wait-only` auto-recovers a dead server as above (same 600000 ms budget):
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
|
||||
```
|
||||
@@ -560,7 +560,7 @@ Read references/visual-styles/<locked-style>.md # aesthetic (spec_lock.md `vis
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
|
||||
```
|
||||
- Start it immediately when Executor begins; `svg_output/` may be empty. Editor opens at `http://localhost:5050`; if another project already holds it, the launcher **auto-advances to the next free port** — read the actual URL from the launch log and report that.
|
||||
- Start it immediately when Executor begins; `svg_output/` may be empty. Editor opens at the launch-log URL such as `http://127.0.0.1:5050`; if another project already holds it, the launcher **auto-advances to the next free port** — read the actual URL from the launch log and report that.
|
||||
- Treat the launch URL as a checkpoint value: before writing the first SVG, either report the actual URL from the launcher or state the launch failure explicitly. Do not silently continue while claiming preview is available.
|
||||
- Run it as a long-running side process/session; do not wait for it to exit before generating SVG pages. Do not wait for user confirmation after startup.
|
||||
- **Service must keep running** until one of: (a) the user clicks **Exit preview** in the browser, or (b) the user explicitly asks in chat to stop it. Generation continues even if the user closes the editor.
|
||||
|
||||
@@ -39,7 +39,9 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -54,7 +56,10 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from server_common import ( # noqa: E402
|
||||
claim_lock as _claim_lock,
|
||||
clear_lock as _clear_lock,
|
||||
find_free_port as _find_free_port,
|
||||
lock_pid as _lock_pid,
|
||||
popen_detached as _popen_detached,
|
||||
process_alive as _process_alive,
|
||||
read_lock as _read_lock,
|
||||
release_lock as _release_lock,
|
||||
@@ -69,10 +74,11 @@ logger = logging.getLogger('confirm_ui')
|
||||
# preview lock so the two surfaces never collide.
|
||||
LOCK_FILE_NAME = '.confirm_ui.lock'
|
||||
|
||||
# Round-trip files, both under <project_path>/confirm_ui/.
|
||||
# Round-trip/session files, all under <project_path>/confirm_ui/.
|
||||
CONFIRM_DIR_NAME = 'confirm_ui'
|
||||
RECOMMENDATIONS_NAME = 'recommendations.json'
|
||||
RESULT_NAME = 'result.json'
|
||||
SESSION_NAME = 'session.json'
|
||||
|
||||
# Static option universe served at /api/catalogs (canvas synced live from config).
|
||||
_CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json'
|
||||
@@ -90,6 +96,8 @@ _ICON_PREVIEW_SAMPLES = {
|
||||
# freeing the port before live preview starts at Step 6. One port = one forward
|
||||
# rule for the whole pipeline. They still keep separate processes and locks.
|
||||
DEFAULT_PORT = 5050
|
||||
PUBLIC_HOST = '127.0.0.1'
|
||||
STARTUP_TIMEOUT = 10
|
||||
|
||||
# Default --wait budget, kept just under the 600s Bash-tool ceiling so the
|
||||
# parent (waiting) command returns before the calling harness kills it. The
|
||||
@@ -99,6 +107,147 @@ DEFAULT_PORT = 5050
|
||||
WAIT_TIMEOUT_DEFAULT = 590
|
||||
|
||||
|
||||
def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict:
|
||||
"""Read a JSON object, retrying briefly around non-atomic external writes."""
|
||||
last_error: Exception = ValueError('unknown JSON read error')
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
raise ValueError(f'{path} top-level JSON value must be an object')
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise last_error
|
||||
raise last_error
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, data: dict) -> None:
|
||||
"""Write a JSON object with replace semantics so waiters never see a partial file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp')
|
||||
try:
|
||||
tmp.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _server_url(port: int, path: str = '') -> str:
|
||||
"""Return the loopback URL shown to users and used by readiness probes."""
|
||||
suffix = path if path.startswith('/') or not path else f'/{path}'
|
||||
return f'http://{PUBLIC_HOST}:{port}{suffix}'
|
||||
|
||||
|
||||
def _wait_for_server_ready(
|
||||
port: int,
|
||||
proc: subprocess.Popen,
|
||||
timeout: int = STARTUP_TIMEOUT,
|
||||
) -> bool:
|
||||
"""Wait until the detached child is accepting HTTP requests."""
|
||||
deadline = time.time() + timeout
|
||||
last_error = ''
|
||||
health_url = _server_url(port, '/api/health')
|
||||
while time.time() < deadline:
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
logger.error('confirm UI exited during startup (code=%s)', returncode)
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=1) as resp:
|
||||
if 200 <= resp.status < 500:
|
||||
return True
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(0.2)
|
||||
logger.error(
|
||||
'confirm UI did not become ready at %s within %ss%s',
|
||||
health_url,
|
||||
timeout,
|
||||
f' (last error: {last_error})' if last_error else '',
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _launch_background_server(
|
||||
project_path: Path,
|
||||
*,
|
||||
preferred_port: int,
|
||||
idle_timeout: int,
|
||||
open_browser: bool,
|
||||
) -> tuple[subprocess.Popen, int, Path]:
|
||||
"""Start the confirm server child and wait until it is reachable."""
|
||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
||||
confirm_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = confirm_dir / 'server.log'
|
||||
port = _find_free_port(preferred_port)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
str(project_path),
|
||||
'--port',
|
||||
str(port),
|
||||
'--timeout',
|
||||
str(idle_timeout),
|
||||
'--no-browser',
|
||||
]
|
||||
with log_path.open('a', encoding='utf-8') as log:
|
||||
proc = _popen_detached(
|
||||
cmd,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
logger=logger,
|
||||
)
|
||||
logger.info('log: %s', log_path)
|
||||
if not _wait_for_server_ready(port, proc):
|
||||
raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}')
|
||||
_sync_session_state(confirm_dir, server_port=port, event='server-ready')
|
||||
url = _server_url(port)
|
||||
logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
|
||||
if open_browser:
|
||||
webbrowser.open(url)
|
||||
return proc, port, log_path
|
||||
|
||||
|
||||
def _live_lock(lock_file: Path) -> Optional[dict]:
|
||||
"""Return a live lock; stale entries are overwritten by the recovered child."""
|
||||
existing = _read_lock(lock_file)
|
||||
if not existing:
|
||||
return None
|
||||
if _process_alive(_lock_pid(existing)):
|
||||
return existing
|
||||
return None
|
||||
|
||||
|
||||
def _preferred_recovery_port(lock_file: Path, fallback: int) -> int:
|
||||
"""Prefer a stale lock's port so an already-open browser can reconnect."""
|
||||
existing = _read_lock(lock_file)
|
||||
try:
|
||||
port = int((existing or {}).get('port', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
return port or fallback
|
||||
|
||||
|
||||
def _open_browser_async(url: str, delay: float = 0.4) -> None:
|
||||
"""Open the browser after Flask has had a moment to bind its socket."""
|
||||
def _open() -> None:
|
||||
time.sleep(delay)
|
||||
webbrowser.open(url)
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
|
||||
def _wait_for_result(
|
||||
result_file: Path,
|
||||
proc: subprocess.Popen,
|
||||
@@ -139,10 +288,10 @@ def _wait_for_result(
|
||||
def _result_stage(result_file: Path) -> Optional[str]:
|
||||
"""Return the canonical ``stage`` field of result.json, or None."""
|
||||
try:
|
||||
data = json.loads(result_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = _read_json_object(result_file)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return _stage_key(data.get('stage')) if isinstance(data, dict) else None
|
||||
return _stage_key(data.get('stage'))
|
||||
|
||||
|
||||
def _stage_key(value: object) -> Optional[str]:
|
||||
@@ -175,6 +324,137 @@ def _recommendation_stage(data: dict) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _stage_name(number: Optional[int]) -> Optional[str]:
|
||||
"""Return the canonical stage key for a stage number."""
|
||||
if number == 1:
|
||||
return 'stage1'
|
||||
if number == 2:
|
||||
return 'stage2'
|
||||
if number == 3:
|
||||
return 'stage3'
|
||||
return None
|
||||
|
||||
|
||||
def _result_stage_number(stage: Optional[str]) -> int:
|
||||
"""Return result progression: stage1=1, stage2=2, final=4."""
|
||||
if stage == 'stage1':
|
||||
return 1
|
||||
if stage == 'stage2':
|
||||
return 2
|
||||
if stage == 'final':
|
||||
return 4
|
||||
return 0
|
||||
|
||||
|
||||
def _file_version(path: Path) -> Optional[float]:
|
||||
"""Return a cheap file version for polling state, or None when absent."""
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _read_session(confirm_dir: Path) -> dict:
|
||||
"""Read session.json if present, returning an object."""
|
||||
session_file = confirm_dir / SESSION_NAME
|
||||
if not session_file.exists():
|
||||
return {}
|
||||
try:
|
||||
return _read_json_object(session_file)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _build_session_state(
|
||||
confirm_dir: Path,
|
||||
*,
|
||||
server_port: Optional[int] = None,
|
||||
event: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Derive the resumable Confirm UI state from disk artifacts."""
|
||||
previous = _read_session(confirm_dir)
|
||||
rec_file = confirm_dir / RECOMMENDATIONS_NAME
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
|
||||
rec_stage_number = 0
|
||||
rec_stage = None
|
||||
rec_error = None
|
||||
if rec_file.exists():
|
||||
try:
|
||||
rec_data = _read_json_object(rec_file)
|
||||
rec_stage_number = _recommendation_stage(rec_data)
|
||||
rec_stage = _stage_name(rec_stage_number)
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
rec_error = str(exc)
|
||||
|
||||
result_stage = _result_stage(result_file)
|
||||
result_stage_number = _result_stage_number(result_stage)
|
||||
|
||||
if result_stage == 'final':
|
||||
expected_stage_number = None
|
||||
status = 'done'
|
||||
current_stage = 'final'
|
||||
elif result_stage == 'stage2':
|
||||
expected_stage_number = 3
|
||||
status = 'ready_user' if rec_stage_number >= 3 else 'waiting_agent'
|
||||
current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 3 else 'stage2'
|
||||
elif result_stage == 'stage1':
|
||||
expected_stage_number = 2
|
||||
status = 'ready_user' if rec_stage_number >= 2 else 'waiting_agent'
|
||||
current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 2 else 'stage1'
|
||||
else:
|
||||
expected_stage_number = rec_stage_number or 1
|
||||
status = 'ready_user' if rec_stage_number else 'waiting_agent'
|
||||
current_stage = rec_stage or 'stage1'
|
||||
|
||||
return {
|
||||
'session_id': previous.get('session_id') or uuid.uuid4().hex,
|
||||
'status': status,
|
||||
'current_stage': current_stage,
|
||||
'expected_stage': _stage_name(expected_stage_number),
|
||||
'expected_stage_number': expected_stage_number,
|
||||
'recommendation_stage': rec_stage,
|
||||
'recommendation_stage_number': rec_stage_number,
|
||||
'recommendation_version': _file_version(rec_file),
|
||||
'recommendation_error': rec_error,
|
||||
'result_stage': result_stage,
|
||||
'result_stage_number': result_stage_number,
|
||||
'result_version': _file_version(result_file),
|
||||
'server_port': server_port or previous.get('server_port'),
|
||||
'event': event or previous.get('event') or 'derived',
|
||||
}
|
||||
|
||||
|
||||
def _write_session_state(confirm_dir: Path, session: dict) -> None:
|
||||
"""Persist session.json only when stable state changes."""
|
||||
previous = _read_session(confirm_dir)
|
||||
comparable_previous = dict(previous)
|
||||
comparable_current = dict(session)
|
||||
comparable_previous.pop('updated_at', None)
|
||||
comparable_current.pop('updated_at', None)
|
||||
if comparable_previous == comparable_current:
|
||||
return
|
||||
session = dict(session)
|
||||
session['updated_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
_write_json_atomic(confirm_dir / SESSION_NAME, session)
|
||||
|
||||
|
||||
def _sync_session_state(
|
||||
confirm_dir: Path,
|
||||
*,
|
||||
server_port: Optional[int] = None,
|
||||
event: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Derive and persist the current session state."""
|
||||
session = _build_session_state(
|
||||
confirm_dir,
|
||||
server_port=server_port,
|
||||
event=event,
|
||||
)
|
||||
_write_session_state(confirm_dir, session)
|
||||
return session
|
||||
|
||||
|
||||
# Stage-1 anchors and Stage-2 design-system choices. On later pages these sections
|
||||
# are not rendered (they were already confirmed), so their values live only in
|
||||
# browser STATE — lost on a refresh. Folding them from result.json into the
|
||||
@@ -188,10 +468,8 @@ _DESIGN_RECOMMEND_KEYS = ('icons', 'formula_policy')
|
||||
def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
|
||||
"""Fold already-confirmed choices into later-stage recommendations."""
|
||||
try:
|
||||
res = json.loads(result_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return
|
||||
if not isinstance(res, dict):
|
||||
res = _read_json_object(result_file)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return
|
||||
recommend = data.setdefault('recommend', {})
|
||||
if not isinstance(recommend, dict):
|
||||
@@ -239,7 +517,7 @@ def _wait_only_for_result(
|
||||
return 0
|
||||
|
||||
lock = _read_lock(lock_file)
|
||||
pid = int((lock or {}).get('pid', 0) or 0)
|
||||
pid = _lock_pid(lock)
|
||||
if not pid or not _process_alive(pid):
|
||||
logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
|
||||
return 1
|
||||
@@ -267,10 +545,10 @@ def _shutdown_existing(lock_file: Path) -> int:
|
||||
if not existing:
|
||||
logger.info('no confirm server running — nothing to stop')
|
||||
return 0
|
||||
pid = int(existing.get('pid', 0) or 0)
|
||||
pid = _lock_pid(existing)
|
||||
port = existing.get('port')
|
||||
if not _process_alive(pid):
|
||||
_release_lock(lock_file)
|
||||
_clear_lock(lock_file)
|
||||
logger.info('confirm server already stopped; cleared stale lock')
|
||||
return 0
|
||||
# Graceful first: the server flushes and releases its own lock.
|
||||
@@ -294,7 +572,7 @@ def _shutdown_existing(lock_file: Path) -> int:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
_release_lock(lock_file)
|
||||
_clear_lock(lock_file)
|
||||
logger.info('confirm server stopped (pid=%s)', pid)
|
||||
return 0
|
||||
|
||||
@@ -395,6 +673,7 @@ def create_app(
|
||||
project_dir: str,
|
||||
idle_timeout: int = 900,
|
||||
lock_file: Optional[Path] = None,
|
||||
server_port: Optional[int] = None,
|
||||
) -> Flask:
|
||||
"""Create and configure the Flask app for a given project directory."""
|
||||
project_path = Path(project_dir).resolve()
|
||||
@@ -404,6 +683,7 @@ def create_app(
|
||||
app.config['PROJECT_PATH'] = project_path
|
||||
app.config['CONFIRM_DIR'] = confirm_dir
|
||||
app.config['LOCK_FILE'] = lock_file
|
||||
app.config['SERVER_PORT'] = server_port
|
||||
app.config['LAST_REQUEST_TIME'] = time.time()
|
||||
|
||||
@app.before_request
|
||||
@@ -445,6 +725,44 @@ def create_app(
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.route('/api/health')
|
||||
def health():
|
||||
"""Expose a cheap readiness probe for the daemon launcher."""
|
||||
rec_file = confirm_dir / RECOMMENDATIONS_NAME
|
||||
rec_ok = False
|
||||
stage = None
|
||||
if rec_file.exists():
|
||||
try:
|
||||
rec_data = _read_json_object(rec_file, retries=0)
|
||||
rec_ok = True
|
||||
stage = _recommendation_stage(rec_data)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
rec_ok = False
|
||||
resp = jsonify({
|
||||
'status': 'ok',
|
||||
'project': str(project_path),
|
||||
'recommendations': rec_ok,
|
||||
'stage': stage,
|
||||
'session': _build_session_state(
|
||||
confirm_dir,
|
||||
server_port=app.config.get('SERVER_PORT'),
|
||||
),
|
||||
})
|
||||
resp.headers['Cache-Control'] = 'no-store'
|
||||
return resp
|
||||
|
||||
@app.route('/api/session')
|
||||
def get_session():
|
||||
"""Expose the derived three-stage wizard state for browser polling."""
|
||||
session = _sync_session_state(
|
||||
confirm_dir,
|
||||
server_port=app.config.get('SERVER_PORT'),
|
||||
event='poll',
|
||||
)
|
||||
resp = jsonify(session)
|
||||
resp.headers['Cache-Control'] = 'no-store'
|
||||
return resp
|
||||
|
||||
@app.route('/api/catalogs')
|
||||
def get_catalogs():
|
||||
"""Serve the option universe; canvas is synced live from config.py so
|
||||
@@ -489,8 +807,8 @@ def create_app(
|
||||
if not rec_file.exists():
|
||||
return jsonify({'error': 'recommendations not found'}), 404
|
||||
try:
|
||||
data = json.loads(rec_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
data = _read_json_object(rec_file)
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
return jsonify({'error': f'invalid recommendations.json: {exc}'}), 400
|
||||
# Report whether a result already exists (re-open after confirm).
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
@@ -527,9 +845,11 @@ def create_app(
|
||||
result['status'] = 'confirmed'
|
||||
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
result_file.write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
_write_json_atomic(result_file, result)
|
||||
_sync_session_state(
|
||||
confirm_dir,
|
||||
server_port=app.config.get('SERVER_PORT'),
|
||||
event=f'{result["stage"]}-submitted',
|
||||
)
|
||||
logger.info('%s confirmation written to %s', result['stage'], result_file)
|
||||
return jsonify({'status': 'ok'})
|
||||
@@ -558,8 +878,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait-only', action='store_true',
|
||||
help='Do not launch. Attach to the already-running confirm server for '
|
||||
'this project and wait for an already-open page to write result.json.',
|
||||
help='Attach to the confirm server for this project and wait for an '
|
||||
'already-open page to write result.json. If the server died, '
|
||||
'recover it on the recorded/default port so browser polling can resume.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait-stage', default='final', metavar='{stage2,final}',
|
||||
@@ -611,9 +932,34 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# Staged wait: attach to the server launched by the first --wait and block
|
||||
# until the page writes the requested intermediate or final result.json.
|
||||
if args.wait_only:
|
||||
lock_file = project_path / LOCK_FILE_NAME
|
||||
if not _live_lock(lock_file):
|
||||
if not (project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME).exists():
|
||||
logger.error(
|
||||
'%s not found — cannot recover confirm UI before wait-only',
|
||||
project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME,
|
||||
)
|
||||
return 1
|
||||
recovery_port = _preferred_recovery_port(lock_file, args.port)
|
||||
try:
|
||||
_, actual_port, _ = _launch_background_server(
|
||||
project_path,
|
||||
preferred_port=recovery_port,
|
||||
idle_timeout=args.timeout,
|
||||
open_browser=False,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.error('%s', exc)
|
||||
return 1
|
||||
if actual_port != recovery_port and not args.no_browser:
|
||||
webbrowser.open(_server_url(actual_port))
|
||||
logger.info(
|
||||
'recovered confirm UI for wait-only at %s; the browser polling should resume',
|
||||
_server_url(actual_port),
|
||||
)
|
||||
return _wait_only_for_result(
|
||||
project_path / CONFIRM_DIR_NAME / RESULT_NAME,
|
||||
project_path / LOCK_FILE_NAME,
|
||||
lock_file,
|
||||
args.wait_timeout,
|
||||
wait_stage,
|
||||
)
|
||||
@@ -629,53 +975,29 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
if args.daemon:
|
||||
lock_file = project_path / LOCK_FILE_NAME
|
||||
existing = _read_lock(lock_file)
|
||||
if existing and _process_alive(int(existing.get('pid', 0))):
|
||||
if existing and _process_alive(_lock_pid(existing)):
|
||||
existing_pid = existing.get('pid', '?')
|
||||
existing_port = existing.get('port', '?')
|
||||
logger.error(
|
||||
'confirm UI is already running for this project '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s',
|
||||
existing_pid, existing_port, existing_port,
|
||||
'(pid=%s, port=%s). Open http://%s:%s',
|
||||
existing_pid, existing_port, PUBLIC_HOST, existing_port,
|
||||
)
|
||||
return 1
|
||||
|
||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
||||
confirm_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = confirm_dir / 'server.log'
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
started_at = time.time()
|
||||
# Pick a free port up front (another project may hold the default) and
|
||||
# pass the concrete port to the child so the reported URL is accurate.
|
||||
port = _find_free_port(args.port)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
str(project_path),
|
||||
'--port',
|
||||
str(port),
|
||||
'--timeout',
|
||||
str(args.timeout),
|
||||
]
|
||||
if args.no_browser:
|
||||
cmd.append('--no-browser')
|
||||
creationflags = 0
|
||||
popen_kwargs = {}
|
||||
if os.name == 'nt':
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
else:
|
||||
popen_kwargs['start_new_session'] = True
|
||||
with log_path.open('a', encoding='utf-8') as log:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
**popen_kwargs,
|
||||
try:
|
||||
proc, port, _ = _launch_background_server(
|
||||
project_path,
|
||||
preferred_port=args.port,
|
||||
idle_timeout=args.timeout,
|
||||
open_browser=not args.no_browser,
|
||||
)
|
||||
url = f'http://localhost:{port}'
|
||||
logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
|
||||
logger.info('log: %s', log_path)
|
||||
except RuntimeError as exc:
|
||||
logger.error('%s', exc)
|
||||
return 1
|
||||
if args.wait:
|
||||
return _wait_for_result(result_file, proc, started_at, args.wait_timeout)
|
||||
return 0
|
||||
@@ -689,8 +1011,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
existing_port = existing.get('port', '?')
|
||||
logger.error(
|
||||
'confirm UI is already running for this project '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s, or run: kill %s',
|
||||
existing_pid, existing_port, existing_port, existing_pid,
|
||||
'(pid=%s, port=%s). Open http://%s:%s, or run: kill %s',
|
||||
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
|
||||
)
|
||||
return 1
|
||||
atexit.register(_release_lock, lock_file)
|
||||
@@ -707,16 +1029,17 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
str(project_path),
|
||||
idle_timeout=args.timeout,
|
||||
lock_file=lock_file,
|
||||
server_port=args.port,
|
||||
)
|
||||
|
||||
url = f'http://localhost:{args.port}'
|
||||
url = _server_url(args.port)
|
||||
if not args.no_browser:
|
||||
webbrowser.open(url)
|
||||
_open_browser_async(url)
|
||||
|
||||
logger.info('running at %s', url)
|
||||
logger.info('project: %s', project_path)
|
||||
logger.info('idle timeout: %ds (0 = disabled)', args.timeout)
|
||||
app.run(host='127.0.0.1', port=args.port, debug=False)
|
||||
app.run(host=PUBLIC_HOST, port=args.port, debug=False)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+92
-23
@@ -21,6 +21,7 @@
|
||||
btn_confirm: "Confirm",
|
||||
btn_next: "Next →",
|
||||
deriving: "Generating the downstream options from your choices…",
|
||||
connection_lost: "Connection to the confirm server was interrupted; retrying. If this keeps failing, return to the chat for confirmation.",
|
||||
already_confirmed: "Already confirmed once. Re-submitting overwrites the previous choices.",
|
||||
confirmed_title: "✓ Confirmed",
|
||||
confirmed_hint: "Your choices are saved. You can close this page and return to the chat.",
|
||||
@@ -69,6 +70,9 @@
|
||||
font_body: "Body",
|
||||
font_body_size: "Body baseline size",
|
||||
font_body_size_hint: "All type sizes derive from this body baseline.",
|
||||
body_size_unit_relation: "SVG px to PPT pt: 1px = 0.75pt.",
|
||||
body_size_pt_hint: "Approximately {pt} pt (1px = 0.75pt; saved as px).",
|
||||
role_size_pt_hint: "≈ {pt} pt",
|
||||
body_size_hint_canvas: "This canvas suggests ~{lo}–{hi}px (scales with canvas height).",
|
||||
body_size_hint_purpose: "This delivery purpose recommends {def}px — one fixed size, not a range.",
|
||||
body_size_hint_oor: "(Current value is outside the usual range for this canvas — check the unit is right and that it fits.)",
|
||||
@@ -128,6 +132,7 @@
|
||||
btn_confirm: "確定",
|
||||
btn_next: "次へ →",
|
||||
deriving: "選択内容をもとに後続の選択肢を生成しています…",
|
||||
connection_lost: "確認ページのサーバー接続が中断されました。再試行しています。失敗が続く場合はチャットで確認してください。",
|
||||
already_confirmed: "すでに一度確定済みです。再送信すると前回の選択を上書きします。",
|
||||
confirmed_title: "✓ 確定しました",
|
||||
confirmed_hint: "選択内容を保存しました。このページを閉じてチャットに戻ってください。",
|
||||
@@ -176,6 +181,9 @@
|
||||
font_body: "本文",
|
||||
font_body_size: "本文の基準サイズ",
|
||||
font_body_size_hint: "すべての文字サイズはこの本文基準から導出されます。",
|
||||
body_size_unit_relation: "SVG px と PPT pt の換算:1px = 0.75pt。",
|
||||
body_size_pt_hint: "約 {pt} pt(1px = 0.75pt 換算、保存は px)。",
|
||||
role_size_pt_hint: "約 {pt} pt",
|
||||
body_size_hint_canvas: "このキャンバスの目安は約{lo}–{hi}px(キャンバスの高さに応じて変化)。",
|
||||
body_size_hint_purpose: "この利用シーンの推奨は{def}px — 範囲ではなく固定値です。",
|
||||
body_size_hint_oor: "(現在の値はこのキャンバスの通常範囲外です — 単位とサイズ感を確認してください。)",
|
||||
@@ -235,6 +243,7 @@
|
||||
btn_confirm: "确认",
|
||||
btn_next: "下一步 →",
|
||||
deriving: "正在根据你的选择生成下游选项…",
|
||||
connection_lost: "确认页服务连接中断,正在重试;如果持续失败,请回到聊天窗口走聊天确认。",
|
||||
already_confirmed: "已确认过一次,重新提交会覆盖之前的选择。",
|
||||
confirmed_title: "✓ 已确认",
|
||||
confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。",
|
||||
@@ -283,6 +292,9 @@
|
||||
font_body: "正文",
|
||||
font_body_size: "正文基准字号",
|
||||
font_body_size_hint: "所有字号按这个正文基准推导。",
|
||||
body_size_unit_relation: "SVG px 与 PPT pt 的换算:1px = 0.75pt。",
|
||||
body_size_pt_hint: "约 {pt} pt(按 1px = 0.75pt 换算;提交仍保存 px)。",
|
||||
role_size_pt_hint: "约 {pt} pt",
|
||||
body_size_hint_canvas: "当前画布建议 ~{lo}–{hi}px(随画布高度缩放)。",
|
||||
body_size_hint_purpose: "该交付目的推荐 {def}px(单一固定值,非区间)。",
|
||||
body_size_hint_oor: "(当前数值超出该画布的常用范围——请确认单位无误、是否合适。)",
|
||||
@@ -1144,6 +1156,13 @@
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function formatPtFromPx(value) {
|
||||
var px = parseFloat(value);
|
||||
if (!isFinite(px)) return "";
|
||||
var pt = Math.round(px * 0.75 * 10) / 10;
|
||||
return pt % 1 === 0 ? String(Math.round(pt)) : String(pt);
|
||||
}
|
||||
|
||||
function normalizeTypographyForSubmit(payload) {
|
||||
if (!payload.typography || typeof payload.typography !== "object") return;
|
||||
var typ = payload.typography;
|
||||
@@ -1398,6 +1417,7 @@
|
||||
|
||||
var sizeField = el("div", "subfield");
|
||||
sizeField.appendChild(el("div", "subfield-label", t("font_body_size")));
|
||||
sizeField.appendChild(el("div", "toggle-desc body-size-relation", t("body_size_unit_relation")));
|
||||
var sizeRow = el("div", "font-size-row");
|
||||
var sizeInput = el("input", "num-input font-size-input");
|
||||
sizeInput.type = "number";
|
||||
@@ -1415,8 +1435,9 @@
|
||||
refreshStylePreview();
|
||||
});
|
||||
sizeRow.appendChild(sizeInput);
|
||||
var sizeHint = el("div", "toggle-desc");
|
||||
sizeRow.appendChild(sizeHint);
|
||||
sizeRow.appendChild(el("span", "font-size-unit", "px"));
|
||||
var sizePtHint = el("div", "toggle-desc body-size-pt");
|
||||
var sizeHint = el("div", "toggle-desc body-size-hint");
|
||||
// Hint only — the user's value is never overwritten; downstream §g
|
||||
// re-derives if ignored. PPT body is one fixed px value per delivery
|
||||
// purpose (not a range); non-PPT canvases scale px to canvas height.
|
||||
@@ -1441,6 +1462,9 @@
|
||||
// canvas's usual px range, so an accidental extreme value is visible
|
||||
// instead of silently submitting it.
|
||||
var cur = parseFloat(STATE.typography && STATE.typography.body_size);
|
||||
sizePtHint.textContent = isFinite(cur)
|
||||
? t("body_size_pt_hint").replace("{pt}", formatPtFromPx(cur))
|
||||
: "";
|
||||
if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) {
|
||||
txt += " " + t("body_size_hint_oor");
|
||||
}
|
||||
@@ -1448,6 +1472,8 @@
|
||||
};
|
||||
refreshBodySizeHint();
|
||||
sizeField.appendChild(sizeRow);
|
||||
sizeField.appendChild(sizePtHint);
|
||||
sizeField.appendChild(sizeHint);
|
||||
|
||||
// Delivery purpose is a Stage-1 anchor confirmed inside renderAudience (§c) —
|
||||
// it is set before this Stage-2 section exists, so its value drives the
|
||||
@@ -1462,9 +1488,18 @@
|
||||
sizeOverride.appendChild(el("div", "subfield-label", t("size_override")));
|
||||
var srow = el("div", "hex-row");
|
||||
var sizeInputs = {};
|
||||
var sizePtHints = {};
|
||||
function refreshRolePtHint(role) {
|
||||
var input = sizeInputs[role];
|
||||
var hint = sizePtHints[role];
|
||||
if (!input || !hint) return;
|
||||
var pt = formatPtFromPx(input.value);
|
||||
hint.textContent = pt ? t("role_size_pt_hint").replace("{pt}", pt) : "";
|
||||
}
|
||||
SIZE_ROLES.forEach(function (role) {
|
||||
var wrap = el("div", "hex-cell");
|
||||
wrap.appendChild(el("div", "hex-cell-label", t("size_role_" + role)));
|
||||
var inputLine = el("div", "role-size-line");
|
||||
var inp = document.createElement("input");
|
||||
inp.type = "number"; inp.min = "6"; inp.max = "200"; inp.step = "1";
|
||||
inp.addEventListener("input", function () {
|
||||
@@ -1472,10 +1507,16 @@
|
||||
if (!STATE.typography.sizes) STATE.typography.sizes = {};
|
||||
// Independent input — each role holds its own value; no cascade.
|
||||
STATE.typography.sizes[role] = inp.value;
|
||||
refreshRolePtHint(role);
|
||||
refreshStylePreview();
|
||||
});
|
||||
sizeInputs[role] = inp;
|
||||
wrap.appendChild(inp); srow.appendChild(wrap);
|
||||
inputLine.appendChild(inp);
|
||||
inputLine.appendChild(el("span", "font-size-unit", "px"));
|
||||
wrap.appendChild(inputLine);
|
||||
sizePtHints[role] = el("div", "role-size-pt");
|
||||
wrap.appendChild(sizePtHints[role]);
|
||||
srow.appendChild(wrap);
|
||||
});
|
||||
sizeOverride.appendChild(srow);
|
||||
sec.appendChild(sizeOverride);
|
||||
@@ -1495,6 +1536,7 @@
|
||||
var hasVal = cur !== undefined && cur !== null && cur !== "";
|
||||
if (!hasVal) STATE.typography.sizes[role] = deriveSize(role, bodyVal);
|
||||
if (sizeInputs[role]) sizeInputs[role].value = STATE.typography.sizes[role];
|
||||
refreshRolePtHint(role);
|
||||
});
|
||||
};
|
||||
refreshSizeInputs();
|
||||
@@ -2161,25 +2203,36 @@
|
||||
l.style.display = "block";
|
||||
}
|
||||
|
||||
// Poll the recommendations endpoint (no-store) until the AI overwrites it with
|
||||
// the next re-derived stage, then render it in the same session.
|
||||
// Poll session state first. It is derived from recommendations.json and
|
||||
// result.json, so a recovered server can tell the existing page exactly when
|
||||
// the next re-derived stage is ready.
|
||||
function pollForStage(nextStage) {
|
||||
fetch("/api/recommendations", { cache: "no-store" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("poll failed"); return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data && stageNumber(data) === nextStage) { enterStage(data, nextStage); }
|
||||
else { setTimeout(function () { pollForStage(nextStage); }, 1200); }
|
||||
fetchJson("/api/session", "session")
|
||||
.then(function (session) {
|
||||
var readyStage = Number(session && session.recommendation_stage_number || 0);
|
||||
if (readyStage < nextStage) {
|
||||
setTimeout(function () { pollForStage(nextStage); }, 1200);
|
||||
return null;
|
||||
}
|
||||
return fetchJson("/api/recommendations", "recommendations").then(function (data) {
|
||||
var serverStage = stageNumber(data);
|
||||
if (data && typeof serverStage === "number" && serverStage >= nextStage) {
|
||||
enterStage(data, serverStage);
|
||||
}
|
||||
else { setTimeout(function () { pollForStage(nextStage); }, 1200); }
|
||||
return null;
|
||||
});
|
||||
}).catch(function (err) {
|
||||
var l = document.getElementById("loading");
|
||||
if (l) l.textContent = t("load_error") + " " + (err && err.message ? err.message : "");
|
||||
if (l) l.textContent = t("connection_lost") + " " + (err && err.message ? err.message : "");
|
||||
setTimeout(function () { pollForStage(nextStage); }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function enterStage(data, stage) {
|
||||
REC = data;
|
||||
if (stage === 2) initStage2State();
|
||||
if (stage === 3) initStage3State();
|
||||
if (stage >= 2) initStage2State();
|
||||
if (stage >= 3) initStage3State();
|
||||
STAGE = stage;
|
||||
document.getElementById("loading").style.display = "none";
|
||||
document.getElementById("sections").style.display = "block";
|
||||
@@ -2235,21 +2288,37 @@
|
||||
e.textContent = msg;
|
||||
}
|
||||
|
||||
function fetchJson(url, label) {
|
||||
return fetch(url, { cache: "no-store" }).then(function (r) {
|
||||
return r.text().then(function (text) {
|
||||
var data = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); }
|
||||
catch (e) {
|
||||
if (r.ok) throw new Error((label || url) + ": invalid JSON");
|
||||
}
|
||||
}
|
||||
if (!r.ok) {
|
||||
var serverMsg = data && data.error ? data.error : (text || r.statusText || r.status);
|
||||
throw new Error((label || url) + ": " + serverMsg);
|
||||
}
|
||||
return data || {};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadCatalogs() {
|
||||
return fetch("/api/catalogs")
|
||||
.then(function (r) { if (r.ok) return r.json(); throw new Error("no api"); })
|
||||
.catch(function () { return fetch("/static/catalogs.json").then(function (r) { return r.json(); }); });
|
||||
return fetchJson("/api/catalogs", "catalogs")
|
||||
.catch(function () { return fetchJson("/static/catalogs.json", "static catalogs"); });
|
||||
}
|
||||
|
||||
function loadIconPreviews() {
|
||||
return fetch("/api/icon-previews")
|
||||
.then(function (r) { if (r.ok) return r.json(); throw new Error("no icon preview api"); })
|
||||
return fetchJson("/api/icon-previews", "icon previews")
|
||||
.catch(function () { return {}; });
|
||||
}
|
||||
|
||||
function loadAiImageComparison() {
|
||||
return fetch("/api/ai-image-comparison")
|
||||
.then(function (r) { if (r.ok) return r.json(); throw new Error("no ai image comparison api"); })
|
||||
return fetchJson("/api/ai-image-comparison", "ai image comparison")
|
||||
.catch(function () { return {}; });
|
||||
}
|
||||
|
||||
@@ -2337,7 +2406,7 @@
|
||||
|
||||
Promise.all([
|
||||
loadCatalogs(),
|
||||
fetch("/api/recommendations").then(function (r) { if (!r.ok) throw new Error("load failed"); return r.json(); }),
|
||||
fetchJson("/api/recommendations", "recommendations"),
|
||||
loadIconPreviews(),
|
||||
loadAiImageComparison()
|
||||
]).then(function (res) {
|
||||
@@ -2360,8 +2429,8 @@
|
||||
if (REC._already_confirmed) {
|
||||
document.getElementById("confirm-status").textContent = t("already_confirmed");
|
||||
}
|
||||
}).catch(function () {
|
||||
showError(t("load_error"));
|
||||
}).catch(function (err) {
|
||||
showError(t("load_error") + " " + (err && err.message ? err.message : ""));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -504,6 +504,14 @@ body {
|
||||
.image-usage-notes-input { min-height: 64px; resize: vertical; }
|
||||
.font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.font-size-input { width: 96px; }
|
||||
.font-size-unit {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.body-size-relation { margin-bottom: 6px; }
|
||||
.body-size-pt { margin-top: 6px; }
|
||||
.body-size-hint { margin-top: 3px; }
|
||||
|
||||
.subfield { margin-top: 14px; }
|
||||
.subfield-label { font-size: 12.5px; color: var(--muted); margin-bottom: 7px; }
|
||||
@@ -577,6 +585,8 @@ body {
|
||||
.hex-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.hex-cell-label { font-size: 11px; color: var(--muted); }
|
||||
.hex-input-line { display: flex; align-items: center; gap: 6px; }
|
||||
.role-size-line { display: flex; align-items: center; gap: 6px; }
|
||||
.role-size-pt { min-height: 15px; font-size: 11px; color: var(--muted); }
|
||||
.hex-cell input {
|
||||
width: 92px;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -32,9 +32,10 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
|
||||
```
|
||||
|
||||
- Binds `127.0.0.1:5050` by default — or the next free port if another project already holds it (the launch log prints the actual URL) — and auto-opens the browser (suppress with `--no-browser`). `--port <other>` forces a specific port.
|
||||
- In `--daemon` mode the launcher starts the child server with browser opening suppressed, waits for `GET /api/health` to prove the server is accepting requests, then opens the printed `http://127.0.0.1:<port>` URL. If health never becomes reachable, the command fails before presenting a dead page.
|
||||
- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`).
|
||||
- `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns).
|
||||
- `--wait-only` does **not** launch a server — it attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the requested stage. Use `--wait-stage stage2` for the middle design-system handoff, then the default `--wait-stage final` for the final Stage-3 confirmation. It keys on the **stage alone** (no mtime gate), because a user may submit before this wait command is issued. Same `--wait-timeout` budget; on timeout / server-gone it returns non-zero and the caller re-checks `result.json` once before the chat fallback.
|
||||
- `--wait-only` attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the requested stage. If the recorded server died (common on Windows hosts that clean up background children), it automatically restarts the confirm server on the recorded/default port so the browser's polling reconnects without user action. Use `--wait-stage stage2` for the middle design-system handoff, then the default `--wait-stage final` for the final Stage-3 confirmation. It keys on the **stage alone** (no mtime gate), because a user may submit before this wait command is issued. Same `--wait-timeout` budget; on timeout / recovery failure it returns non-zero and the caller re-checks `result.json` once before the chat fallback.
|
||||
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. SKILL.md Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts.
|
||||
- Refuses to start unless `<project_path>/confirm_ui/recommendations.json` exists (except `--shutdown`, which needs no recommendations).
|
||||
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
||||
@@ -64,7 +65,7 @@ The front-end loads `/api/catalogs` (served by the confirm server) and falls bac
|
||||
|
||||
## Round-trip data contract
|
||||
|
||||
Both files live under `<project_path>/confirm_ui/`.
|
||||
Round-trip and session files live under `<project_path>/confirm_ui/`.
|
||||
|
||||
### Three-stage flow
|
||||
|
||||
@@ -77,7 +78,7 @@ The page runs as a **three-stage wizard in one browser session**. `recommendatio
|
||||
| `"stage3"` | images and execution — image usage + generated-image style, generation mode, refine spec | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
||||
| *(absent)* | legacy single-pass — every section on one page | **Confirm** | single final write (`status: "confirmed"`) — backward-compatible |
|
||||
|
||||
The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-derives** the design-system candidates from the user's actual anchors, overwrites `recommendations.json` with `"stage": "stage2"`, and re-attaches with `--wait-only --wait-stage stage2`. After the Stage-2 result, it **re-derives** image and execution recommendations from the confirmed anchors + design system, overwrites `recommendations.json` with `"stage": "stage3"`, and re-attaches with `--wait-only` for the final result. The page preserves earlier selections across transitions (single JS session). `GET /api/recommendations` is served `no-store` so polls see overwrites; on later stages the server folds already-confirmed choices from `result.json` back into the payload so a refresh / reopen re-initializes from the user's actual choices even though those sections are no longer rendered.
|
||||
The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-derives** the design-system candidates from the user's actual anchors, overwrites `recommendations.json` with `"stage": "stage2"`, and re-attaches with `--wait-only --wait-stage stage2`. After the Stage-2 result, it **re-derives** image and execution recommendations from the confirmed anchors + design system, overwrites `recommendations.json` with `"stage": "stage3"`, and re-attaches with `--wait-only` for the final result. The page preserves earlier selections across transitions (single JS session). `GET /api/session` is the browser's waiting-state endpoint: it is derived from `recommendations.json`, `result.json`, and the active server port, then persisted to `session.json` so a recovered server can resume the same stage state. Only after `/api/session` reports that the next recommendation stage is ready does the page fetch `GET /api/recommendations`. `GET /api/recommendations` is served `no-store` so polls see overwrites; on later stages the server folds already-confirmed choices from `result.json` back into the payload so a refresh / reopen re-initializes from the user's actual choices even though those sections are no longer rendered.
|
||||
|
||||
### Input — `recommendations.json` (written by Strategist before launch)
|
||||
|
||||
@@ -155,8 +156,8 @@ The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-deri
|
||||
- When `recommend.image_usage` includes `ai`, also set `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`; the page presents these as explicit choices.
|
||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
|
||||
- **Candidate display text may be multilingual**: color / typography candidates can provide `name_zh` / `name_en` / `name_ja` and `note_zh` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note`. Labels resolve in the page language first, then fall back across the others (a `ja` page: ja → en → zh; zh/en pages keep their zh↔en fallback and try `_ja` last), so when `lang` is `ja` always include the `_ja` variants — otherwise the candidate labels render in English.
|
||||
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should include topic-matched `sample_heading`, `sample_heading_latin`, `sample_body`, and `sample_body_latin`; do not reuse unrelated fixed examples such as a digital-transformation headline for a travel, education, product, or brand deck. Each candidate should also include `body_size` — the body baseline in **px** (the system's only unit, every canvas). The initial value comes from the candidate's `body_size`, sized for the recommended delivery purpose: `text` ~20 · `balanced` ~24 · `presentation` ~32. On submit the page writes `typography.body_size` as px directly — no pt conversion, no `body_size_pt` provenance. The page exposes `body_size` as an editable numeric field whose hint shows the recommended size — **one fixed px per delivery purpose on PPT (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (non-PPT ≈2.5–3.3% of height in px). The user may still edit it; an out-of-range flag only warns if the value strays far (e.g. a unit mistake). **Inputs are independent — the hint updates with canvas / delivery purpose but never rewrites a value the user can see.** It also offers a custom typography text box so the user is not limited to the proposed candidates.
|
||||
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes **independent** editable inputs for `title` / `subtitle` / `annotation`. Each role is pre-filled **once** with a starting value — the candidate's `typography.sizes[role]` if provided, otherwise a one-time ramp suggestion (`body × ` mid-band ratio) — and then holds its own value. **There is no cross-field cascade**: changing `body_size`, `delivery_purpose`, or canvas updates only the recommended-value hint, never the role values; a re-render preserves exactly what the user sees. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. Seeding `sizes` in a candidate is optional — omit it and each role gets its one-time ramp suggestion.
|
||||
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should include topic-matched `sample_heading`, `sample_heading_latin`, `sample_body`, and `sample_body_latin`; do not reuse unrelated fixed examples such as a digital-transformation headline for a travel, education, product, or brand deck. Each candidate should also include `body_size` — the body baseline in **px** (the system's only unit, every canvas). The initial value comes from the candidate's `body_size`, sized for the recommended delivery purpose: `text` ~20 · `balanced` ~24 · `presentation` ~32. On submit the page writes `typography.body_size` as px directly — no pt conversion, no `body_size_pt` provenance. The page exposes `body_size` as an editable numeric field whose hint shows the recommended size — **one fixed px per delivery purpose on PPT (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (non-PPT ≈2.5–3.3% of height in px). The input area labels the value as px, shows the SVG px → PPT pt relation (`1px = 0.75pt`) above the input, and displays the current approximate pt value below it for user orientation only; the output contract remains px-only. The user may still edit it; an out-of-range flag only warns if the value strays far (e.g. a unit mistake). **Inputs are independent — the hint updates with canvas / delivery purpose but never rewrites a value the user can see.** It also offers a custom typography text box so the user is not limited to the proposed candidates.
|
||||
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes **independent** editable inputs for `title` / `subtitle` / `annotation`. Each role is pre-filled **once** with a starting value — the candidate's `typography.sizes[role]` if provided, otherwise a one-time ramp suggestion (`body × ` mid-band ratio) — and then holds its own value. **There is no cross-field cascade**: changing `body_size`, `delivery_purpose`, or canvas updates only the recommended-value hint, never the role values; a re-render preserves exactly what the user sees. Each role input is labelled as px and shows an approximate pt equivalent (`1px = 0.75pt`) for orientation. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. Seeding `sizes` in a candidate is optional — omit it and each role gets its one-time ramp suggestion.
|
||||
- **`delivery_purpose`** (enumerable, PPT only) is the primary driver of the body baseline: `text` (read-close), `balanced` (business, the default), `presentation`. It is surfaced in the **§c key-information area** (beside audience, Stage 1) as a consumption-mode choice; the Stage-2 typography section then reads the confirmed value for the recommended body px — the pick itself does not rewrite the body field (inputs are independent). `recommend.delivery_purpose` pre-selects one (default `balanced`); the user's pick writes back to `result.json.delivery_purpose` as a plain id. Strategist uses it to set the px body baseline — **one fixed px per purpose (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (see [strategist.md §g](../../references/strategist.md)). Non-PPT canvases omit it.
|
||||
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
|
||||
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` includes `ai`. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. Author **exactly three non-custom recommendation candidates** here; the page displays those three, then appends one built-in **Custom** card. If extra candidates are present, the page shows only the first three non-custom entries. When `rendering` / `palette` match files under `references/ai-image-comparison/`, the left preview pane displays those reference PNGs for the selected candidate; when AI image generation is not selected, that left preview is hidden. The right-side option cards stay text-first and do not duplicate the gallery. The **Custom** card lists all reference-gallery `rendering` and `palette` ids from `ai-image-comparison/*/_manifest.json`, plus `custom` as a prose-only tail choice and a free-text prompt box; that prose is written to `result.json.image_strategy.custom`. If either selected dimension is `custom`, the preview intentionally falls back to prose and shows no reference image. `palette` means color behavior only — final AI image HEX values follow the confirmed `color` choice above. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan.
|
||||
|
||||
@@ -9,15 +9,17 @@ cross-platform process-liveness check and the claim/read/release lock logic so
|
||||
the two servers cannot drift apart.
|
||||
|
||||
Usage:
|
||||
from server_common import process_alive, read_lock, claim_lock, release_lock, find_free_port
|
||||
from server_common import process_alive, read_lock, lock_pid, claim_lock, release_lock, clear_lock, find_free_port
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -40,7 +42,41 @@ def find_free_port(preferred: int, host: str = '127.0.0.1', span: int = 50) -> i
|
||||
return preferred
|
||||
|
||||
|
||||
def process_alive(pid: int) -> bool:
|
||||
def popen_detached(
|
||||
args: list[str],
|
||||
*,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
**kwargs: object,
|
||||
) -> subprocess.Popen:
|
||||
"""Start a long-running child process detached from the caller.
|
||||
|
||||
Windows hosts such as terminal sandboxes may place child processes in the
|
||||
caller's Job Object. ``CREATE_BREAKAWAY_FROM_JOB`` lets the local UI server
|
||||
survive after the launcher command returns; when that flag is forbidden, the
|
||||
function falls back to the previous detached-process flags.
|
||||
"""
|
||||
if os.name != 'nt':
|
||||
return subprocess.Popen(args, start_new_session=True, **kwargs)
|
||||
|
||||
base_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
breakaway_flag = getattr(subprocess, 'CREATE_BREAKAWAY_FROM_JOB', 0x01000000)
|
||||
try:
|
||||
return subprocess.Popen(
|
||||
args,
|
||||
creationflags=base_flags | breakaway_flag,
|
||||
**kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
if logger is not None:
|
||||
logger.warning(
|
||||
'Windows process breakaway failed; falling back to detached '
|
||||
'process-group launch (%s)',
|
||||
exc,
|
||||
)
|
||||
return subprocess.Popen(args, creationflags=base_flags, **kwargs)
|
||||
|
||||
|
||||
def process_alive(pid: object) -> bool:
|
||||
"""Return True if a process with this pid is reachable.
|
||||
|
||||
On POSIX, ``os.kill(pid, 0)`` succeeds when the process exists even without
|
||||
@@ -48,7 +84,11 @@ def process_alive(pid: int) -> bool:
|
||||
alive. On Windows there is no ``os.kill(pid, 0)`` equivalent, so probe via
|
||||
``OpenProcess`` + ``WaitForSingleObject``.
|
||||
"""
|
||||
if pid <= 0:
|
||||
try:
|
||||
pid_int = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid_int <= 0:
|
||||
return False
|
||||
if os.name == 'nt':
|
||||
import ctypes
|
||||
@@ -78,7 +118,7 @@ def process_alive(pid: int) -> bool:
|
||||
handle = kernel32.OpenProcess(
|
||||
process_query_limited_information | synchronize,
|
||||
False,
|
||||
pid,
|
||||
pid_int,
|
||||
)
|
||||
if not handle:
|
||||
return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED
|
||||
@@ -93,7 +133,7 @@ def process_alive(pid: int) -> bool:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
os.kill(pid_int, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
@@ -112,6 +152,20 @@ def read_lock(lock_file: Path) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def lock_pid(lock: Optional[dict]) -> int:
|
||||
"""Return a valid pid from a lock dict, or 0 if absent/corrupt."""
|
||||
if not lock:
|
||||
return 0
|
||||
raw_pid = lock.get('pid', 0)
|
||||
if isinstance(raw_pid, bool):
|
||||
return 0
|
||||
if isinstance(raw_pid, int):
|
||||
return raw_pid if raw_pid > 0 else 0
|
||||
if isinstance(raw_pid, str) and raw_pid.strip().isdigit():
|
||||
return int(raw_pid.strip())
|
||||
return 0
|
||||
|
||||
|
||||
def claim_lock(lock_file: Path, port: int) -> Optional[dict]:
|
||||
"""Try to claim the per-project preview slot.
|
||||
|
||||
@@ -120,7 +174,7 @@ def claim_lock(lock_file: Path, port: int) -> Optional[dict]:
|
||||
A stale lock (pointing at a dead pid) is silently overwritten.
|
||||
"""
|
||||
existing = read_lock(lock_file)
|
||||
if existing and process_alive(int(existing.get('pid', 0))):
|
||||
if existing and process_alive(lock_pid(existing)):
|
||||
return existing
|
||||
lock_file.write_text(
|
||||
json.dumps({'pid': os.getpid(), 'port': port}),
|
||||
@@ -133,7 +187,15 @@ def release_lock(lock_file: Path) -> None:
|
||||
"""Best-effort cleanup: only delete the lock if it still names *us*."""
|
||||
try:
|
||||
current = read_lock(lock_file)
|
||||
if current and int(current.get('pid', 0)) == os.getpid():
|
||||
if lock_pid(current) == os.getpid():
|
||||
lock_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def clear_lock(lock_file: Path) -> None:
|
||||
"""Best-effort cleanup for a lock already proven stale by the caller."""
|
||||
try:
|
||||
lock_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
+688
-19
@@ -339,6 +339,9 @@ MIN_IMAGE_PIXELS = 100 # Minimum pixel dimension (width AND height)
|
||||
MIN_IMAGE_AREA = 30000 # Minimum pixel area (e.g. 200x150)
|
||||
MIN_IMAGE_BYTES = 2048 # Minimum image data size (2KB)
|
||||
MIN_PAGE_RATIO = 0.05 # Minimum render size relative to page (5%)
|
||||
MIN_VISIBLE_IMAGE_WIDTH = 40
|
||||
MIN_VISIBLE_IMAGE_HEIGHT = 40
|
||||
MIN_VISIBLE_IMAGE_AREA_RATIO = 0.01
|
||||
MAX_ASPECT_RATIO = 12 # Maximum aspect ratio (filters decorative bars)
|
||||
MAX_LOW_INFO_BPP = 0.08 # Bytes-per-pixel threshold for low-info images
|
||||
MAX_LOW_INFO_AREA = 500000 # Area threshold: only apply bpp filter below this
|
||||
@@ -359,6 +362,20 @@ MAX_VECTOR_BACKGROUND_AREA_RATIO = 1.9
|
||||
FIGURE_CAPTION_RE = re.compile(r'^(?:Figure|Fig\.?)\s*\d+\s*[:.||]', re.IGNORECASE)
|
||||
|
||||
|
||||
TABLE_CAPTION_RE = re.compile(
|
||||
r'^\u8868\s*\d+(?:\.\d+)*\s+(?!(?:\u7684|\u5217\u793a|\u6240\u793a|\u4e3a)).+'
|
||||
)
|
||||
TABLE_REFERENCE_PROSE_RE = re.compile(
|
||||
r'^\u8868\s*\d+(?:\.\d+)*\s*(?:\u7684|\u5217\u793a|\u6240\u793a|\u4e3a)'
|
||||
)
|
||||
SECTION_HEADING_RE = re.compile(r'^\d+(?:\.\d+){1,3}\s+\S')
|
||||
NUMBER_RE = re.compile(r'[-+]?\d+(?:\.\d+)?')
|
||||
MODEL_COLUMN_RE = re.compile(r'^[((]\d+[))]$')
|
||||
TABLE_NOTE_PREFIX = '\u6ce8'
|
||||
TABLE_CONTINUATION_Y_RATIO = 0.88
|
||||
TABLE_SCAN_BOTTOM_RATIO = 0.92
|
||||
|
||||
|
||||
def should_keep_image(
|
||||
block: dict[str, object],
|
||||
page_rect: fitz.Rect,
|
||||
@@ -375,31 +392,39 @@ def should_keep_image(
|
||||
Whether the image should be kept in the Markdown output.
|
||||
"""
|
||||
w, h = block.get("width", 0), block.get("height", 0)
|
||||
bbox = block.get("bbox", (0, 0, 0, 0))
|
||||
render_w = bbox[2] - bbox[0]
|
||||
render_h = bbox[3] - bbox[1]
|
||||
page_area = page_rect.width * page_rect.height
|
||||
render_area_ratio = (render_w * render_h) / page_area if page_area > 0 else 0
|
||||
visibly_placed = (
|
||||
render_w >= MIN_VISIBLE_IMAGE_WIDTH
|
||||
and render_h >= MIN_VISIBLE_IMAGE_HEIGHT
|
||||
and render_area_ratio >= MIN_VISIBLE_IMAGE_AREA_RATIO
|
||||
)
|
||||
|
||||
# Pixel dimension filter
|
||||
if w < MIN_IMAGE_PIXELS or h < MIN_IMAGE_PIXELS:
|
||||
if not visibly_placed and (w < MIN_IMAGE_PIXELS or h < MIN_IMAGE_PIXELS):
|
||||
return False
|
||||
|
||||
# Pixel area filter
|
||||
area = w * h
|
||||
if area < MIN_IMAGE_AREA:
|
||||
if not visibly_placed and area < MIN_IMAGE_AREA:
|
||||
return False
|
||||
|
||||
image_data = block.get("image", b"")
|
||||
if len(image_data) < MIN_IMAGE_BYTES:
|
||||
if not visibly_placed and len(image_data) < MIN_IMAGE_BYTES:
|
||||
return False
|
||||
|
||||
# Deduplicate: skip images with identical content (e.g. repeated backgrounds)
|
||||
# Deduplicate tiny repeats, but preserve visibly placed logos / charts on
|
||||
# distinct pages. Academic PDFs often reuse a small logo on title pages.
|
||||
if seen_hashes is not None:
|
||||
img_hash = hashlib.md5(image_data).hexdigest()
|
||||
if img_hash in seen_hashes:
|
||||
if img_hash in seen_hashes and not visibly_placed:
|
||||
return False
|
||||
seen_hashes.add(img_hash)
|
||||
|
||||
# Check render size relative to page
|
||||
bbox = block.get("bbox", (0, 0, 0, 0))
|
||||
render_w = bbox[2] - bbox[0]
|
||||
render_h = bbox[3] - bbox[1]
|
||||
page_w = page_rect.width
|
||||
page_h = page_rect.height
|
||||
if page_w > 0 and page_h > 0:
|
||||
@@ -415,7 +440,7 @@ def should_keep_image(
|
||||
# compression ratios (low bytes-per-pixel). Only apply to smaller images
|
||||
# to avoid filtering large photos with dark/uniform backgrounds.
|
||||
bpp = len(image_data) / area
|
||||
if bpp < MAX_LOW_INFO_BPP and area < MAX_LOW_INFO_AREA:
|
||||
if bpp < MAX_LOW_INFO_BPP and area < MAX_LOW_INFO_AREA and not visibly_placed:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -626,6 +651,642 @@ def clean_text(text: str) -> str:
|
||||
return '\n'.join(cleaned_lines)
|
||||
|
||||
|
||||
def _extract_text_lines(page: fitz.Page) -> list[tuple[fitz.Rect, str]]:
|
||||
"""Return text lines with their page rectangles in reading order."""
|
||||
lines = []
|
||||
for block in page.get_text("dict")["blocks"]:
|
||||
if block.get("type") != 0:
|
||||
continue
|
||||
for line in block["lines"]:
|
||||
text = "".join(span["text"] for span in line["spans"]).strip()
|
||||
if text:
|
||||
lines.append((fitz.Rect(line["bbox"]), text))
|
||||
return sorted(lines, key=lambda item: (item[0].y0, item[0].x0))
|
||||
|
||||
|
||||
def _is_table_caption(text: str) -> bool:
|
||||
"""Return whether a line is a real table caption, not prose citing a table."""
|
||||
return bool(TABLE_CAPTION_RE.match(text.strip()))
|
||||
|
||||
|
||||
def _caption_table_start_y(
|
||||
caption_rect: fitz.Rect,
|
||||
lines: list[tuple[fitz.Rect, str]],
|
||||
) -> float:
|
||||
"""Start below a caption and its adjacent English translation line."""
|
||||
start_y = caption_rect.y1 + 1
|
||||
for rect, text in lines:
|
||||
if rect.y0 < caption_rect.y1 - 1 or rect.y0 > caption_rect.y1 + 35:
|
||||
continue
|
||||
if text.startswith("Table"):
|
||||
start_y = max(start_y, rect.y1 + 1)
|
||||
return start_y
|
||||
|
||||
|
||||
def _looks_like_numeric_table_line(text: str) -> bool:
|
||||
"""Detect long data rows so they are not mistaken for prose boundaries."""
|
||||
numbers = NUMBER_RE.findall(text)
|
||||
if len(numbers) >= 3:
|
||||
return True
|
||||
tokens = [token for token in re.split(r'\s+', text.strip()) if token]
|
||||
return len(tokens) >= 4 and len(numbers) >= 2
|
||||
|
||||
|
||||
def _is_table_region_boundary(
|
||||
rect: fitz.Rect,
|
||||
text: str,
|
||||
page: fitz.Page,
|
||||
start_y: float,
|
||||
) -> bool:
|
||||
"""Return whether a line likely starts prose after a text-detected table."""
|
||||
text = text.strip()
|
||||
if rect.y0 < start_y + 45:
|
||||
return False
|
||||
if text.startswith(TABLE_NOTE_PREFIX):
|
||||
return True
|
||||
if SECTION_HEADING_RE.match(text):
|
||||
return True
|
||||
if TABLE_REFERENCE_PROSE_RE.match(text):
|
||||
return True
|
||||
if _looks_like_numeric_table_line(text):
|
||||
return False
|
||||
|
||||
width_ratio = rect.width / page.rect.width if page.rect.width > 0 else 0
|
||||
return len(text) >= 26 and width_ratio > 0.52 and rect.x0 < page.rect.width * 0.25
|
||||
|
||||
|
||||
def _table_region_bottom(
|
||||
page: fitz.Page,
|
||||
lines: list[tuple[fitz.Rect, str]],
|
||||
start_y: float,
|
||||
) -> float:
|
||||
"""Find a conservative bottom edge for a caption-guided text table scan."""
|
||||
for rect, text in lines:
|
||||
if rect.y0 <= start_y:
|
||||
continue
|
||||
if _is_table_region_boundary(rect, text, page, start_y):
|
||||
return max(start_y + 20, rect.y0 - 2)
|
||||
return page.rect.height * TABLE_SCAN_BOTTOM_RATIO
|
||||
|
||||
|
||||
def _normalize_table_cell(value: object) -> str:
|
||||
"""Normalize one extracted table cell for Markdown output."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = CONTROL_CHARS_RE.sub('', str(value))
|
||||
text = re.sub(r'\s*\n\s*', '<br>', text.strip())
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
return text.replace('|', r'\|')
|
||||
|
||||
|
||||
def _clean_table_rows(rows: list[list[object]]) -> list[list[str]]:
|
||||
"""Remove empty rows / columns from PyMuPDF table extraction output."""
|
||||
normalized = [[_normalize_table_cell(cell) for cell in row] for row in rows]
|
||||
normalized = [row for row in normalized if any(cell for cell in row)]
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_cols = max(len(row) for row in normalized)
|
||||
padded = [row + [""] * (max_cols - len(row)) for row in normalized]
|
||||
keep_cols = [
|
||||
idx
|
||||
for idx in range(max_cols)
|
||||
if any(row[idx] for row in padded)
|
||||
]
|
||||
if len(keep_cols) < 2:
|
||||
return []
|
||||
return _postprocess_table_rows([[row[idx] for idx in keep_cols] for row in padded])
|
||||
|
||||
|
||||
def _nonempty_cell_indexes(row: list[str]) -> list[int]:
|
||||
"""Return indexes of non-empty cells in a row."""
|
||||
return [idx for idx, cell in enumerate(row) if cell]
|
||||
|
||||
|
||||
def _merge_label_underscore_rows(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Join rows where PDF extraction split a leading underscore from a label."""
|
||||
merged = []
|
||||
for row in rows:
|
||||
nonempty = _nonempty_cell_indexes(row)
|
||||
if nonempty == [0] and row[0] == "_" and merged and merged[-1][0]:
|
||||
merged[-1][0] = f"_{merged[-1][0]}"
|
||||
continue
|
||||
merged.append(row)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_single_cell_continuations(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Merge wrapped single-cell table rows into the previous row."""
|
||||
merged: list[list[str]] = []
|
||||
for row in rows:
|
||||
nonempty = _nonempty_cell_indexes(row)
|
||||
if (
|
||||
len(nonempty) == 1
|
||||
and nonempty[0] > 0
|
||||
and merged
|
||||
and not MODEL_COLUMN_RE.match(row[nonempty[0]])
|
||||
):
|
||||
index = nonempty[0]
|
||||
separator = "<br>" if merged[-1][index] else ""
|
||||
merged[-1][index] = f"{merged[-1][index]}{separator}{row[index]}"
|
||||
continue
|
||||
merged.append(row)
|
||||
return merged
|
||||
|
||||
|
||||
def _looks_like_model_row(row: list[str]) -> bool:
|
||||
"""Return whether a row contains model-number table headings."""
|
||||
values = [cell for cell in row[1:] if cell]
|
||||
return len(values) >= 2 and all(MODEL_COLUMN_RE.match(cell) for cell in values)
|
||||
|
||||
|
||||
def _looks_like_outcome_row(row: list[str]) -> bool:
|
||||
"""Return whether a row contains regression outcome labels."""
|
||||
values = [cell for cell in row[1:] if cell]
|
||||
if len(values) < 2:
|
||||
return False
|
||||
short_values = [cell for cell in values if len(cell) <= 12 and not NUMBER_RE.search(cell)]
|
||||
return len(short_values) == len(values)
|
||||
|
||||
|
||||
def _regression_group_labels(row: list[str], data_cols: int) -> list[str]:
|
||||
"""Infer repeated group labels for common regression-table headings."""
|
||||
compact = "".join(row)
|
||||
if "总样本" in compact and "国有" in compact and "非国有" in compact and data_cols == 7:
|
||||
return ["总样本"] * 3 + ["国有企业"] * 2 + ["非国有企业"] * 2
|
||||
return [""] * data_cols
|
||||
|
||||
|
||||
def _flatten_regression_header(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Flatten multi-line regression headings into one Markdown header row."""
|
||||
if len(rows) < 3:
|
||||
return rows
|
||||
|
||||
if (
|
||||
len(rows) >= 2
|
||||
and rows[0][0]
|
||||
and not any(rows[0][1:])
|
||||
and _looks_like_model_row(rows[1])
|
||||
):
|
||||
outcome = rows[0][0]
|
||||
header = ["变量"]
|
||||
header.extend(
|
||||
f"{model} {outcome}".strip()
|
||||
for model in rows[1][1:]
|
||||
)
|
||||
return [header] + rows[2:]
|
||||
|
||||
header_offset = 0
|
||||
groups = [""] * (len(rows[0]) - 1)
|
||||
if not _looks_like_model_row(rows[0]) and _looks_like_model_row(rows[1]):
|
||||
header_offset = 1
|
||||
groups = _regression_group_labels(rows[0], len(rows[1]) - 1)
|
||||
|
||||
if not _looks_like_model_row(rows[header_offset]):
|
||||
return rows
|
||||
if len(rows) <= header_offset + 1 or not _looks_like_outcome_row(rows[header_offset + 1]):
|
||||
return rows
|
||||
|
||||
model_row = rows[header_offset]
|
||||
outcome_row = rows[header_offset + 1]
|
||||
header = ["变量"]
|
||||
for idx, model in enumerate(model_row[1:]):
|
||||
pieces = []
|
||||
if idx < len(groups) and groups[idx]:
|
||||
pieces.append(groups[idx])
|
||||
if model:
|
||||
pieces.append(model)
|
||||
if idx + 1 < len(outcome_row) and outcome_row[idx + 1]:
|
||||
pieces.append(outcome_row[idx + 1])
|
||||
header.append(" ".join(pieces).strip())
|
||||
return [header] + rows[header_offset + 2:]
|
||||
|
||||
|
||||
def _fix_paired_sample_t_table(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Collapse multi-row paired-sample T-test headings into readable columns."""
|
||||
if not rows or not any("成对差分" in cell for cell in rows[0]):
|
||||
return rows
|
||||
body = [row for row in rows if row and row[0].startswith("对")]
|
||||
if len(body) < 1:
|
||||
return rows
|
||||
header = [
|
||||
"配对",
|
||||
"变量",
|
||||
"均值",
|
||||
"标准差",
|
||||
"均值的标准误",
|
||||
"差分95%置信区间下限",
|
||||
"差分95%置信区间上限",
|
||||
"t",
|
||||
"Df",
|
||||
"Sig.(双侧)",
|
||||
]
|
||||
fixed_rows = [header]
|
||||
for row in body:
|
||||
fixed_rows.append(row[:len(header)] + [""] * max(0, len(header) - len(row)))
|
||||
return fixed_rows
|
||||
|
||||
|
||||
def _fix_variable_definition_table(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Repeat variable-category labels for common variable definition tables."""
|
||||
if not rows or rows[0] != ["变量类型", "变量名称", "符号", "变量说明"]:
|
||||
return rows
|
||||
|
||||
fixed = [rows[0]]
|
||||
for row in rows[1:]:
|
||||
if not any(row):
|
||||
continue
|
||||
name = row[1] if len(row) > 1 else ""
|
||||
symbol = row[2] if len(row) > 2 else ""
|
||||
description = row[3] if len(row) > 3 else ""
|
||||
if not name or not symbol:
|
||||
continue
|
||||
|
||||
if symbol in {"R&D", "Fixed", "Hc"}:
|
||||
category = "被解释变量"
|
||||
elif symbol == "Vat":
|
||||
category = "解释变量"
|
||||
else:
|
||||
category = "控制变量"
|
||||
fixed.append([category, name, symbol, description])
|
||||
return fixed
|
||||
|
||||
|
||||
def _fix_correlation_triangle(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Restore the missing last self-correlation column in triangular tables."""
|
||||
if len(rows) < 4 or not rows[0] or rows[0][0] != "变量":
|
||||
return rows
|
||||
body_names = [row[0] for row in rows[1:] if row and row[0]]
|
||||
header_names = rows[0][1:]
|
||||
if len(body_names) != len(header_names) + 1:
|
||||
return rows
|
||||
missing_name = body_names[-1]
|
||||
fixed = [rows[0] + [missing_name]]
|
||||
for row in rows[1:-1]:
|
||||
fixed.append(row + [""])
|
||||
fixed.append(rows[-1] + ["1"])
|
||||
return fixed
|
||||
|
||||
|
||||
def _postprocess_table_rows(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Apply Markdown-oriented cleanup to extracted table rows."""
|
||||
rows = _merge_label_underscore_rows(rows)
|
||||
rows = _merge_single_cell_continuations(rows)
|
||||
rows = _fix_variable_definition_table(rows)
|
||||
rows = _fix_paired_sample_t_table(rows)
|
||||
rows = _flatten_regression_header(rows)
|
||||
rows = _fix_correlation_triangle(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def _rows_to_markdown(rows: list[list[str]]) -> str:
|
||||
"""Convert cleaned table rows to GitHub-flavored Markdown."""
|
||||
if len(rows) < 2:
|
||||
return ""
|
||||
col_count = max(len(row) for row in rows)
|
||||
padded = [row + [""] * (col_count - len(row)) for row in rows]
|
||||
header = padded[0]
|
||||
body = padded[1:]
|
||||
lines = [
|
||||
"|" + "|".join(header) + "|",
|
||||
"|" + "|".join(["---"] * col_count) + "|",
|
||||
]
|
||||
lines.extend("|" + "|".join(row) + "|" for row in body)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _table_to_markdown(tab: object) -> str:
|
||||
"""Convert a PyMuPDF table object to cleaned Markdown."""
|
||||
try:
|
||||
rows = tab.extract() or []
|
||||
except Exception:
|
||||
return ""
|
||||
return _rows_to_markdown(_clean_table_rows(rows))
|
||||
|
||||
|
||||
def _is_valid_table_markdown(markdown: str) -> bool:
|
||||
"""Return whether generated Markdown contains a minimally useful table."""
|
||||
return markdown.count("\n") >= 2 and markdown.startswith("|")
|
||||
|
||||
|
||||
def _markdown_col_count(markdown: str) -> int:
|
||||
"""Return the column count implied by the first Markdown table row."""
|
||||
first_line = markdown.splitlines()[0] if markdown else ""
|
||||
return max(0, first_line.count("|") - 1)
|
||||
|
||||
|
||||
def _append_table_markdown_candidate(
|
||||
candidates: list[dict[str, object]],
|
||||
bbox: fitz.Rect,
|
||||
markdown: str,
|
||||
method: str,
|
||||
replace_narrow: bool = False,
|
||||
) -> None:
|
||||
"""Append or replace a table candidate after overlap deduplication."""
|
||||
if not _is_valid_table_markdown(markdown):
|
||||
return
|
||||
|
||||
for index, candidate in enumerate(candidates):
|
||||
existing = candidate["bbox"]
|
||||
if not isinstance(existing, fitz.Rect):
|
||||
continue
|
||||
overlap = (bbox & existing).get_area()
|
||||
if overlap <= 0.8 * min(bbox.get_area(), existing.get_area()):
|
||||
continue
|
||||
|
||||
existing_markdown = str(candidate.get("content", ""))
|
||||
can_replace = (
|
||||
replace_narrow
|
||||
and bbox.width > existing.width * 1.4
|
||||
and _markdown_col_count(markdown) >= _markdown_col_count(existing_markdown)
|
||||
)
|
||||
if can_replace:
|
||||
candidates[index] = {
|
||||
"bbox": bbox,
|
||||
"content": markdown,
|
||||
"method": method,
|
||||
}
|
||||
return
|
||||
|
||||
candidates.append({
|
||||
"bbox": bbox,
|
||||
"content": markdown,
|
||||
"method": method,
|
||||
})
|
||||
|
||||
|
||||
def _add_table_candidate(
|
||||
candidates: list[dict[str, object]],
|
||||
tab: object,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Append a table candidate if it has useful Markdown and is not duplicate."""
|
||||
markdown = _table_to_markdown(tab)
|
||||
if not _is_valid_table_markdown(markdown):
|
||||
return
|
||||
|
||||
bbox = fitz.Rect(tab.bbox)
|
||||
_append_table_markdown_candidate(candidates, bbox, markdown, method)
|
||||
|
||||
|
||||
def _merge_word_runs(words: list[tuple]) -> list[dict[str, object]]:
|
||||
"""Group PyMuPDF words into row-level text runs."""
|
||||
rows: list[list[tuple]] = []
|
||||
for word in sorted(words, key=lambda item: (item[1], item[0])):
|
||||
if not rows or abs(rows[-1][0][1] - word[1]) > 4:
|
||||
rows.append([word])
|
||||
else:
|
||||
rows[-1].append(word)
|
||||
|
||||
runs = []
|
||||
for row in rows:
|
||||
row_runs = []
|
||||
for word in sorted(row, key=lambda item: item[0]):
|
||||
x0, y0, x1, y1, text = word[:5]
|
||||
if row_runs and x0 - row_runs[-1]["x1"] <= 8:
|
||||
row_runs[-1]["x1"] = x1
|
||||
row_runs[-1]["y0"] = min(row_runs[-1]["y0"], y0)
|
||||
row_runs[-1]["y1"] = max(row_runs[-1]["y1"], y1)
|
||||
row_runs[-1]["text"] = f"{row_runs[-1]['text']} {text}"
|
||||
else:
|
||||
row_runs.append({
|
||||
"x0": x0,
|
||||
"y0": y0,
|
||||
"x1": x1,
|
||||
"y1": y1,
|
||||
"text": text,
|
||||
})
|
||||
runs.extend(row_runs)
|
||||
return runs
|
||||
|
||||
|
||||
def _cluster_word_columns(runs: list[dict[str, object]]) -> list[float]:
|
||||
"""Infer stable table columns from word-run centers."""
|
||||
centers = sorted((float(run["x0"]) + float(run["x1"])) / 2 for run in runs)
|
||||
columns: list[float] = []
|
||||
for center in centers:
|
||||
if not columns or abs(center - columns[-1]) > 14:
|
||||
columns.append(center)
|
||||
else:
|
||||
columns[-1] = (columns[-1] + center) / 2
|
||||
return columns
|
||||
|
||||
|
||||
def _word_runs_to_rows(
|
||||
runs: list[dict[str, object]],
|
||||
columns: list[float],
|
||||
) -> list[list[str]]:
|
||||
"""Place word runs into inferred columns and return table rows."""
|
||||
row_groups: list[list[dict[str, object]]] = []
|
||||
for run in sorted(runs, key=lambda item: (float(item["y0"]), float(item["x0"]))):
|
||||
if not row_groups or abs(float(row_groups[-1][0]["y0"]) - float(run["y0"])) > 4:
|
||||
row_groups.append([run])
|
||||
else:
|
||||
row_groups[-1].append(run)
|
||||
|
||||
rows = []
|
||||
for group in row_groups:
|
||||
row = [""] * len(columns)
|
||||
for run in group:
|
||||
center = (float(run["x0"]) + float(run["x1"])) / 2
|
||||
col_index = min(range(len(columns)), key=lambda idx: abs(columns[idx] - center))
|
||||
text = _normalize_table_cell(run["text"])
|
||||
row[col_index] = f"{row[col_index]} {text}".strip() if row[col_index] else text
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _words_to_markdown_table(
|
||||
page: fitz.Page,
|
||||
clip: fitz.Rect,
|
||||
) -> tuple[fitz.Rect, str] | None:
|
||||
"""Build a simple table from word coordinates inside a clipped region."""
|
||||
words = page.get_text("words", clip=clip)
|
||||
if len(words) < 6:
|
||||
return None
|
||||
|
||||
runs = _merge_word_runs(words)
|
||||
if len(runs) < 6:
|
||||
return None
|
||||
|
||||
columns = _cluster_word_columns(runs)
|
||||
if len(columns) < 3:
|
||||
return None
|
||||
|
||||
rows = _word_runs_to_rows(runs, columns)
|
||||
cleaned_rows = _clean_table_rows(rows)
|
||||
markdown = _rows_to_markdown(cleaned_rows)
|
||||
if not _is_valid_table_markdown(markdown):
|
||||
return None
|
||||
|
||||
x0 = min(float(run["x0"]) for run in runs)
|
||||
y0 = min(float(run["y0"]) for run in runs)
|
||||
x1 = max(float(run["x1"]) for run in runs)
|
||||
y1 = max(float(run["y1"]) for run in runs)
|
||||
return fitz.Rect(x0, y0, x1, y1), markdown
|
||||
|
||||
|
||||
def _find_tables_in_clip(
|
||||
page: fitz.Page,
|
||||
clip: fitz.Rect,
|
||||
) -> list[object]:
|
||||
"""Find text-strategy tables inside a clipped page region."""
|
||||
if clip.height < 20 or clip.width < 80:
|
||||
return []
|
||||
try:
|
||||
return list(page.find_tables(strategy="text", clip=clip))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def find_page_tables(
|
||||
page: fitz.Page,
|
||||
include_top_continuation: bool = False,
|
||||
) -> tuple[list[dict[str, object]], bool]:
|
||||
"""Find line-detected tables plus caption-guided text tables on one page."""
|
||||
candidates: list[dict[str, object]] = []
|
||||
try:
|
||||
for tab in page.find_tables():
|
||||
_add_table_candidate(candidates, tab, "lines")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines = _extract_text_lines(page)
|
||||
for rect, text in lines:
|
||||
if not _is_table_caption(text):
|
||||
continue
|
||||
start_y = _caption_table_start_y(rect, lines)
|
||||
bottom_y = _table_region_bottom(page, lines, start_y)
|
||||
clip = fitz.Rect(0, start_y, page.rect.width, bottom_y)
|
||||
for tab in _find_tables_in_clip(page, clip):
|
||||
_add_table_candidate(candidates, tab, "caption-text")
|
||||
word_table = _words_to_markdown_table(page, clip)
|
||||
if word_table:
|
||||
bbox, markdown = word_table
|
||||
_append_table_markdown_candidate(
|
||||
candidates,
|
||||
bbox,
|
||||
markdown,
|
||||
"caption-words",
|
||||
replace_narrow=True,
|
||||
)
|
||||
|
||||
if include_top_continuation:
|
||||
start_y = page.rect.height * 0.08
|
||||
bottom_y = _table_region_bottom(page, lines, start_y)
|
||||
clip = fitz.Rect(0, start_y, page.rect.width, bottom_y)
|
||||
for tab in _find_tables_in_clip(page, clip):
|
||||
_add_table_candidate(candidates, tab, "continuation-text")
|
||||
word_table = _words_to_markdown_table(page, clip)
|
||||
if word_table:
|
||||
bbox, markdown = word_table
|
||||
_append_table_markdown_candidate(
|
||||
candidates,
|
||||
bbox,
|
||||
markdown,
|
||||
"continuation-words",
|
||||
replace_narrow=True,
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda candidate: candidate["bbox"].y0)
|
||||
table_continues = any(
|
||||
isinstance(candidate["bbox"], fitz.Rect)
|
||||
and candidate["bbox"].y1 >= page.rect.height * TABLE_CONTINUATION_Y_RATIO
|
||||
for candidate in candidates
|
||||
)
|
||||
return candidates, table_continues
|
||||
|
||||
|
||||
def _is_markdown_table_line(line: str) -> bool:
|
||||
"""Return whether a Markdown line belongs to a pipe table."""
|
||||
return line.startswith("|")
|
||||
|
||||
|
||||
def _is_markdown_separator_line(line: str) -> bool:
|
||||
"""Return whether a Markdown table line is the separator row."""
|
||||
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
||||
return bool(cells) and all(cell and set(cell) <= {"-", ":"} for cell in cells)
|
||||
|
||||
|
||||
def _compatible_table_headers(first: list[str], second: list[str]) -> bool:
|
||||
"""Return whether two Markdown table blocks have the same flattened header."""
|
||||
if len(first) < 2 or len(second) < 2:
|
||||
return False
|
||||
if not _is_markdown_separator_line(first[1]) or not _is_markdown_separator_line(second[1]):
|
||||
return False
|
||||
return first[0] == second[0] and _markdown_col_count(first[0]) > 2
|
||||
|
||||
|
||||
def _is_table_continuation_noise(line: str) -> bool:
|
||||
"""Allow only page/header noise between split table parts."""
|
||||
text = line.strip()
|
||||
if not text:
|
||||
return True
|
||||
if text.startswith("<!-- Page ") and text.endswith("-->"):
|
||||
return True
|
||||
if re.fullmatch(r'\d+', text):
|
||||
return True
|
||||
if "重庆大学硕士学位论文" in text:
|
||||
return True
|
||||
continuation_labels = [
|
||||
"营改增",
|
||||
"深化增值税改革",
|
||||
"国有企业",
|
||||
"非国有企业",
|
||||
]
|
||||
return any(label in text for label in continuation_labels)
|
||||
|
||||
|
||||
def _read_markdown_table_block(lines: list[str], start: int) -> tuple[list[str], int]:
|
||||
"""Read a contiguous Markdown table block from ``start``."""
|
||||
end = start
|
||||
while end < len(lines) and _is_markdown_table_line(lines[end]):
|
||||
end += 1
|
||||
return lines[start:end], end
|
||||
|
||||
|
||||
def merge_markdown_continuation_tables(markdown: str) -> str:
|
||||
"""Merge split cross-page Markdown tables with repeated headers."""
|
||||
lines = markdown.splitlines()
|
||||
result = []
|
||||
index = 0
|
||||
|
||||
while index < len(lines):
|
||||
if not _is_markdown_table_line(lines[index]):
|
||||
result.append(lines[index])
|
||||
index += 1
|
||||
continue
|
||||
|
||||
table, table_end = _read_markdown_table_block(lines, index)
|
||||
search = table_end
|
||||
while True:
|
||||
between_start = search
|
||||
while search < len(lines) and not _is_markdown_table_line(lines[search]):
|
||||
if not _is_table_continuation_noise(lines[search]):
|
||||
break
|
||||
search += 1
|
||||
|
||||
if search >= len(lines) or not _is_markdown_table_line(lines[search]):
|
||||
break
|
||||
if any(
|
||||
not _is_table_continuation_noise(line)
|
||||
for line in lines[between_start:search]
|
||||
):
|
||||
break
|
||||
|
||||
next_table, next_end = _read_markdown_table_block(lines, search)
|
||||
if not _compatible_table_headers(table, next_table):
|
||||
break
|
||||
|
||||
table.extend(next_table[2:])
|
||||
search = next_end
|
||||
|
||||
result.extend(table)
|
||||
index = search if search != table_end else table_end
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def merge_adjacent_formatting(text: str) -> str:
|
||||
"""Merge adjacent same-style formatted spans split across PDF tokens.
|
||||
|
||||
@@ -724,28 +1385,35 @@ def extract_pdf_to_markdown(
|
||||
|
||||
img_count = 0
|
||||
image_manifest: list[dict[str, object]] = []
|
||||
previous_table_continues = False
|
||||
|
||||
for page_num, page in enumerate(doc, 1):
|
||||
if page_num > 1:
|
||||
# Add page break marker to help LLM understand context segmentation
|
||||
markdown_content += f"\n\n<!-- Page {page_num} -->\n\n"
|
||||
|
||||
try:
|
||||
tabs = page.find_tables()
|
||||
except Exception:
|
||||
tabs = []
|
||||
|
||||
tab_rects = [fitz.Rect(t.bbox) for t in tabs]
|
||||
table_candidates, previous_table_continues = find_page_tables(
|
||||
page,
|
||||
include_top_continuation=previous_table_continues,
|
||||
)
|
||||
tab_rects = [
|
||||
candidate["bbox"]
|
||||
for candidate in table_candidates
|
||||
if isinstance(candidate["bbox"], fitz.Rect)
|
||||
]
|
||||
|
||||
page_elements = []
|
||||
|
||||
for tab in tabs:
|
||||
for table in table_candidates:
|
||||
bbox = table["bbox"]
|
||||
if not isinstance(bbox, fitz.Rect):
|
||||
continue
|
||||
page_elements.append({
|
||||
"y0": tab.bbox[1],
|
||||
"y0": bbox.y0,
|
||||
"type": 2,
|
||||
"content": tab.to_markdown()
|
||||
"content": table["content"]
|
||||
})
|
||||
print(f" [OK] Found table: P{page_num}")
|
||||
print(f" [OK] Found table: P{page_num} ({table['method']})")
|
||||
|
||||
if render_vector_figures:
|
||||
for figure_rect in detect_vector_figure_rects(page, tab_rects):
|
||||
@@ -1048,6 +1716,7 @@ def extract_pdf_to_markdown(
|
||||
|
||||
doc.close()
|
||||
|
||||
markdown_content = merge_markdown_continuation_tables(markdown_content)
|
||||
markdown_content = CONTROL_CHARS_RE.sub('', markdown_content)
|
||||
markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
|
||||
markdown_content = markdown_content.strip() + "\n"
|
||||
|
||||
@@ -62,7 +62,10 @@ if str(_ROOT_SCRIPTS_DIR) not in sys.path:
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from server_common import ( # noqa: E402
|
||||
claim_lock as _claim_lock,
|
||||
clear_lock as _clear_lock,
|
||||
find_free_port as _find_free_port,
|
||||
lock_pid as _lock_pid,
|
||||
popen_detached as _popen_detached,
|
||||
process_alive as _process_alive,
|
||||
read_lock as _read_lock,
|
||||
release_lock as _release_lock,
|
||||
@@ -99,6 +102,16 @@ _SLIDE_CACHE: dict = {} # path -> (mtime, (content, warnings))
|
||||
_LIST_CACHE_LOCK = threading.Lock()
|
||||
_LIST_CACHE: dict = {} # path -> (mtime, annotation_count_on_disk)
|
||||
|
||||
DEFAULT_PORT = 5050
|
||||
PUBLIC_HOST = '127.0.0.1'
|
||||
STARTUP_TIMEOUT = 15
|
||||
|
||||
|
||||
def _server_url(port: int, path: str = '') -> str:
|
||||
"""Return the loopback URL shown to users and used by readiness probes."""
|
||||
suffix = path if path.startswith('/') or not path else f'/{path}'
|
||||
return f'http://{PUBLIC_HOST}:{port}{suffix}'
|
||||
|
||||
|
||||
def _xml_attr(value: object) -> str:
|
||||
"""Escape a value for safe insertion into generated preview SVG markup."""
|
||||
@@ -118,8 +131,7 @@ def _cache_put(cache: dict, lock: threading.Lock, path: str, mtime: float, value
|
||||
cache[path] = (mtime, value)
|
||||
|
||||
|
||||
# Lock / liveness helpers are shared with confirm_ui via server_common
|
||||
# (imported above as _process_alive / _read_lock / _claim_lock / _release_lock).
|
||||
# Lock / liveness helpers are shared with confirm_ui via server_common.
|
||||
|
||||
|
||||
def _inline_icons(content: str) -> tuple[str, list[dict]]:
|
||||
@@ -409,6 +421,23 @@ def create_app(
|
||||
'live': app.config['LIVE_MODE'],
|
||||
})
|
||||
|
||||
@app.route('/api/health')
|
||||
def health():
|
||||
"""Expose a cheap readiness probe for the daemon launcher."""
|
||||
try:
|
||||
slide_count = len(list(svg_dir.glob('*.svg'))) if svg_dir.exists() else 0
|
||||
except OSError:
|
||||
slide_count = 0
|
||||
resp = jsonify({
|
||||
'status': 'ok',
|
||||
'project': str(project_path),
|
||||
'live': app.config['LIVE_MODE'],
|
||||
'svg_output': str(svg_dir),
|
||||
'slides': slide_count,
|
||||
})
|
||||
resp.headers['Cache-Control'] = 'no-store'
|
||||
return resp
|
||||
|
||||
@app.route('/images/<path:filename>')
|
||||
def serve_image(filename: str):
|
||||
"""Serve images referenced by SVGs as `../images/*.png`.
|
||||
@@ -861,7 +890,7 @@ def _legacy_live_lock(project_path: Path) -> Optional[dict]:
|
||||
"""Return a live legacy root lock, if one exists."""
|
||||
legacy_lock = project_path / LEGACY_LOCK_FILE_NAME
|
||||
existing = _read_lock(legacy_lock)
|
||||
if existing and _process_alive(int(existing.get('pid', 0))):
|
||||
if existing and _process_alive(_lock_pid(existing)):
|
||||
return existing
|
||||
return None
|
||||
|
||||
@@ -878,17 +907,20 @@ def _shutdown_existing(project_path: Path) -> int:
|
||||
logger.info('no live preview server running — nothing to stop')
|
||||
return 0
|
||||
|
||||
pid = int(existing.get('pid', 0) or 0)
|
||||
port = existing.get('port')
|
||||
pid = _lock_pid(existing)
|
||||
try:
|
||||
port = int(existing.get('port', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
if not _process_alive(pid):
|
||||
_release_lock(lock_file)
|
||||
_clear_lock(lock_file)
|
||||
logger.info('live preview already stopped; cleared stale lock')
|
||||
return 0
|
||||
|
||||
if port:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f'http://127.0.0.1:{port}/api/shutdown',
|
||||
_server_url(port, '/api/shutdown'),
|
||||
data=b'{"reason": "cli-shutdown"}',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
@@ -906,24 +938,37 @@ def _shutdown_existing(project_path: Path) -> int:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
_release_lock(lock_file)
|
||||
_clear_lock(lock_file)
|
||||
logger.info('live preview server stopped (pid=%s)', pid)
|
||||
return 0
|
||||
|
||||
|
||||
def _wait_for_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
|
||||
def _wait_for_ready(
|
||||
port: int,
|
||||
proc: subprocess.Popen,
|
||||
timeout: int = STARTUP_TIMEOUT,
|
||||
) -> bool:
|
||||
"""Wait until the server responds or the child exits."""
|
||||
deadline = time.time() + timeout
|
||||
health_url = f'{url}/api/config'
|
||||
health_url = _server_url(port, '/api/health')
|
||||
last_error = ''
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
logger.error('live preview exited during startup (code=%s)', proc.returncode)
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=1) as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
time.sleep(0.25)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(0.25)
|
||||
logger.error(
|
||||
'live preview did not become ready at %s within %ss%s',
|
||||
health_url,
|
||||
timeout,
|
||||
f' (last error: {last_error})' if last_error else '',
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -941,13 +986,27 @@ def _open_browser(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _open_browser_async(url: str, delay: float = 0.4) -> None:
|
||||
"""Open the browser shortly after Flask starts binding its socket."""
|
||||
def _open() -> None:
|
||||
time.sleep(delay)
|
||||
_open_browser(url)
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master SVG Editor',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument('project_dir', help='Path to project directory (contains svg_output/)')
|
||||
parser.add_argument('--port', type=int, default=5050, help='Port to listen on (default: 5050)')
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
type=int,
|
||||
default=DEFAULT_PORT,
|
||||
help=f'Port to listen on (default: {DEFAULT_PORT})',
|
||||
)
|
||||
parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
|
||||
parser.add_argument(
|
||||
'--daemon',
|
||||
@@ -1004,9 +1063,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
existing_port = legacy_existing.get('port', '?')
|
||||
logger.error(
|
||||
'live preview is already running for this project via legacy lock '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s, click '
|
||||
'(pid=%s, port=%s). Open http://%s:%s, click '
|
||||
'Exit preview in the browser, or stop pid %s',
|
||||
existing_pid, existing_port, existing_port, existing_pid,
|
||||
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -1015,13 +1074,13 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
|
||||
if args.daemon:
|
||||
existing = _read_lock(lock_file)
|
||||
if existing and _process_alive(int(existing.get('pid', 0))):
|
||||
if existing and _process_alive(_lock_pid(existing)):
|
||||
existing_pid = existing.get('pid', '?')
|
||||
existing_port = existing.get('port', '?')
|
||||
logger.error(
|
||||
'live preview is already running for this project '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s',
|
||||
existing_pid, existing_port, existing_port,
|
||||
'(pid=%s, port=%s). Open http://%s:%s',
|
||||
existing_pid, existing_port, PUBLIC_HOST, existing_port,
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -1047,27 +1106,20 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
]
|
||||
if args.live:
|
||||
cmd.append('--live')
|
||||
creationflags = 0
|
||||
popen_kwargs = {}
|
||||
if os.name == 'nt':
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
else:
|
||||
popen_kwargs['start_new_session'] = True
|
||||
try:
|
||||
with log_path.open('a', encoding='utf-8') as log:
|
||||
proc = subprocess.Popen(
|
||||
proc = _popen_detached(
|
||||
cmd,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
**popen_kwargs,
|
||||
logger=logger,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error('cannot write live preview log: %s (%s)', log_path, exc)
|
||||
return 1
|
||||
url = f'http://localhost:{port}'
|
||||
if not _wait_for_ready(url, proc):
|
||||
url = _server_url(port)
|
||||
if not _wait_for_ready(port, proc):
|
||||
logger.error('live preview failed to become reachable: %s (log: %s)', url, log_path)
|
||||
return 1
|
||||
logger.info('started live preview in background: %s (pid=%s)', url, proc.pid)
|
||||
@@ -1096,9 +1148,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
existing_port = existing.get('port', '?')
|
||||
logger.error(
|
||||
'live preview is already running for this project '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s, click '
|
||||
'(pid=%s, port=%s). Open http://%s:%s, click '
|
||||
'Exit preview in the browser, or run: kill %s',
|
||||
existing_pid, existing_port, existing_port, existing_pid,
|
||||
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
|
||||
)
|
||||
return 1
|
||||
# atexit covers normal interpreter shutdown (Ctrl+C / SystemExit);
|
||||
@@ -1132,9 +1184,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
lock_file=lock_file,
|
||||
)
|
||||
|
||||
url = f'http://localhost:{port}'
|
||||
url = _server_url(port)
|
||||
if not args.no_browser:
|
||||
_open_browser(url)
|
||||
_open_browser_async(url)
|
||||
|
||||
mode = "live preview (auto-startup)" if args.live else "live preview"
|
||||
svg_count = len(list(svg_output.glob('*.svg')))
|
||||
@@ -1142,7 +1194,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
logger.info('project: %s', project_path)
|
||||
logger.info('svg_output: %s (%d slides)', svg_output, svg_count)
|
||||
logger.info('idle timeout: %ds (0 = disabled)', idle_timeout)
|
||||
app.run(host='127.0.0.1', port=port, debug=False)
|
||||
app.run(host=PUBLIC_HOST, port=port, debug=False)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+166
-7
@@ -978,6 +978,20 @@ _TEXT_WIDTH_HEADROOM_BASE = 1.06
|
||||
_TEXT_WIDTH_HEADROOM_CAPS = 1.12
|
||||
_SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12
|
||||
_SERIF_TEXT_WIDTH_HEADROOM_CAPS = 1.36
|
||||
_TEXT_BULLET_MARKERS = {
|
||||
'·': '•',
|
||||
'•': '•',
|
||||
'●': '●',
|
||||
'▪': '▪',
|
||||
'■': '■',
|
||||
'◆': '◆',
|
||||
'◇': '◇',
|
||||
'◦': '◦',
|
||||
'‣': '‣',
|
||||
}
|
||||
_TEXT_BULLET_RE = re.compile(
|
||||
r'^(?P<prefix>\s*)(?P<marker>[·•●▪■◆◇◦‣])(?P<space>\s*)'
|
||||
)
|
||||
|
||||
|
||||
def _normalize_text(text: str, *, preserve_space: bool = False) -> str:
|
||||
@@ -1108,6 +1122,131 @@ def _estimate_text_runs_width(
|
||||
return width * (base + (ceiling - base) * caps)
|
||||
|
||||
|
||||
def _first_nonspace_run(runs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
for run in runs:
|
||||
if str(run.get('text', '')).strip():
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _strip_leading_chars_from_runs(
|
||||
runs: list[dict[str, Any]],
|
||||
char_count: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
stripped: list[dict[str, Any]] = []
|
||||
remaining = char_count
|
||||
for run in runs:
|
||||
text = str(run.get('text', ''))
|
||||
if remaining >= len(text):
|
||||
remaining -= len(text)
|
||||
continue
|
||||
if remaining > 0:
|
||||
text = text[remaining:]
|
||||
remaining = 0
|
||||
if text:
|
||||
stripped.append({**run, 'text': text})
|
||||
return stripped
|
||||
|
||||
|
||||
def _take_leading_chars_from_runs(
|
||||
runs: list[dict[str, Any]],
|
||||
char_count: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
taken: list[dict[str, Any]] = []
|
||||
remaining = char_count
|
||||
for run in runs:
|
||||
if remaining <= 0:
|
||||
break
|
||||
text = str(run.get('text', ''))
|
||||
if remaining >= len(text):
|
||||
prefix = text
|
||||
remaining -= len(text)
|
||||
else:
|
||||
prefix = text[:remaining]
|
||||
remaining = 0
|
||||
if prefix:
|
||||
taken.append({**run, 'text': prefix})
|
||||
return taken
|
||||
|
||||
|
||||
def _extract_text_bullet(
|
||||
runs: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
||||
"""Convert a leading text bullet marker into paragraph metadata."""
|
||||
full_text = ''.join(str(run.get('text', '')) for run in runs)
|
||||
match = _TEXT_BULLET_RE.match(full_text)
|
||||
if not match:
|
||||
return runs, None
|
||||
if not full_text[match.end():].strip():
|
||||
return runs, None
|
||||
|
||||
marker = match.group('marker')
|
||||
marker_run = _first_nonspace_run(runs) or {}
|
||||
prefix_runs = _take_leading_chars_from_runs(runs, match.end())
|
||||
replacement_prefix = _TEXT_BULLET_MARKERS.get(marker, marker) + (match.group('space') or ' ')
|
||||
replacement_runs = [{**marker_run, 'text': replacement_prefix}] if marker_run else []
|
||||
bullet = {
|
||||
'char': _TEXT_BULLET_MARKERS.get(marker, marker),
|
||||
'fill': marker_run.get('fill'),
|
||||
'source_prefix_width_px': _estimate_text_runs_width(prefix_runs, include_headroom=False),
|
||||
'margin_px': max(
|
||||
_estimate_text_runs_width(replacement_runs, include_headroom=False),
|
||||
8.0,
|
||||
),
|
||||
}
|
||||
stripped = _strip_leading_chars_from_runs(runs, match.end())
|
||||
return (stripped or runs), bullet
|
||||
|
||||
|
||||
def _bullet_margin_px(bullet: dict[str, Any], font_size: float) -> float:
|
||||
try:
|
||||
return float(bullet.get('margin_px', 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return max(font_size * 0.95, 12.0)
|
||||
|
||||
|
||||
def _bullet_indent_px(bullet: dict[str, Any], font_size: float) -> float:
|
||||
return -_bullet_margin_px(bullet, font_size)
|
||||
|
||||
|
||||
def _build_bullet_xml(bullet: dict[str, Any] | None) -> str:
|
||||
if not bullet:
|
||||
return ''
|
||||
fill = bullet.get('fill')
|
||||
if isinstance(fill, str) and re.fullmatch(r'[0-9A-Fa-f]{6}', fill):
|
||||
color_xml = f'<a:buClr><a:srgbClr val="{fill.upper()}"/></a:buClr>'
|
||||
else:
|
||||
color_xml = '<a:buClrTx/>'
|
||||
return (
|
||||
f'{color_xml}<a:buSzTx/><a:buFontTx/>'
|
||||
f'<a:buChar char="{_xml_escape(str(bullet.get("char", "•")))}"/>'
|
||||
)
|
||||
|
||||
|
||||
def _paragraph_pr_xml(
|
||||
*,
|
||||
algn: str,
|
||||
font_size: float,
|
||||
body_xml: str = '',
|
||||
bullet: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
attrs = f'algn="{algn}"'
|
||||
if bullet:
|
||||
margin = px_to_emu(_bullet_margin_px(bullet, font_size))
|
||||
indent = px_to_emu(_bullet_indent_px(bullet, font_size))
|
||||
attrs += f' marL="{margin}" indent="{indent}"'
|
||||
return f'<a:pPr {attrs}>{body_xml}{_build_bullet_xml(bullet)}</a:pPr>'
|
||||
|
||||
|
||||
def _estimate_bullet_line_width(runs: list[dict[str, Any]]) -> float:
|
||||
line_runs, bullet = _extract_text_bullet(runs)
|
||||
width = _estimate_text_runs_width(line_runs, include_headroom=False)
|
||||
if bullet:
|
||||
fs_px = float(line_runs[0].get('font_size', 16)) if line_runs else 16.0
|
||||
width += _bullet_margin_px(bullet, fs_px)
|
||||
return width
|
||||
|
||||
|
||||
def _textbox_padding(font_size: float) -> float:
|
||||
"""Return small text-frame slack without visibly lengthening the box."""
|
||||
return max(
|
||||
@@ -1387,6 +1526,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
line_height_px = _f(line_height_attr) if line_height_attr is not None else None
|
||||
paragraph_runs: list[list[dict[str, Any]]] | None = None
|
||||
paragraph_space_before: list[float] = []
|
||||
paragraph_bullets: list[dict[str, Any] | None] = []
|
||||
# Per-tspan widths (visual lines as the deck author drew them) regardless
|
||||
# of how many merge into one <a:p>; used to size the textbox so PowerPoint
|
||||
# has room to wrap text to the SVG's original line widths.
|
||||
@@ -1404,9 +1544,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
line_runs = [r for r in line_runs if r['text']]
|
||||
if not line_runs:
|
||||
continue
|
||||
visual_line_widths.append(
|
||||
_estimate_text_runs_width(line_runs, include_headroom=False)
|
||||
)
|
||||
visual_line_widths.append(_estimate_bullet_line_width(line_runs))
|
||||
soft_break = child.get('data-paragraph-soft-break') == '1'
|
||||
if soft_break and paragraph_runs:
|
||||
# Append to the previous paragraph. A Latin line-wrap needs a
|
||||
@@ -1433,11 +1571,19 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
paragraph_runs = None
|
||||
paragraph_space_before = []
|
||||
visual_line_widths = []
|
||||
else:
|
||||
stripped_paragraphs: list[list[dict[str, Any]]] = []
|
||||
for line_runs in paragraph_runs:
|
||||
stripped_runs, bullet = _extract_text_bullet(line_runs)
|
||||
stripped_paragraphs.append(stripped_runs)
|
||||
paragraph_bullets.append(bullet)
|
||||
paragraph_runs = stripped_paragraphs
|
||||
|
||||
if paragraph_runs is not None:
|
||||
runs = [r for line in paragraph_runs for r in line]
|
||||
else:
|
||||
runs = _build_text_runs(elem, parent_attrs)
|
||||
runs, single_bullet = _extract_text_bullet(runs)
|
||||
|
||||
if not runs:
|
||||
return None
|
||||
@@ -1463,6 +1609,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
)
|
||||
else:
|
||||
text_width = _estimate_text_runs_width(runs)
|
||||
if single_bullet:
|
||||
fs_px = float(runs[0].get('font_size', font_size)) if runs else font_size
|
||||
text_width += _bullet_margin_px(single_bullet, fs_px)
|
||||
text_height = font_size * 1.5
|
||||
padding = _textbox_padding(font_size)
|
||||
|
||||
@@ -1551,20 +1700,30 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
||||
paragraph_xml_chunks = []
|
||||
for line, extra_px in zip(paragraph_runs, paragraph_space_before):
|
||||
for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
|
||||
spc_bef_xml = ''
|
||||
if extra_px > 0:
|
||||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||||
runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
|
||||
p_pr_xml = _paragraph_pr_xml(
|
||||
algn=algn,
|
||||
font_size=float(line[0].get('font_size', font_size)) if line else font_size,
|
||||
body_xml=f'{ln_spc_xml}{spc_bef_xml}',
|
||||
bullet=bullet,
|
||||
)
|
||||
paragraph_xml_chunks.append(
|
||||
f'<a:p>\n<a:pPr algn="{algn}">{ln_spc_xml}{spc_bef_xml}</a:pPr>\n'
|
||||
f'{runs_inner}\n</a:p>'
|
||||
f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
|
||||
)
|
||||
paragraphs_xml = '\n'.join(paragraph_xml_chunks)
|
||||
else:
|
||||
runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs)
|
||||
paragraphs_xml = f'<a:p>\n<a:pPr algn="{algn}"/>\n{runs_xml}\n</a:p>'
|
||||
p_pr_xml = _paragraph_pr_xml(
|
||||
algn=algn,
|
||||
font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
|
||||
bullet=single_bullet,
|
||||
)
|
||||
paragraphs_xml = f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
|
||||
|
||||
off_x = px_to_emu(box_x)
|
||||
off_y = px_to_emu(box_y)
|
||||
|
||||
+9
-6
@@ -110,17 +110,20 @@ Transition effects (-t/--transition):
|
||||
Per-element entrance animation (-a/--animation, native shapes mode):
|
||||
{', '.join(animation_choices)}
|
||||
Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
|
||||
"auto" (map effect from group id: chart→wipe, card-/step-/pillar-→fly,
|
||||
title/takeaway→fade; image-like ids hero/figure-/image/img-/kpi cycle
|
||||
zoom/dissolve/circle/box/diamond/wheel so multiple images vary across
|
||||
the deck; unmatched ids cycle fade/wipe/fly/zoom). Start mode set by
|
||||
--animation-trigger, matching PowerPoint's Start dropdown:
|
||||
"none" (no auto element builds; page transitions still apply). Use
|
||||
"-a auto" to map effects from group id: chart→wipe,
|
||||
card-/step-/pillar-→fly, title/takeaway→fade; image-like ids
|
||||
hero/figure-/image/img-/kpi cycle zoom/dissolve/circle/box/diamond/
|
||||
wheel so multiple images vary across the deck; unmatched ids cycle
|
||||
fade/wipe/fly/zoom. Start mode set by --animation-trigger, matching
|
||||
PowerPoint's Start dropdown:
|
||||
on-click one presenter click per group
|
||||
with-previous all groups start together on slide entry
|
||||
after-previous (default) cascade on slide entry;
|
||||
gap = --animation-stagger seconds
|
||||
mixed (legacy) cycles a larger 16-effect pool by group order;
|
||||
random samples from the same legacy pool. Use "-a none" to disable.
|
||||
random samples from the same legacy pool. Use "-a none" to disable
|
||||
element builds explicitly.
|
||||
|
||||
Compatibility mode (enabled by default):
|
||||
- Automatically generates PNG fallback images, SVG embedded as extension
|
||||
|
||||
@@ -34,9 +34,9 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --daemon
|
||||
|
||||
(Plain mode — no `--live`. The `--live` flag is reserved for Step 6's auto-startup.)
|
||||
|
||||
The launcher binds `127.0.0.1:5050` (or the next free port), starts the server in the background, writes runtime files under `<project_path>/live_preview/`, opens the browser on a local desktop when possible, and edits `<project_path>/svg_output/` in place. After it prints the running URL, tell the user in their language, in one short message:
|
||||
The launcher binds `127.0.0.1:5050` (or the next free port), starts the server in the background, waits for `GET /api/health` to prove the server is accepting requests, writes runtime files under `<project_path>/live_preview/`, opens the browser on a local desktop when possible, and edits `<project_path>/svg_output/` in place. After it prints the running URL, tell the user in their language, in one short message:
|
||||
|
||||
- editor is at the URL reported by the launcher, e.g. `http://localhost:5050`
|
||||
- editor is at the URL reported by the launcher, e.g. `http://127.0.0.1:5050`
|
||||
- **Direct edit** (deterministic tweaks — wording, color, coordinates, SVG attributes): select an element → change the controls in the right panel → preview updates immediately, but nothing is written to `svg_output/` until **Apply changes**. `Ctrl+Z` or the **Undo** button drops staged edits step by step; applied changes are logged to `<project>/live_preview/edits.jsonl`. Re-export stays chat-driven and separate: say "re-export" / "重新导出" to refresh the PPTX.
|
||||
- **Annotate** (changes that need AI judgement / re-layout): select an element → write the instruction, optionally starting from a quick type such as move / resize / replace image / copy / relayout → click **Add annotation** to stage it → click **Apply changes** to write annotation markers → return to the chat and say `apply my annotations` (or quote the browser prompt)
|
||||
- to skip the editor, just describe the change in chat
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shadcn",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "093a538453d3455159fb12a9cc897e39826f9522",
|
||||
"commit": "21e4ceb94418096e21a7f1990027741a8f9b085d",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/shadcn",
|
||||
"syncedAt": "2026-07-08T16:00:01Z"
|
||||
"syncedAt": "2026-07-09T15:59:59Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user