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:
KeyInfo Bot
2026-07-10 00:01:36 +08:00
parent aaed3162df
commit 53d1b02685
17 changed files with 1532 additions and 186 deletions
+6 -6
View File
@@ -51,8 +51,8 @@
"repo": "https://github.com/shadcn-ui/ui.git", "repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "093a538453d3455159fb12a9cc897e39826f9522", "commit": "21e4ceb94418096e21a7f1990027741a8f9b085d",
"syncedAt": "2026-07-08T16:00:01Z" "syncedAt": "2026-07-09T15:59:59Z"
}, },
{ {
"id": "frontend-slides", "id": "frontend-slides",
@@ -87,8 +87,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "37427f940014c7d1aa122a3faf9cc16d64d573b6", "commit": "b0beba5b659c664bdbf0c07227fbdee313698dd7",
"syncedAt": "2026-07-08T16:00:01Z" "syncedAt": "2026-07-09T15:59:59Z"
}, },
{ {
"id": "next-skills", "id": "next-skills",
@@ -96,8 +96,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "3c0fd70fc04a9bf26e7c510df9a1b7d393ac65c4", "commit": "34d92a50266d2b70be5ac8ac147bd270f52d4a12",
"syncedAt": "2026-07-08T16:00:01Z" "syncedAt": "2026-07-09T15:59:59Z"
} }
] ]
} }
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-07-08T16:01:18Z" "syncedAt": "2026-07-09T16:01:35Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "3c0fd70fc04a9bf26e7c510df9a1b7d393ac65c4", "commit": "34d92a50266d2b70be5ac8ac147bd270f52d4a12",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-07-08T16:00:01Z" "syncedAt": "2026-07-09T15:59:59Z"
} }
+3 -5
View File
@@ -232,11 +232,7 @@ Never used one of these? Don't worry — in this project they play exactly one r
### 3. Set Up ### 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. **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.
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):
```bash ```bash
git clone https://github.com/hugohe3/ppt-master.git git clone https://github.com/hugohe3/ppt-master.git
@@ -249,6 +245,8 @@ Then install dependencies:
pip install -r requirements.txt 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 #### Updating Later
**Git clone installs:** **Git clone installs:**
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "37427f940014c7d1aa122a3faf9cc16d64d573b6", "commit": "b0beba5b659c664bdbf0c07227fbdee313698dd7",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-07-08T16:00:01Z" "syncedAt": "2026-07-09T15:59:59Z"
} }
@@ -365,13 +365,13 @@ Steps:
```bash ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait 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.** 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, do **not** relaunch: 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 ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2 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. 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 ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only 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 ```bash
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon 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. - 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. - 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. - **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 sys
import threading import threading
import time import time
import urllib.error
import urllib.request import urllib.request
import uuid
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from typing import Optional 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 console_encoding import configure_utf8_stdio # noqa: E402
from server_common import ( # noqa: E402 from server_common import ( # noqa: E402
claim_lock as _claim_lock, claim_lock as _claim_lock,
clear_lock as _clear_lock,
find_free_port as _find_free_port, find_free_port as _find_free_port,
lock_pid as _lock_pid,
popen_detached as _popen_detached,
process_alive as _process_alive, process_alive as _process_alive,
read_lock as _read_lock, read_lock as _read_lock,
release_lock as _release_lock, release_lock as _release_lock,
@@ -69,10 +74,11 @@ logger = logging.getLogger('confirm_ui')
# preview lock so the two surfaces never collide. # preview lock so the two surfaces never collide.
LOCK_FILE_NAME = '.confirm_ui.lock' 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' CONFIRM_DIR_NAME = 'confirm_ui'
RECOMMENDATIONS_NAME = 'recommendations.json' RECOMMENDATIONS_NAME = 'recommendations.json'
RESULT_NAME = 'result.json' RESULT_NAME = 'result.json'
SESSION_NAME = 'session.json'
# Static option universe served at /api/catalogs (canvas synced live from config). # Static option universe served at /api/catalogs (canvas synced live from config).
_CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json' _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 # 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. # rule for the whole pipeline. They still keep separate processes and locks.
DEFAULT_PORT = 5050 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 # Default --wait budget, kept just under the 600s Bash-tool ceiling so the
# parent (waiting) command returns before the calling harness kills it. The # parent (waiting) command returns before the calling harness kills it. The
@@ -99,6 +107,147 @@ DEFAULT_PORT = 5050
WAIT_TIMEOUT_DEFAULT = 590 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( def _wait_for_result(
result_file: Path, result_file: Path,
proc: subprocess.Popen, proc: subprocess.Popen,
@@ -139,10 +288,10 @@ def _wait_for_result(
def _result_stage(result_file: Path) -> Optional[str]: def _result_stage(result_file: Path) -> Optional[str]:
"""Return the canonical ``stage`` field of result.json, or None.""" """Return the canonical ``stage`` field of result.json, or None."""
try: try:
data = json.loads(result_file.read_text(encoding='utf-8')) data = _read_json_object(result_file)
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError, ValueError):
return None 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]: def _stage_key(value: object) -> Optional[str]:
@@ -175,6 +324,137 @@ def _recommendation_stage(data: dict) -> int:
return 0 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 # 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 # 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 # 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: def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
"""Fold already-confirmed choices into later-stage recommendations.""" """Fold already-confirmed choices into later-stage recommendations."""
try: try:
res = json.loads(result_file.read_text(encoding='utf-8')) res = _read_json_object(result_file)
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError, ValueError):
return
if not isinstance(res, dict):
return return
recommend = data.setdefault('recommend', {}) recommend = data.setdefault('recommend', {})
if not isinstance(recommend, dict): if not isinstance(recommend, dict):
@@ -239,7 +517,7 @@ def _wait_only_for_result(
return 0 return 0
lock = _read_lock(lock_file) 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): if not pid or not _process_alive(pid):
logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage) logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
return 1 return 1
@@ -267,10 +545,10 @@ def _shutdown_existing(lock_file: Path) -> int:
if not existing: if not existing:
logger.info('no confirm server running — nothing to stop') logger.info('no confirm server running — nothing to stop')
return 0 return 0
pid = int(existing.get('pid', 0) or 0) pid = _lock_pid(existing)
port = existing.get('port') port = existing.get('port')
if not _process_alive(pid): if not _process_alive(pid):
_release_lock(lock_file) _clear_lock(lock_file)
logger.info('confirm server already stopped; cleared stale lock') logger.info('confirm server already stopped; cleared stale lock')
return 0 return 0
# Graceful first: the server flushes and releases its own lock. # 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) os.kill(pid, signal.SIGTERM)
except OSError: except OSError:
pass pass
_release_lock(lock_file) _clear_lock(lock_file)
logger.info('confirm server stopped (pid=%s)', pid) logger.info('confirm server stopped (pid=%s)', pid)
return 0 return 0
@@ -395,6 +673,7 @@ def create_app(
project_dir: str, project_dir: str,
idle_timeout: int = 900, idle_timeout: int = 900,
lock_file: Optional[Path] = None, lock_file: Optional[Path] = None,
server_port: Optional[int] = None,
) -> Flask: ) -> Flask:
"""Create and configure the Flask app for a given project directory.""" """Create and configure the Flask app for a given project directory."""
project_path = Path(project_dir).resolve() project_path = Path(project_dir).resolve()
@@ -404,6 +683,7 @@ def create_app(
app.config['PROJECT_PATH'] = project_path app.config['PROJECT_PATH'] = project_path
app.config['CONFIRM_DIR'] = confirm_dir app.config['CONFIRM_DIR'] = confirm_dir
app.config['LOCK_FILE'] = lock_file app.config['LOCK_FILE'] = lock_file
app.config['SERVER_PORT'] = server_port
app.config['LAST_REQUEST_TIME'] = time.time() app.config['LAST_REQUEST_TIME'] = time.time()
@app.before_request @app.before_request
@@ -445,6 +725,44 @@ def create_app(
def index(): def index():
return send_from_directory(app.static_folder, 'index.html') 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') @app.route('/api/catalogs')
def get_catalogs(): def get_catalogs():
"""Serve the option universe; canvas is synced live from config.py so """Serve the option universe; canvas is synced live from config.py so
@@ -489,8 +807,8 @@ def create_app(
if not rec_file.exists(): if not rec_file.exists():
return jsonify({'error': 'recommendations not found'}), 404 return jsonify({'error': 'recommendations not found'}), 404
try: try:
data = json.loads(rec_file.read_text(encoding='utf-8')) data = _read_json_object(rec_file)
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError, ValueError) as exc:
return jsonify({'error': f'invalid recommendations.json: {exc}'}), 400 return jsonify({'error': f'invalid recommendations.json: {exc}'}), 400
# Report whether a result already exists (re-open after confirm). # Report whether a result already exists (re-open after confirm).
result_file = confirm_dir / RESULT_NAME result_file = confirm_dir / RESULT_NAME
@@ -527,9 +845,11 @@ def create_app(
result['status'] = 'confirmed' result['status'] = 'confirmed'
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S') result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
result_file = confirm_dir / RESULT_NAME result_file = confirm_dir / RESULT_NAME
result_file.write_text( _write_json_atomic(result_file, result)
json.dumps(result, ensure_ascii=False, indent=2), _sync_session_state(
encoding='utf-8', 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) logger.info('%s confirmation written to %s', result['stage'], result_file)
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@@ -558,8 +878,9 @@ def build_parser() -> argparse.ArgumentParser:
) )
parser.add_argument( parser.add_argument(
'--wait-only', action='store_true', '--wait-only', action='store_true',
help='Do not launch. Attach to the already-running confirm server for ' help='Attach to the confirm server for this project and wait for an '
'this project and wait for an already-open page to write result.json.', '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( parser.add_argument(
'--wait-stage', default='final', metavar='{stage2,final}', '--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 # Staged wait: attach to the server launched by the first --wait and block
# until the page writes the requested intermediate or final result.json. # until the page writes the requested intermediate or final result.json.
if args.wait_only: 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( return _wait_only_for_result(
project_path / CONFIRM_DIR_NAME / RESULT_NAME, project_path / CONFIRM_DIR_NAME / RESULT_NAME,
project_path / LOCK_FILE_NAME, lock_file,
args.wait_timeout, args.wait_timeout,
wait_stage, wait_stage,
) )
@@ -629,53 +975,29 @@ def main(argv: Optional[list[str]] = None) -> int:
if args.daemon: if args.daemon:
lock_file = project_path / LOCK_FILE_NAME lock_file = project_path / LOCK_FILE_NAME
existing = _read_lock(lock_file) 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_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?') existing_port = existing.get('port', '?')
logger.error( logger.error(
'confirm UI is already running for this project ' 'confirm UI is already running for this project '
'(pid=%s, port=%s). Open http://localhost:%s', '(pid=%s, port=%s). Open http://%s:%s',
existing_pid, existing_port, existing_port, existing_pid, existing_port, PUBLIC_HOST, existing_port,
) )
return 1 return 1
confirm_dir = project_path / CONFIRM_DIR_NAME 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 result_file = confirm_dir / RESULT_NAME
started_at = time.time() started_at = time.time()
# Pick a free port up front (another project may hold the default) and try:
# pass the concrete port to the child so the reported URL is accurate. proc, port, _ = _launch_background_server(
port = _find_free_port(args.port) project_path,
cmd = [ preferred_port=args.port,
sys.executable, idle_timeout=args.timeout,
str(Path(__file__).resolve()), open_browser=not args.no_browser,
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,
) )
url = f'http://localhost:{port}' except RuntimeError as exc:
logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid) logger.error('%s', exc)
logger.info('log: %s', log_path) return 1
if args.wait: if args.wait:
return _wait_for_result(result_file, proc, started_at, args.wait_timeout) return _wait_for_result(result_file, proc, started_at, args.wait_timeout)
return 0 return 0
@@ -689,8 +1011,8 @@ def main(argv: Optional[list[str]] = None) -> int:
existing_port = existing.get('port', '?') existing_port = existing.get('port', '?')
logger.error( logger.error(
'confirm UI is already running for this project ' 'confirm UI is already running for this project '
'(pid=%s, port=%s). Open http://localhost:%s, or run: kill %s', '(pid=%s, port=%s). Open http://%s:%s, or run: kill %s',
existing_pid, existing_port, existing_port, existing_pid, existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
) )
return 1 return 1
atexit.register(_release_lock, lock_file) atexit.register(_release_lock, lock_file)
@@ -707,16 +1029,17 @@ def main(argv: Optional[list[str]] = None) -> int:
str(project_path), str(project_path),
idle_timeout=args.timeout, idle_timeout=args.timeout,
lock_file=lock_file, lock_file=lock_file,
server_port=args.port,
) )
url = f'http://localhost:{args.port}' url = _server_url(args.port)
if not args.no_browser: if not args.no_browser:
webbrowser.open(url) _open_browser_async(url)
logger.info('running at %s', url) logger.info('running at %s', url)
logger.info('project: %s', project_path) logger.info('project: %s', project_path)
logger.info('idle timeout: %ds (0 = disabled)', args.timeout) 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 return 0
@@ -21,6 +21,7 @@
btn_confirm: "Confirm", btn_confirm: "Confirm",
btn_next: "Next →", btn_next: "Next →",
deriving: "Generating the downstream options from your choices…", 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.", already_confirmed: "Already confirmed once. Re-submitting overwrites the previous choices.",
confirmed_title: "✓ Confirmed", confirmed_title: "✓ Confirmed",
confirmed_hint: "Your choices are saved. You can close this page and return to the chat.", 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: "Body",
font_body_size: "Body baseline size", font_body_size: "Body baseline size",
font_body_size_hint: "All type sizes derive from this body baseline.", 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_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_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.)", 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_confirm: "確定",
btn_next: "次へ →", btn_next: "次へ →",
deriving: "選択内容をもとに後続の選択肢を生成しています…", deriving: "選択内容をもとに後続の選択肢を生成しています…",
connection_lost: "確認ページのサーバー接続が中断されました。再試行しています。失敗が続く場合はチャットで確認してください。",
already_confirmed: "すでに一度確定済みです。再送信すると前回の選択を上書きします。", already_confirmed: "すでに一度確定済みです。再送信すると前回の選択を上書きします。",
confirmed_title: "✓ 確定しました", confirmed_title: "✓ 確定しました",
confirmed_hint: "選択内容を保存しました。このページを閉じてチャットに戻ってください。", confirmed_hint: "選択内容を保存しました。このページを閉じてチャットに戻ってください。",
@@ -176,6 +181,9 @@
font_body: "本文", font_body: "本文",
font_body_size: "本文の基準サイズ", font_body_size: "本文の基準サイズ",
font_body_size_hint: "すべての文字サイズはこの本文基準から導出されます。", font_body_size_hint: "すべての文字サイズはこの本文基準から導出されます。",
body_size_unit_relation: "SVG px と PPT pt の換算:1px = 0.75pt。",
body_size_pt_hint: "約 {pt} pt1px = 0.75pt 換算、保存は px)。",
role_size_pt_hint: "約 {pt} pt",
body_size_hint_canvas: "このキャンバスの目安は約{lo}–{hi}px(キャンバスの高さに応じて変化)。", body_size_hint_canvas: "このキャンバスの目安は約{lo}–{hi}px(キャンバスの高さに応じて変化)。",
body_size_hint_purpose: "この利用シーンの推奨は{def}px — 範囲ではなく固定値です。", body_size_hint_purpose: "この利用シーンの推奨は{def}px — 範囲ではなく固定値です。",
body_size_hint_oor: "(現在の値はこのキャンバスの通常範囲外です — 単位とサイズ感を確認してください。)", body_size_hint_oor: "(現在の値はこのキャンバスの通常範囲外です — 単位とサイズ感を確認してください。)",
@@ -235,6 +243,7 @@
btn_confirm: "确认", btn_confirm: "确认",
btn_next: "下一步 →", btn_next: "下一步 →",
deriving: "正在根据你的选择生成下游选项…", deriving: "正在根据你的选择生成下游选项…",
connection_lost: "确认页服务连接中断,正在重试;如果持续失败,请回到聊天窗口走聊天确认。",
already_confirmed: "已确认过一次,重新提交会覆盖之前的选择。", already_confirmed: "已确认过一次,重新提交会覆盖之前的选择。",
confirmed_title: "✓ 已确认", confirmed_title: "✓ 已确认",
confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。", confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。",
@@ -283,6 +292,9 @@
font_body: "正文", font_body: "正文",
font_body_size: "正文基准字号", font_body_size: "正文基准字号",
font_body_size_hint: "所有字号按这个正文基准推导。", 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_canvas: "当前画布建议 ~{lo}{hi}px(随画布高度缩放)。",
body_size_hint_purpose: "该交付目的推荐 {def}px(单一固定值,非区间)。", body_size_hint_purpose: "该交付目的推荐 {def}px(单一固定值,非区间)。",
body_size_hint_oor: "(当前数值超出该画布的常用范围——请确认单位无误、是否合适。)", body_size_hint_oor: "(当前数值超出该画布的常用范围——请确认单位无误、是否合适。)",
@@ -1144,6 +1156,13 @@
return Math.round(value * 100) / 100; 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) { function normalizeTypographyForSubmit(payload) {
if (!payload.typography || typeof payload.typography !== "object") return; if (!payload.typography || typeof payload.typography !== "object") return;
var typ = payload.typography; var typ = payload.typography;
@@ -1398,6 +1417,7 @@
var sizeField = el("div", "subfield"); var sizeField = el("div", "subfield");
sizeField.appendChild(el("div", "subfield-label", t("font_body_size"))); 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 sizeRow = el("div", "font-size-row");
var sizeInput = el("input", "num-input font-size-input"); var sizeInput = el("input", "num-input font-size-input");
sizeInput.type = "number"; sizeInput.type = "number";
@@ -1415,8 +1435,9 @@
refreshStylePreview(); refreshStylePreview();
}); });
sizeRow.appendChild(sizeInput); sizeRow.appendChild(sizeInput);
var sizeHint = el("div", "toggle-desc"); sizeRow.appendChild(el("span", "font-size-unit", "px"));
sizeRow.appendChild(sizeHint); 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 // Hint only — the user's value is never overwritten; downstream §g
// re-derives if ignored. PPT body is one fixed px value per delivery // 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. // 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 // canvas's usual px range, so an accidental extreme value is visible
// instead of silently submitting it. // instead of silently submitting it.
var cur = parseFloat(STATE.typography && STATE.typography.body_size); 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)) { if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) {
txt += " " + t("body_size_hint_oor"); txt += " " + t("body_size_hint_oor");
} }
@@ -1448,6 +1472,8 @@
}; };
refreshBodySizeHint(); refreshBodySizeHint();
sizeField.appendChild(sizeRow); sizeField.appendChild(sizeRow);
sizeField.appendChild(sizePtHint);
sizeField.appendChild(sizeHint);
// Delivery purpose is a Stage-1 anchor confirmed inside renderAudience (§c) — // 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 // 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"))); sizeOverride.appendChild(el("div", "subfield-label", t("size_override")));
var srow = el("div", "hex-row"); var srow = el("div", "hex-row");
var sizeInputs = {}; 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) { SIZE_ROLES.forEach(function (role) {
var wrap = el("div", "hex-cell"); var wrap = el("div", "hex-cell");
wrap.appendChild(el("div", "hex-cell-label", t("size_role_" + role))); wrap.appendChild(el("div", "hex-cell-label", t("size_role_" + role)));
var inputLine = el("div", "role-size-line");
var inp = document.createElement("input"); var inp = document.createElement("input");
inp.type = "number"; inp.min = "6"; inp.max = "200"; inp.step = "1"; inp.type = "number"; inp.min = "6"; inp.max = "200"; inp.step = "1";
inp.addEventListener("input", function () { inp.addEventListener("input", function () {
@@ -1472,10 +1507,16 @@
if (!STATE.typography.sizes) STATE.typography.sizes = {}; if (!STATE.typography.sizes) STATE.typography.sizes = {};
// Independent input — each role holds its own value; no cascade. // Independent input — each role holds its own value; no cascade.
STATE.typography.sizes[role] = inp.value; STATE.typography.sizes[role] = inp.value;
refreshRolePtHint(role);
refreshStylePreview(); refreshStylePreview();
}); });
sizeInputs[role] = inp; 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); sizeOverride.appendChild(srow);
sec.appendChild(sizeOverride); sec.appendChild(sizeOverride);
@@ -1495,6 +1536,7 @@
var hasVal = cur !== undefined && cur !== null && cur !== ""; var hasVal = cur !== undefined && cur !== null && cur !== "";
if (!hasVal) STATE.typography.sizes[role] = deriveSize(role, bodyVal); if (!hasVal) STATE.typography.sizes[role] = deriveSize(role, bodyVal);
if (sizeInputs[role]) sizeInputs[role].value = STATE.typography.sizes[role]; if (sizeInputs[role]) sizeInputs[role].value = STATE.typography.sizes[role];
refreshRolePtHint(role);
}); });
}; };
refreshSizeInputs(); refreshSizeInputs();
@@ -2161,25 +2203,36 @@
l.style.display = "block"; l.style.display = "block";
} }
// Poll the recommendations endpoint (no-store) until the AI overwrites it with // Poll session state first. It is derived from recommendations.json and
// the next re-derived stage, then render it in the same session. // result.json, so a recovered server can tell the existing page exactly when
// the next re-derived stage is ready.
function pollForStage(nextStage) { function pollForStage(nextStage) {
fetch("/api/recommendations", { cache: "no-store" }) fetchJson("/api/session", "session")
.then(function (r) { if (!r.ok) throw new Error("poll failed"); return r.json(); }) .then(function (session) {
.then(function (data) { var readyStage = Number(session && session.recommendation_stage_number || 0);
if (data && stageNumber(data) === nextStage) { enterStage(data, nextStage); } 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); } else { setTimeout(function () { pollForStage(nextStage); }, 1200); }
return null;
});
}).catch(function (err) { }).catch(function (err) {
var l = document.getElementById("loading"); 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); setTimeout(function () { pollForStage(nextStage); }, 1500);
}); });
} }
function enterStage(data, stage) { function enterStage(data, stage) {
REC = data; REC = data;
if (stage === 2) initStage2State(); if (stage >= 2) initStage2State();
if (stage === 3) initStage3State(); if (stage >= 3) initStage3State();
STAGE = stage; STAGE = stage;
document.getElementById("loading").style.display = "none"; document.getElementById("loading").style.display = "none";
document.getElementById("sections").style.display = "block"; document.getElementById("sections").style.display = "block";
@@ -2235,21 +2288,37 @@
e.textContent = msg; 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() { function loadCatalogs() {
return fetch("/api/catalogs") return fetchJson("/api/catalogs", "catalogs")
.then(function (r) { if (r.ok) return r.json(); throw new Error("no api"); }) .catch(function () { return fetchJson("/static/catalogs.json", "static catalogs"); });
.catch(function () { return fetch("/static/catalogs.json").then(function (r) { return r.json(); }); });
} }
function loadIconPreviews() { function loadIconPreviews() {
return fetch("/api/icon-previews") return fetchJson("/api/icon-previews", "icon previews")
.then(function (r) { if (r.ok) return r.json(); throw new Error("no icon preview api"); })
.catch(function () { return {}; }); .catch(function () { return {}; });
} }
function loadAiImageComparison() { function loadAiImageComparison() {
return fetch("/api/ai-image-comparison") return fetchJson("/api/ai-image-comparison", "ai image comparison")
.then(function (r) { if (r.ok) return r.json(); throw new Error("no ai image comparison api"); })
.catch(function () { return {}; }); .catch(function () { return {}; });
} }
@@ -2337,7 +2406,7 @@
Promise.all([ Promise.all([
loadCatalogs(), loadCatalogs(),
fetch("/api/recommendations").then(function (r) { if (!r.ok) throw new Error("load failed"); return r.json(); }), fetchJson("/api/recommendations", "recommendations"),
loadIconPreviews(), loadIconPreviews(),
loadAiImageComparison() loadAiImageComparison()
]).then(function (res) { ]).then(function (res) {
@@ -2360,8 +2429,8 @@
if (REC._already_confirmed) { if (REC._already_confirmed) {
document.getElementById("confirm-status").textContent = t("already_confirmed"); document.getElementById("confirm-status").textContent = t("already_confirmed");
} }
}).catch(function () { }).catch(function (err) {
showError(t("load_error")); showError(t("load_error") + " " + (err && err.message ? err.message : ""));
}); });
} }
@@ -504,6 +504,14 @@ body {
.image-usage-notes-input { min-height: 64px; resize: vertical; } .image-usage-notes-input { min-height: 64px; resize: vertical; }
.font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.font-size-input { width: 96px; } .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 { margin-top: 14px; }
.subfield-label { font-size: 12.5px; color: var(--muted); margin-bottom: 7px; } .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 { display: flex; flex-direction: column; gap: 4px; }
.hex-cell-label { font-size: 11px; color: var(--muted); } .hex-cell-label { font-size: 11px; color: var(--muted); }
.hex-input-line { display: flex; align-items: center; gap: 6px; } .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 { .hex-cell input {
width: 92px; width: 92px;
border: 1px solid var(--line); 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. - 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`). - **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). - `--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. - `--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). - 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. - 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 ## 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 ### 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 | | `"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 | | *(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) ### 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. - 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. - **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. - **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.53.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. - **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.53.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. 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. - **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. - **`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. - **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. - **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. the two servers cannot drift apart.
Usage: 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: Dependencies:
None (only uses standard library) None (only uses standard library)
""" """
import json import json
import logging
import os import os
import socket import socket
import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional 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 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. """Return True if a process with this pid is reachable.
On POSIX, ``os.kill(pid, 0)`` succeeds when the process exists even without 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 alive. On Windows there is no ``os.kill(pid, 0)`` equivalent, so probe via
``OpenProcess`` + ``WaitForSingleObject``. ``OpenProcess`` + ``WaitForSingleObject``.
""" """
if pid <= 0: try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
if pid_int <= 0:
return False return False
if os.name == 'nt': if os.name == 'nt':
import ctypes import ctypes
@@ -78,7 +118,7 @@ def process_alive(pid: int) -> bool:
handle = kernel32.OpenProcess( handle = kernel32.OpenProcess(
process_query_limited_information | synchronize, process_query_limited_information | synchronize,
False, False,
pid, pid_int,
) )
if not handle: if not handle:
return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED
@@ -93,7 +133,7 @@ def process_alive(pid: int) -> bool:
kernel32.CloseHandle(handle) kernel32.CloseHandle(handle)
try: try:
os.kill(pid, 0) os.kill(pid_int, 0)
except ProcessLookupError: except ProcessLookupError:
return False return False
except PermissionError: except PermissionError:
@@ -112,6 +152,20 @@ def read_lock(lock_file: Path) -> Optional[dict]:
return None 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]: def claim_lock(lock_file: Path, port: int) -> Optional[dict]:
"""Try to claim the per-project preview slot. """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. A stale lock (pointing at a dead pid) is silently overwritten.
""" """
existing = read_lock(lock_file) 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 return existing
lock_file.write_text( lock_file.write_text(
json.dumps({'pid': os.getpid(), 'port': port}), 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*.""" """Best-effort cleanup: only delete the lock if it still names *us*."""
try: try:
current = read_lock(lock_file) 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) lock_file.unlink(missing_ok=True)
except OSError: except OSError:
pass pass
@@ -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_AREA = 30000 # Minimum pixel area (e.g. 200x150)
MIN_IMAGE_BYTES = 2048 # Minimum image data size (2KB) MIN_IMAGE_BYTES = 2048 # Minimum image data size (2KB)
MIN_PAGE_RATIO = 0.05 # Minimum render size relative to page (5%) 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_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_BPP = 0.08 # Bytes-per-pixel threshold for low-info images
MAX_LOW_INFO_AREA = 500000 # Area threshold: only apply bpp filter below this 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) 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( def should_keep_image(
block: dict[str, object], block: dict[str, object],
page_rect: fitz.Rect, page_rect: fitz.Rect,
@@ -375,31 +392,39 @@ def should_keep_image(
Whether the image should be kept in the Markdown output. Whether the image should be kept in the Markdown output.
""" """
w, h = block.get("width", 0), block.get("height", 0) 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 # 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 return False
# Pixel area filter # Pixel area filter
area = w * h area = w * h
if area < MIN_IMAGE_AREA: if not visibly_placed and area < MIN_IMAGE_AREA:
return False return False
image_data = block.get("image", b"") 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 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: if seen_hashes is not None:
img_hash = hashlib.md5(image_data).hexdigest() 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 return False
seen_hashes.add(img_hash) seen_hashes.add(img_hash)
# Check render size relative to page # 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_w = page_rect.width
page_h = page_rect.height page_h = page_rect.height
if page_w > 0 and page_h > 0: 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 # compression ratios (low bytes-per-pixel). Only apply to smaller images
# to avoid filtering large photos with dark/uniform backgrounds. # to avoid filtering large photos with dark/uniform backgrounds.
bpp = len(image_data) / area 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 False
return True return True
@@ -626,6 +651,642 @@ def clean_text(text: str) -> str:
return '\n'.join(cleaned_lines) 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 {"RD", "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: def merge_adjacent_formatting(text: str) -> str:
"""Merge adjacent same-style formatted spans split across PDF tokens. """Merge adjacent same-style formatted spans split across PDF tokens.
@@ -724,28 +1385,35 @@ def extract_pdf_to_markdown(
img_count = 0 img_count = 0
image_manifest: list[dict[str, object]] = [] image_manifest: list[dict[str, object]] = []
previous_table_continues = False
for page_num, page in enumerate(doc, 1): for page_num, page in enumerate(doc, 1):
if page_num > 1: if page_num > 1:
# Add page break marker to help LLM understand context segmentation # Add page break marker to help LLM understand context segmentation
markdown_content += f"\n\n<!-- Page {page_num} -->\n\n" markdown_content += f"\n\n<!-- Page {page_num} -->\n\n"
try: table_candidates, previous_table_continues = find_page_tables(
tabs = page.find_tables() page,
except Exception: include_top_continuation=previous_table_continues,
tabs = [] )
tab_rects = [
tab_rects = [fitz.Rect(t.bbox) for t in tabs] candidate["bbox"]
for candidate in table_candidates
if isinstance(candidate["bbox"], fitz.Rect)
]
page_elements = [] page_elements = []
for tab in tabs: for table in table_candidates:
bbox = table["bbox"]
if not isinstance(bbox, fitz.Rect):
continue
page_elements.append({ page_elements.append({
"y0": tab.bbox[1], "y0": bbox.y0,
"type": 2, "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: if render_vector_figures:
for figure_rect in detect_vector_figure_rects(page, tab_rects): for figure_rect in detect_vector_figure_rects(page, tab_rects):
@@ -1048,6 +1716,7 @@ def extract_pdf_to_markdown(
doc.close() doc.close()
markdown_content = merge_markdown_continuation_tables(markdown_content)
markdown_content = CONTROL_CHARS_RE.sub('', markdown_content) markdown_content = CONTROL_CHARS_RE.sub('', markdown_content)
markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content) markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
markdown_content = markdown_content.strip() + "\n" 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 console_encoding import configure_utf8_stdio # noqa: E402
from server_common import ( # noqa: E402 from server_common import ( # noqa: E402
claim_lock as _claim_lock, claim_lock as _claim_lock,
clear_lock as _clear_lock,
find_free_port as _find_free_port, find_free_port as _find_free_port,
lock_pid as _lock_pid,
popen_detached as _popen_detached,
process_alive as _process_alive, process_alive as _process_alive,
read_lock as _read_lock, read_lock as _read_lock,
release_lock as _release_lock, release_lock as _release_lock,
@@ -99,6 +102,16 @@ _SLIDE_CACHE: dict = {} # path -> (mtime, (content, warnings))
_LIST_CACHE_LOCK = threading.Lock() _LIST_CACHE_LOCK = threading.Lock()
_LIST_CACHE: dict = {} # path -> (mtime, annotation_count_on_disk) _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: def _xml_attr(value: object) -> str:
"""Escape a value for safe insertion into generated preview SVG markup.""" """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) cache[path] = (mtime, value)
# Lock / liveness helpers are shared with confirm_ui via server_common # Lock / liveness helpers are shared with confirm_ui via server_common.
# (imported above as _process_alive / _read_lock / _claim_lock / _release_lock).
def _inline_icons(content: str) -> tuple[str, list[dict]]: def _inline_icons(content: str) -> tuple[str, list[dict]]:
@@ -409,6 +421,23 @@ def create_app(
'live': app.config['LIVE_MODE'], '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>') @app.route('/images/<path:filename>')
def serve_image(filename: str): def serve_image(filename: str):
"""Serve images referenced by SVGs as `../images/*.png`. """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.""" """Return a live legacy root lock, if one exists."""
legacy_lock = project_path / LEGACY_LOCK_FILE_NAME legacy_lock = project_path / LEGACY_LOCK_FILE_NAME
existing = _read_lock(legacy_lock) 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 existing
return None return None
@@ -878,17 +907,20 @@ def _shutdown_existing(project_path: Path) -> int:
logger.info('no live preview server running — nothing to stop') logger.info('no live preview server running — nothing to stop')
return 0 return 0
pid = int(existing.get('pid', 0) or 0) pid = _lock_pid(existing)
port = existing.get('port') try:
port = int(existing.get('port', 0) or 0)
except (TypeError, ValueError):
port = 0
if not _process_alive(pid): if not _process_alive(pid):
_release_lock(lock_file) _clear_lock(lock_file)
logger.info('live preview already stopped; cleared stale lock') logger.info('live preview already stopped; cleared stale lock')
return 0 return 0
if port: if port:
try: try:
req = urllib.request.Request( req = urllib.request.Request(
f'http://127.0.0.1:{port}/api/shutdown', _server_url(port, '/api/shutdown'),
data=b'{"reason": "cli-shutdown"}', data=b'{"reason": "cli-shutdown"}',
headers={'Content-Type': 'application/json'}, headers={'Content-Type': 'application/json'},
method='POST', method='POST',
@@ -906,24 +938,37 @@ def _shutdown_existing(project_path: Path) -> int:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
except OSError: except OSError:
pass pass
_release_lock(lock_file) _clear_lock(lock_file)
logger.info('live preview server stopped (pid=%s)', pid) logger.info('live preview server stopped (pid=%s)', pid)
return 0 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.""" """Wait until the server responds or the child exits."""
deadline = time.time() + timeout deadline = time.time() + timeout
health_url = f'{url}/api/config' health_url = _server_url(port, '/api/health')
last_error = ''
while time.time() < deadline: while time.time() < deadline:
if proc.poll() is not None: if proc.poll() is not None:
logger.error('live preview exited during startup (code=%s)', proc.returncode)
return False return False
try: try:
with urllib.request.urlopen(health_url, timeout=1) as response: with urllib.request.urlopen(health_url, timeout=1) as response:
if response.status == 200: if response.status == 200:
return True return True
except (urllib.error.URLError, TimeoutError, OSError): except (urllib.error.URLError, TimeoutError, OSError) as exc:
last_error = str(exc)
time.sleep(0.25) 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 return False
@@ -941,13 +986,27 @@ def _open_browser(url: str) -> bool:
return False 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: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='PPT Master SVG Editor', description='PPT Master SVG Editor',
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument('project_dir', help='Path to project directory (contains svg_output/)') 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('--no-browser', action='store_true', help='Do not auto-open browser')
parser.add_argument( parser.add_argument(
'--daemon', '--daemon',
@@ -1004,9 +1063,9 @@ def main(argv: Optional[list[str]] = None) -> int:
existing_port = legacy_existing.get('port', '?') existing_port = legacy_existing.get('port', '?')
logger.error( logger.error(
'live preview is already running for this project via legacy lock ' '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', '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 return 1
@@ -1015,13 +1074,13 @@ def main(argv: Optional[list[str]] = None) -> int:
if args.daemon: if args.daemon:
existing = _read_lock(lock_file) 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_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?') existing_port = existing.get('port', '?')
logger.error( logger.error(
'live preview is already running for this project ' 'live preview is already running for this project '
'(pid=%s, port=%s). Open http://localhost:%s', '(pid=%s, port=%s). Open http://%s:%s',
existing_pid, existing_port, existing_port, existing_pid, existing_port, PUBLIC_HOST, existing_port,
) )
return 1 return 1
@@ -1047,27 +1106,20 @@ def main(argv: Optional[list[str]] = None) -> int:
] ]
if args.live: if args.live:
cmd.append('--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: try:
with log_path.open('a', encoding='utf-8') as log: with log_path.open('a', encoding='utf-8') as log:
proc = subprocess.Popen( proc = _popen_detached(
cmd, cmd,
stdout=log, stdout=log,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
creationflags=creationflags, logger=logger,
**popen_kwargs,
) )
except OSError as exc: except OSError as exc:
logger.error('cannot write live preview log: %s (%s)', log_path, exc) logger.error('cannot write live preview log: %s (%s)', log_path, exc)
return 1 return 1
url = f'http://localhost:{port}' url = _server_url(port)
if not _wait_for_ready(url, proc): if not _wait_for_ready(port, proc):
logger.error('live preview failed to become reachable: %s (log: %s)', url, log_path) logger.error('live preview failed to become reachable: %s (log: %s)', url, log_path)
return 1 return 1
logger.info('started live preview in background: %s (pid=%s)', url, proc.pid) 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', '?') existing_port = existing.get('port', '?')
logger.error( logger.error(
'live preview is already running for this project ' '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', '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 return 1
# atexit covers normal interpreter shutdown (Ctrl+C / SystemExit); # atexit covers normal interpreter shutdown (Ctrl+C / SystemExit);
@@ -1132,9 +1184,9 @@ def main(argv: Optional[list[str]] = None) -> int:
lock_file=lock_file, lock_file=lock_file,
) )
url = f'http://localhost:{port}' url = _server_url(port)
if not args.no_browser: if not args.no_browser:
_open_browser(url) _open_browser_async(url)
mode = "live preview (auto-startup)" if args.live else "live preview" mode = "live preview (auto-startup)" if args.live else "live preview"
svg_count = len(list(svg_output.glob('*.svg'))) 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('project: %s', project_path)
logger.info('svg_output: %s (%d slides)', svg_output, svg_count) logger.info('svg_output: %s (%d slides)', svg_output, svg_count)
logger.info('idle timeout: %ds (0 = disabled)', idle_timeout) 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 return 0
@@ -978,6 +978,20 @@ _TEXT_WIDTH_HEADROOM_BASE = 1.06
_TEXT_WIDTH_HEADROOM_CAPS = 1.12 _TEXT_WIDTH_HEADROOM_CAPS = 1.12
_SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12 _SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12
_SERIF_TEXT_WIDTH_HEADROOM_CAPS = 1.36 _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: 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) 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: def _textbox_padding(font_size: float) -> float:
"""Return small text-frame slack without visibly lengthening the box.""" """Return small text-frame slack without visibly lengthening the box."""
return max( 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 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_runs: list[list[dict[str, Any]]] | None = None
paragraph_space_before: list[float] = [] paragraph_space_before: list[float] = []
paragraph_bullets: list[dict[str, Any] | None] = []
# Per-tspan widths (visual lines as the deck author drew them) regardless # 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 # 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. # 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']] line_runs = [r for r in line_runs if r['text']]
if not line_runs: if not line_runs:
continue continue
visual_line_widths.append( visual_line_widths.append(_estimate_bullet_line_width(line_runs))
_estimate_text_runs_width(line_runs, include_headroom=False)
)
soft_break = child.get('data-paragraph-soft-break') == '1' soft_break = child.get('data-paragraph-soft-break') == '1'
if soft_break and paragraph_runs: if soft_break and paragraph_runs:
# Append to the previous paragraph. A Latin line-wrap needs a # 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_runs = None
paragraph_space_before = [] paragraph_space_before = []
visual_line_widths = [] 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: if paragraph_runs is not None:
runs = [r for line in paragraph_runs for r in line] runs = [r for line in paragraph_runs for r in line]
else: else:
runs = _build_text_runs(elem, parent_attrs) runs = _build_text_runs(elem, parent_attrs)
runs, single_bullet = _extract_text_bullet(runs)
if not runs: if not runs:
return None return None
@@ -1463,6 +1609,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
) )
else: else:
text_width = _estimate_text_runs_width(runs) 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 text_height = font_size * 1.5
padding = _textbox_padding(font_size) 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) 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>' ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
paragraph_xml_chunks = [] 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 = '' spc_bef_xml = ''
if extra_px > 0: if extra_px > 0:
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT) 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>' 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) 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( paragraph_xml_chunks.append(
f'<a:p>\n<a:pPr algn="{algn}">{ln_spc_xml}{spc_bef_xml}</a:pPr>\n' f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
f'{runs_inner}\n</a:p>'
) )
paragraphs_xml = '\n'.join(paragraph_xml_chunks) paragraphs_xml = '\n'.join(paragraph_xml_chunks)
else: else:
runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs) 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_x = px_to_emu(box_x)
off_y = px_to_emu(box_y) off_y = px_to_emu(box_y)
@@ -110,17 +110,20 @@ Transition effects (-t/--transition):
Per-element entrance animation (-a/--animation, native shapes mode): Per-element entrance animation (-a/--animation, native shapes mode):
{', '.join(animation_choices)} {', '.join(animation_choices)}
Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
"auto" (map effect from group id: chartwipe, card-/step-/pillar-fly, "none" (no auto element builds; page transitions still apply). Use
title/takeawayfade; image-like ids hero/figure-/image/img-/kpi cycle "-a auto" to map effects from group id: chartwipe,
zoom/dissolve/circle/box/diamond/wheel so multiple images vary across card-/step-/pillar-fly, title/takeawayfade; image-like ids
the deck; unmatched ids cycle fade/wipe/fly/zoom). Start mode set by hero/figure-/image/img-/kpi cycle zoom/dissolve/circle/box/diamond/
--animation-trigger, matching PowerPoint's Start dropdown: 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 on-click one presenter click per group
with-previous all groups start together on slide entry with-previous all groups start together on slide entry
after-previous (default) cascade on slide entry; after-previous (default) cascade on slide entry;
gap = --animation-stagger seconds gap = --animation-stagger seconds
mixed (legacy) cycles a larger 16-effect pool by group order; 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): Compatibility mode (enabled by default):
- Automatically generates PNG fallback images, SVG embedded as extension - 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.) (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. - **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) - **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 - to skip the editor, just describe the change in chat
@@ -2,8 +2,8 @@
"sourceId": "shadcn", "sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git", "repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main", "ref": "main",
"commit": "093a538453d3455159fb12a9cc897e39826f9522", "commit": "21e4ceb94418096e21a7f1990027741a8f9b085d",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/shadcn", "sourcePath": "skills/shadcn",
"syncedAt": "2026-07-08T16:00:01Z" "syncedAt": "2026-07-09T15:59:59Z"
} }