From 6056566972141cce17e6b107945a1e7646bb6a97 Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Sun, 18 Oct 2026 00:02:41 +0800 Subject: [PATCH] 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. --- config/external-sources.lock.json | 20 +- .../plugins/caveman/THIRD_PARTY_SOURCE.json | 4 +- .../caveman-compress/scripts/compress.py | 39 ++- .../plugins/mcp-playwright/MCP_SOURCE.json | 2 +- .../next-skills/THIRD_PARTY_SOURCE.json | 4 +- .../ppt-master/THIRD_PARTY_SOURCE.json | 4 +- .../ppt-master/skills/ppt-master/SKILL.md | 5 +- .../skills/ppt-master/scripts/docs/project.md | 3 + .../ppt-master/scripts/docs/svg_editor.md | 9 +- .../skills/ppt-master/scripts/image_gen.py | 2 +- .../scripts/image_sources/provider_common.py | 43 +-- .../ppt-master/scripts/narration_sync.py | 3 +- .../ppt-master/scripts/notes_to_audio.py | 12 +- .../scripts/pptx_to_svg/txbody_to_svg.py | 6 +- .../scripts/project_management/cli.py | 57 +++- .../skills/ppt-master/scripts/slide_roster.py | 11 +- .../scripts/source_to_md/doc_to_md.py | 23 +- .../scripts/source_to_md/pdf_to_md.py | 126 -------- .../scripts/source_to_md/web_to_md.py | 25 +- .../scripts/svg_editor/annotations.py | 10 +- .../ppt-master/scripts/svg_editor/server.py | 275 +++++++++++++++++- .../scripts/svg_editor/static/app.js | 76 ++++- .../scripts/svg_finalize/embed_icons.py | 14 +- .../scripts/svg_quality/svg_contracts.py | 18 +- .../scripts/svg_to_pptx/drawingml/elements.py | 5 +- .../scripts/template_import/manifest.py | 8 +- .../scripts/tests/test_audit_r3_sources.py | 41 +++ .../scripts/tests/test_edit_native_batch_a.py | 8 + .../scripts/tests/test_embed_icons.py | 25 ++ .../scripts/tests/test_image_gen_manifest.py | 52 ++++ .../scripts/tests/test_image_search.py | 45 +++ .../tests/test_pdf_content_preservation.py | 124 ++++++++ .../test_source_to_md_content_loss_docx.py | 14 + .../test_source_to_md_content_loss_web.py | 34 +++ .../scripts/tests/test_svg_editor_server.py | 157 ++++++++++ .../scripts/tests/test_svg_quality_checker.py | 21 ++ .../tests/test_template_flow_batch4.py | 11 + .../scripts/tests/test_text_measure.py | 13 + .../tests/test_text_overflow_import.py | 14 + .../scripts/tests/test_video_subtitles.py | 16 + .../ppt-master/scripts/video_subtitles.py | 3 +- .../plugins/shadcn/THIRD_PARTY_SOURCE.json | 4 +- .../shuorenhua/.codex-plugin/plugin.json | 2 +- .../shuorenhua/THIRD_PARTY_SOURCE.json | 4 +- .../shuorenhua/skills/shuorenhua/SKILL.md | 9 + .../skills/shuorenhua/references/examples.md | 18 ++ 46 files changed, 1171 insertions(+), 248 deletions(-) create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_image_gen_manifest.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_content_preservation.py diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index ed1bd161..04c97859 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -114,8 +114,8 @@ "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", "adapter": "codex-plugin", - "commit": "d262ab3882952a59312ff3537cd86d894523c5ab", - "syncedAt": "2026-10-14T02:09:15Z" + "commit": "81958ad82f2e2da4417778f81f8a3c838d81b798", + "syncedAt": "2026-10-17T16:00:00Z" }, { "id": "taste-skill", @@ -132,8 +132,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "c2477f311c19982ef6e246085438e6cfec3e35ea", - "syncedAt": "2026-10-15T16:00:00Z" + "commit": "d2fba50ee5b9113008b5bb908563527b7efd2f27", + "syncedAt": "2026-10-17T16:00:00Z" }, { "id": "frontend-slides", @@ -168,8 +168,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "8070d3df1f6f54b5fb60a9ae66e79c664f050212", - "syncedAt": "2026-10-14T16:00:00Z" + "commit": "b113d23720260c411ee47b2b05a73615f954deeb", + "syncedAt": "2026-10-17T16:00:00Z" }, { "id": "grill-me", @@ -258,8 +258,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "fb20d9af8b89e2507466702d6844f9e984012fb8", - "syncedAt": "2026-10-15T16:00:00Z" + "commit": "e2e7393c1f8449759aa1ae502b64fd7e946fbf37", + "syncedAt": "2026-10-17T16:00:00Z" }, { "id": "shuorenhua", @@ -267,8 +267,8 @@ "repo": "https://github.com/MrGeDiao/shuorenhua.git", "ref": "main", "adapter": "claude-skill", - "commit": "e7c2b8670bd3e1988bfec199e861cebd87fc5e9c", - "syncedAt": "2026-10-01T16:00:00Z" + "commit": "6c4b9353b87185c26e684c4525be1af47f82fee9", + "syncedAt": "2026-10-17T16:00:00Z" } ] } diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json index 2baaa402..91e6a9f6 100644 --- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "caveman", "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", - "commit": "d262ab3882952a59312ff3537cd86d894523c5ab", + "commit": "81958ad82f2e2da4417778f81f8a3c838d81b798", "adapter": "codex-plugin", "sourcePath": "plugins/caveman", - "syncedAt": "2026-10-14T02:09:15Z" + "syncedAt": "2026-10-17T16:00:00Z" } diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py index 2505f00f..0a8a66c6 100644 --- a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py @@ -912,10 +912,24 @@ def compress_file(filepath: Path) -> bool: ) with file_lock(filepath): - return _compress_file_locked(filepath) + backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md") + had_backup = backup_path.exists() + run = {"live_write_started": False} + try: + return _compress_file_locked(filepath, run) + except BaseException: + # A provider error, timeout or Ctrl-C mid-run must not leave the + # staging file next to the source, or a backup that makes every + # later run abort with "Backup file already exists". The backup is + # removed only if this run created it and never started the live + # write; past that point it may be the only copy of the original. + filepath.with_name(filepath.name + ".caveman-staged").unlink(missing_ok=True) + if not had_backup and not run["live_write_started"]: + backup_path.unlink(missing_ok=True) + raise -def _compress_file_locked(filepath: Path) -> bool: +def _compress_file_locked(filepath: Path, run: dict) -> bool: """Body of compress_file; runs entirely under compress_file's file_lock for this resolved path.""" print(f"Processing: {filepath}") @@ -956,10 +970,6 @@ def _compress_file_locked(filepath: Path) -> bool: provider = configured_provider() print(f"Compressing with {provider}...") masked_body, code_blocks = mask_code_blocks(body) - nocompress_regions = [ - block for _, block in code_blocks - if NOCOMPRESS_OPEN_RE.match(block.split("\n", 1)[0]) - ] masked_compressed = call_claude(build_compress_prompt(masked_body)) try: compressed_body = restore_code_blocks(masked_compressed, code_blocks) @@ -1019,6 +1029,7 @@ def _compress_file_locked(filepath: Path) -> bool: if result.is_valid: print("Validation passed") + run["live_write_started"] = True _write_target(filepath, compressed, backup_path, newline) staging_path.unlink(missing_ok=True) return True @@ -1061,15 +1072,21 @@ def _compress_file_locked(filepath: Path) -> bool: # file (build_fix_prompt is given one, and the anchor check above # requires it to start with the original's first line), so its # frontmatter is split off to compare like against like. - _, fixed_body = split_frontmatter(fixed) + fixed_frontmatter, fixed_body = split_frontmatter(fixed) if not _is_smaller_than_body(fixed_body, body): print(" Skipping this attempt.") continue - # The fix prompt carries regions unmasked and - # validate() never compares prose, so check them verbatim here. - if any(block.rstrip("\n") not in fixed for block in nocompress_regions): - print("❌ Fix attempt aborted: output changed a region.") + # The fix prompt carries the whole file unmasked, so hold the repair to + # the first pass's guards: frontmatter and every masked block (code, + # including fences indented under list items, which validate() cannot + # see, and regions) must come back verbatim. + if fixed_frontmatter != frontmatter: + print("❌ Fix attempt aborted: output changed the YAML frontmatter.") + print(" Skipping this attempt.") + continue + if any(block.rstrip("\n") not in fixed_body for _, block in code_blocks): + print("❌ Fix attempt aborted: output changed a preserved code or block.") print(" Skipping this attempt.") continue diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 3c630d27..de12cd19 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-10-15T16:02:39Z" + "syncedAt": "2026-10-17T16:02:40Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index 20325782..a8dcd3ab 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "fb20d9af8b89e2507466702d6844f9e984012fb8", + "commit": "e2e7393c1f8449759aa1ae502b64fd7e946fbf37", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-10-15T16:00:00Z" + "syncedAt": "2026-10-17T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 7c49d13a..d1f8f8fc 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "8070d3df1f6f54b5fb60a9ae66e79c664f050212", + "commit": "b113d23720260c411ee47b2b05a73615f954deeb", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-10-14T16:00:00Z" + "syncedAt": "2026-10-17T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md index f05d43f3..f3ef7de5 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md @@ -2,7 +2,7 @@ name: ppt-master description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。" metadata: - version: "6.7.0" + version: "6.7.2" copyright: "Copyright (c) 2025-2026 Hugo He" license: "MIT" official_repository: "https://github.com/hugohe3/ppt-master" @@ -25,7 +25,8 @@ use CWD, or assume a repo checkout. If unavailable, ask; never search or guess. 1. Read this file. 2. Run `python3 "${SKILL_DIR}/scripts/attribution_guard.py"`. Any non-zero result stops the Skill immediately; do not inspect, repair, or bypass the integrity - gate. + gate. On Windows, a `python3` that never starts (the WindowsApps stub exits + 9009 with no output) is not a guard result: rerun with `python` first. 3. Read [`workflows/routing.md`](workflows/routing.md) through the concrete absolute path `${SKILL_DIR}/workflows/routing.md`. 4. Select exactly one top-level route and its active profile from the routing diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md index 7f3402c8..6e8ee794 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md @@ -35,6 +35,9 @@ Notes: - Local sources under `projects/` are moved into the target project unless `--copy` is passed; a file inside another project's tree is copied unless `--move` is explicit, so borrowing a finished project's slices never empties it. +- Without `--dir`, `init` creates the project under the repository's + `projects/` when run inside the repository checkout, and under + `/projects/` otherwise (skill-only installations). - `--format` is optional and accepts registered canvas keys only. Pass it only when the actual canvas exactly matches a registered format. - Without `--format`, `init` creates `_`; a name that already diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg_editor.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg_editor.md index f5ba4bdd..cc0d42e3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg_editor.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg_editor.md @@ -18,7 +18,8 @@ The launcher starts the server in the background, waits for `GET /api/health`, r - **Port**: without `--port`, the first free port from `6060`; `--port N` binds strictly and fails if unavailable. Read the actual URL from launch output or `live_preview/lock.json`; never assume `6060`. - **Idle timeout**: plain mode `900s`, `--live` `7200s`; `--timeout ` overrides (`0` disables). - **Single instance per project**: `live_preview/lock.json` is the discovery source for project-local consumers (including `visual_review.py`). A second launch reuses the live instance unless a different explicit `--port N` was requested — that mismatch fails and needs `--shutdown` first. Stale locks (dead pid) are overwritten; legacy `/.live_preview.lock` root locks are still detected when live. -- **Stop conditions**: **Exit preview** in the browser (the only UI action that stops Flask), a chat request to stop, the idle timeout, or an external kill. +- **Stop conditions**: **Exit preview** in the browser (the only UI action that stops Flask), a chat request to stop, the idle timeout, or an external kill. None of them discards unapplied work; see **Draft**. +- **Draft**: every staging change (annotation add/remove, direct edit, undo) rewrites `live_preview/draft.json` before it is acknowledged; the file is removed once nothing is left unapplied. The next start restores each page whose SVG still matches the digest recorded when staging began, and the UI reports the restored pages. A page whose SVG changed meanwhile is not replayed — its draft moves to `live_preview/draft_conflict_.json`, as does an unreadable draft. A failed draft write is reported in the UI; the change then lives in memory only. - **Transient ids**: each element gets a temporary `_edit_N` id while running; on save only annotated elements keep their id. - **Browser preview**: the server inlines `` placeholders and serves `images/*`; the on-disk SVG is unchanged by preview. - **Re-export is chat-driven**: applying changes updates `svg_output/` only; refreshing the PPTX (`finalize_svg.py` + `svg_to_pptx.py`) is a chat step — the editor never runs the export pipeline. @@ -26,13 +27,13 @@ The launcher starts the server in the background, waits for `GET /api/health`, r ## Editing surface - **UI**: four languages (中文 / 繁體中文 / English / 日本語), auto-detected from `navigator.language`, persisted in `localStorage`, switchable from the right panel. The right panel separates direct SVG edits from AI-needed annotations, with a pending-status strip for staged edits and pages with unsaved annotations. Slide navigation: first/prev/next/last buttons plus `←` / `→` / `Home` / `End` (suppressed while typing in the annotation textarea). -- **Buttons**: `Add annotation` stages annotation text in memory; `Apply changes` writes staged direct edits plus annotation markers (`data-edit-target`, `data-edit-annotation`) to disk and keeps the service running; `Exit preview` stops Flask. +- **Buttons**: `Add annotation` stages annotation text in memory; `Apply changes` writes staged direct edits plus annotation markers (`data-edit-target`, `data-edit-annotation`) to disk and keeps the service running; `Exit preview` stops Flask and keeps unapplied work in the draft. - **Direct edit (no AI)**: single element = full inspector (geometry, safe text content, computed text styles for the selected text node or descendant text, raw attributes except protected fields such as `id`, UI `class`, event handlers, hrefs). `` group = group-level surface, selected via `Alt/Option` + click or **Select parent group**. Multi-select = batch editor over top-level objects only: shared x/y plus `fill` / `stroke` / `opacity`; text style fields appear only when every selected object is `text` / `tspan`. Preview updates immediately; disk writes wait for **Apply changes**. - **Drag to move**: press and drag an already-selected element (selection stays a separate click, so the background is never dragged by accident); the whole selection moves together. Pointer delta is mapped through each element's CTM, so moves track the cursor regardless of viewport scale or group transforms. Each release stages one direct edit per moved element; dragging on empty canvas is rubber-band selection; a failed stage rolls the canvas back. - **Arrow-key nudge**: `↑ ↓ ← →` moves the selection 1px, `Shift + arrow` 10px (suppressed while typing); arrow keys navigate slides only when nothing is selected. Same staging/coalescing as drag. - **Overlap picker**: right-click lists every selectable element under the pointer (top→bottom); hovering highlights, clicking selects, `Esc` or an outside click closes; with one element under the pointer, right-click selects it directly. Left-click selects the topmost. -- **Undo**: `Ctrl+Z` or **Undo** drops the last staged direct edit on the current slide (per-slide LIFO, this session). Consecutive edits to the same element and field set coalesce into one step keeping the original pre-edit value. Applied old→new history goes to `live_preview/edits.jsonl`; annotation save/update/remove history to `live_preview/annotations.jsonl`; un-applied staged edits are memory only. -- **Unsaved-work guard**: staged edits and annotation changes live in server memory until **Apply changes**; closing the tab triggers the browser's "leave site?" prompt while any are unapplied. +- **Undo**: `Ctrl+Z` or **Undo** drops the last staged direct edit on the current slide (per-slide LIFO, this session). Consecutive edits to the same element and field set coalesce into one step keeping the original pre-edit value. Applied old→new history goes to `live_preview/edits.jsonl`; annotation save/update/remove history to `live_preview/annotations.jsonl`; un-applied staged edits live in memory plus `live_preview/draft.json`. +- **Unsaved-work guard**: staged edits and annotation changes stay unapplied until **Apply changes**; closing the tab triggers the browser's "leave site?" prompt while any are unapplied. **Apply changes** fails a page, keeping it staged, when an annotated element no longer exists, and the UI flags a submit that finds nothing pending on the server. ## Remote access diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py index dece33e9..6f95e39c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py @@ -998,7 +998,7 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, aspect_ratio=item["aspect_ratio"], image_size=item.get("image_size", image_size), output_dir=output_dir, - filename=Path(item["filename"]).stem, + filename=item["filename"], # the backend strips the extension once model=item.get("model", model), ) saved_path = _materialize_manifest_image( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py index 26049330..8e4fdb32 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py @@ -386,6 +386,19 @@ def _normalize_match_text(text: str) -> str: return _MATCH_SEPARATOR_RE.sub(" ", lowered).strip() +def _contains_term(term: str, text: str) -> bool: + """Match one normalized term: whole words for Latin, spacing-free for CJK. + + "iran" must not pass on "tirana"; "liberation monument" still matches a + run-together "liberationmonument" slug, and CJK ignores inserted spaces. + """ + compact_term = term.replace(" ", "") + if term.isascii(): + forms = "|".join(re.escape(form) for form in {term, compact_term}) + return re.search(rf"(? list[str]: """Split one required term group into alternatives. @@ -414,16 +427,12 @@ def missing_required_terms( return [] text = _normalize_match_text(_candidate_text(candidate)) - compact_text = text.replace(" ", "") missing: list[str] = [] for group in required_terms: alternatives = _term_group_alternatives(group) if not alternatives: continue - matched = any( - alt in text or alt.replace(" ", "") in compact_text - for alt in alternatives - ) + matched = any(_contains_term(alt, text) for alt in alternatives) if not matched: missing.append(str(group)) return missing @@ -447,6 +456,14 @@ def compute_relevance(candidate: AssetCandidate, query: str) -> float: return hits / len(tokens) +# Whole words only: a bare substring test penalized "business", "trail", and +# "网站", and the text includes author names and source URLs. +_INFRA_TERMS_RE = re.compile( + r"(? float: """Score a candidate against a request. Higher is better; -inf rejects. @@ -472,27 +489,17 @@ def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> f score = relevance * 10000.0 title_text = _normalize_match_text(candidate.title) - compact_title = title_text.replace(" ", "") for group in request.required_terms or (): alternatives = _term_group_alternatives(group) - if any( - alt in title_text or alt.replace(" ", "") in compact_title - for alt in alternatives - ): + if any(_contains_term(alt, title_text) for alt in alternatives): score += 1500.0 # Penalize infrastructure/transit metadata if the user didn't explicitly ask for it. # This prevents high-res subway station photos from outranking actual tourist landmarks. text = _candidate_text(candidate) query_lower = request.query.lower() - infra_terms = [ - "station", "subway", "metro", "rail", "transit", "airport", "bus", - "地铁", "站", "轨道", - ] - - if not any(t in query_lower for t in infra_terms): - if any(t in text for t in infra_terms): - score -= 5000.0 + if not _INFRA_TERMS_RE.search(query_lower) and _INFRA_TERMS_RE.search(text): + score -= 5000.0 candidate_orientation = normalize_orientation(candidate.width, candidate.height) requested = (request.orientation or "").strip().lower() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py index 6eced8df..7b38fcf8 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py @@ -51,6 +51,7 @@ if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 +from slide_roster import sort_slide_paths # noqa: E402 from pptx_animations import ( # noqa: E402 ANIMATION_TIMING_OPTION_FIELDS, animation_seconds_to_milliseconds, @@ -494,7 +495,7 @@ def _page_subtitle_names(subtitle_dir: Path) -> list[str]: raise FileNotFoundError(f"Page-local SRT directory not found: {subtitle_dir}") names = [ path.stem - for path in sorted(subtitle_dir.glob("*.srt")) + for path in sort_slide_paths(subtitle_dir.glob("*.srt")) if path.stem != "total" ] if not names: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py index d105d0fa..f0f56454 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py @@ -45,7 +45,7 @@ from pptx_workspace import ( AUTHORING_SVG_FLAT_DIR, ROUNDTRIP_MANIFEST_PATH, ) -from slide_roster import discover_slide_svgs +from slide_roster import discover_slide_svgs, sort_slide_paths from tts_backends import ( backend_cosyvoice, backend_edge, @@ -93,11 +93,15 @@ def _load_tts_env_file() -> None: )) +# A Markdown heading needs a space after the hashes; "#1 revenue grew" is speech. +_ATX_HEADING_RE = re.compile(r" {0,3}#{1,6}(?:[ \t]|$)") + + def spoken_text(markdown: str) -> str: """Return narration text exactly from notes, except Markdown headings.""" lines: list[str] = [] for raw in markdown.splitlines(): - if raw.lstrip().startswith("#"): + if _ATX_HEADING_RE.match(raw): continue line = raw.rstrip() if not line.strip(): @@ -151,7 +155,7 @@ def _expected_note_roster(project: Path) -> list[NoteRosterEntry]: svg_files = discover_slide_svgs(project / "svg_output") if svg_files: aliases: dict[int, list[Path]] = {} - for path in sorted(notes_dir.glob("*.md")): + for path in sort_slide_paths(notes_dir.glob("*.md")): match = re.search(r"slide[_]?(\d+)", path.stem) if match: aliases.setdefault(int(match.group(1)), []).append(path) @@ -255,7 +259,7 @@ def _expected_note_roster(project: Path) -> list[NoteRosterEntry]: note_path=path, output_stem=path.stem, ) - for path in sorted(notes_dir.glob("*.md")) + for path in sort_slide_paths(notes_dir.glob("*.md")) if path.name != "total.md" ] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py index 686b3d0c..734eb9a7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py @@ -34,7 +34,7 @@ from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char from .color_resolver import ColorPalette, find_color_elem, resolve_color from .emu_units import ( NS, Xfrm, fmt_num, emu_to_px, format_ooxml_alpha, - hundredths_pt_to_px, + hundredths_pt_to_px, ooxml_bool, ) from .fill_to_svg import resolve_fill @@ -860,8 +860,8 @@ def _build_run( # font-size: rPr > pPr/defRPr > lstStyle/lvlNpPr/defRPr > endParaRPr > default font_size_px = _font_size_px(style_chain, default_font_size_px) # Bold / italic - bold = _attr_chain(style_chain, "b") == "1" - italic = _attr_chain(style_chain, "i") == "1" + bold = ooxml_bool(_attr_chain(style_chain, "b")) + italic = ooxml_bool(_attr_chain(style_chain, "i")) # Underline / strike u_val = _attr_chain(style_chain, "u") underline = u_val not in (None, "", "none") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/cli.py index 8a269069..911b4c92 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/cli.py @@ -258,13 +258,26 @@ def _has_usable_import(summary: dict[str, list[str]]) -> bool: ) +def default_projects_root() -> Path: + """Return where `init` creates projects when `--dir` is omitted. + + Inside the repository checkout this is the repository's `projects/`, so a + command run from within `projects/` never nests `projects/projects/`. A + skill-only installation (npx skills, plugin cache) has no repository, so + projects go under the caller's working folder instead of the install tree. + """ + if is_within_path(Path.cwd(), REPO_ROOT): + return PROJECTS_ROOT + return Path.cwd() / "projects" + + class ProjectManager: """Create, inspect, validate, and populate project folders.""" CANVAS_FORMATS = CANVAS_FORMATS def __init__(self, base_dir: str | Path | None = None) -> None: - self.base_dir = Path(base_dir) if base_dir is not None else PROJECTS_ROOT + self.base_dir = Path(base_dir) if base_dir is not None else default_projects_root() def scaffold_artifact(self, project_path: str, artifact: str) -> str: """Delegate deterministic Markdown scaffold rendering.""" @@ -540,8 +553,25 @@ class ProjectManager: return file_path def _normalize_text_source(self, source_path: Path, sources_dir: Path) -> Path: + from bs4 import UnicodeDammit + target = self._ensure_unique_path(sources_dir / f"{source_path.stem}.md") - content = source_path.read_text(encoding="utf-8", errors="replace") + # UTF-8 first; a GB18030 / Shift-JIS note used to become U+FFFD silently. + raw = source_path.read_bytes() + dammit = UnicodeDammit(raw, user_encodings=["utf-8"]) + content = dammit.unicode_markup + if content is None: + content = raw.decode("utf-8", errors="replace") + if ( + dammit.unicode_markup is None + or dammit.contains_replacement_characters + or dammit.original_encoding not in ("utf-8", "ascii") + ): + print( + f"note: {source_path.name} decoded as {dammit.original_encoding or 'utf-8 (lossy)'}; " + "check the converted text", + file=sys.stderr, + ) target.write_text(content, encoding="utf-8") return target @@ -566,11 +596,32 @@ class ProjectManager: pass existing_content = existing.read_text(encoding="utf-8", errors="replace") - if self._canonicalize_markdown_content(existing_content) == canonical_source: + if ( + self._canonicalize_markdown_content(existing_content) == canonical_source + and self._companion_assets_match(source_path, existing) + ): return existing return None + def _companion_assets_match(self, first: Path, second: Path) -> bool: + """Same-text Markdown is a duplicate only if its _files/ bytes match too. + + Canonical text ignores the asset folder name, so two exports with + different charts behind the same references used to collapse into one. + """ + def snapshot(markdown: Path) -> dict[str, bytes] | None: + assets = self._companion_asset_dir(markdown) + if assets is None: + return None + return { + path.relative_to(assets).as_posix(): path.read_bytes() + for path in assets.rglob("*") + if path.is_file() + } + + return snapshot(first) == snapshot(second) + def _companion_asset_dir(self, source_path: Path) -> Path | None: candidate = source_path.with_name(f"{source_path.stem}_files") if candidate.exists() and candidate.is_dir(): diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slide_roster.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slide_roster.py index 89022e6b..44f9cc24 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slide_roster.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slide_roster.py @@ -17,6 +17,7 @@ Dependencies: from __future__ import annotations import re +from collections.abc import Iterable from pathlib import Path @@ -36,9 +37,11 @@ def _slide_filename_sort_key( return segments, name +def sort_slide_paths(paths: Iterable[Path]) -> list[Path]: + """Order per-page files (SVG, notes, SRT) by numeric filename segments.""" + return sorted(paths, key=_slide_filename_sort_key) + + def discover_slide_svgs(directory: Path) -> list[Path]: """Return direct child SVG files in numeric filename order.""" - return sorted( - directory.glob("*.svg"), - key=_slide_filename_sort_key, - ) + return sort_slide_paths(directory.glob("*.svg")) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py index 212ec031..4c14a2bb 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py @@ -21,6 +21,7 @@ All paths produce the same output convention: import argparse import base64 +import codecs import hashlib import json import mimetypes @@ -1260,13 +1261,31 @@ def _convert_html(input_file: Path, out_file: Path) -> str: return "" try: - from bs4 import BeautifulSoup + from bs4 import BeautifulSoup, UnicodeDammit except ImportError: print("[ERROR] beautifulsoup4 not installed. Run: pip install beautifulsoup4") return "" media_dir, rel_media_dir = _ensure_media_dir(out_file) - raw_html = input_file.read_text(encoding="utf-8", errors="replace") + # BOM, then the page's own charset declaration, then UTF-8; a forced UTF-8 + # read with errors="replace" turned GBK pages into U+FFFD without a word. + raw_bytes = input_file.read_bytes() + dammit = UnicodeDammit(raw_bytes, user_encodings=["utf-8"], is_html=True) + raw_html = dammit.unicode_markup or "" + has_bom = raw_bytes.startswith((codecs.BOM_UTF8, codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)) + if ( + dammit.contains_replacement_characters + or ( + not has_bom + and not dammit.declared_html_encoding + and dammit.original_encoding not in ("utf-8", "ascii") + ) + ): + print( + f"[WARN] {input_file.name}: no charset declaration and not UTF-8; " + f"decoded as {dammit.original_encoding} — check the converted text", + file=sys.stderr, + ) # Strip non-content elements (head/style/script) so metadata doesn't leak into MD soup = BeautifulSoup(raw_html, "html.parser") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py index da5cccb9..f7559d2b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py @@ -913,136 +913,10 @@ def _merge_single_cell_continuations(rows: list[list[str]]) -> list[list[str]]: 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 _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 - if not _looks_like_model_row(rows[0]) and _looks_like_model_row(rows[1]): - header_offset = 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 model: - pieces.append(model) - if idx + 1 < len(outcome_row) and outcome_row[idx + 1]: - pieces.append(outcome_row[idx + 1]) - header.append(" ".join(pieces).strip()) - return [header] + rows[header_offset + 2:] - - -def _fix_paired_sample_t_table(rows: list[list[str]]) -> list[list[str]]: - """Collapse multi-row paired-sample T-test headings into readable columns.""" - if not rows or not any("成对差分" in cell for cell in rows[0]): - return rows - body = [row for row in rows if row and row[0].startswith("对")] - if len(body) < 1: - return rows - header = [ - "配对", - "变量", - "均值", - "标准差", - "均值的标准误", - "差分95%置信区间下限", - "差分95%置信区间上限", - "t", - "Df", - "Sig.(双侧)", - ] - fixed_rows = [header] - for row in body: - fixed_rows.append(row[:len(header)] + [""] * max(0, len(header) - len(row))) - return fixed_rows - - -def _fix_variable_definition_table(rows: list[list[str]]) -> list[list[str]]: - """Repeat variable-category labels for common variable definition tables.""" - if not rows or rows[0] != ["变量类型", "变量名称", "符号", "变量说明"]: - return rows - - fixed = [rows[0]] - for row in rows[1:]: - if not any(row): - continue - name = row[1] if len(row) > 1 else "" - symbol = row[2] if len(row) > 2 else "" - description = row[3] if len(row) > 3 else "" - if not name or not symbol: - continue - - if symbol in {"R&D", "Fixed", "Hc"}: - category = "被解释变量" - elif symbol == "Vat": - category = "解释变量" - else: - category = "控制变量" - fixed.append([category, name, symbol, description]) - return fixed - - -def _fix_correlation_triangle(rows: list[list[str]]) -> list[list[str]]: - """Restore the missing last self-correlation column in triangular tables.""" - if len(rows) < 4 or not rows[0] or rows[0][0] != "变量": - return rows - body_names = [row[0] for row in rows[1:] if row and row[0]] - header_names = rows[0][1:] - if len(body_names) != len(header_names) + 1: - return rows - missing_name = body_names[-1] - fixed = [rows[0] + [missing_name]] - for row in rows[1:-1]: - fixed.append(row + [""]) - fixed.append(rows[-1] + ["1"]) - return fixed - - def _postprocess_table_rows(rows: list[list[str]]) -> list[list[str]]: """Apply Markdown-oriented cleanup to extracted table rows.""" rows = _merge_label_underscore_rows(rows) rows = _merge_single_cell_continuations(rows) - rows = _fix_variable_definition_table(rows) - rows = _fix_paired_sample_t_table(rows) - rows = _flatten_regression_header(rows) - rows = _fix_correlation_triangle(rows) return rows diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py index a63b9409..a0e173dd 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py @@ -57,6 +57,7 @@ if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 +from _batch import unique_output_path # noqa: E402 from _dispatcher import ( # noqa: E402 DOC_SUFFIXES, EXCEL_SUFFIXES, @@ -1261,14 +1262,16 @@ def is_plain_text_document(url: str, body: str) -> bool: A raw file such as ``.../CHANGELOG.md`` on raw.githubusercontent.com is already Markdown; running it through the HTML extractor loses the file or - fails on a missing body. The URL suffix decides, guarded by the absence of - an HTML document tag near the top of the body. + fails on a missing body. The URL suffix decides, unless the body itself + opens as an HTML document. """ path = urlparse(url).path.lower() if not path.endswith(PLAIN_TEXT_SUFFIXES): return False - head = body[:4096].lower() - return "" or + # "" further down is still Markdown. + start = body[:4096].lstrip("\ufeff \t\r\n").lower() + return not start.startswith((" str | None: return target if urlparse(target).scheme in {"http", "https"} else None +# Auto-named outputs claimed in this run; explicit -o paths are not tracked. +_AUTO_NAMED_OUTPUTS: set[Path] = set() + + def process_url( url: str, output_file: str | None = None, @@ -1429,8 +1436,14 @@ def process_url( output_path = output_file else: base_name = derive_base_name(metadata['title'], url) - filename = f"{base_name}.md" - output_path = os.path.join(CONFIG["output_dir"], filename) + default_path = os.path.join(CONFIG["output_dir"], f"{base_name}.md") + # Two pages titled "Report" in one batch used to share Report.md. + output_path = str(unique_output_path( + Path(CONFIG["output_dir"]), base_name, _AUTO_NAMED_OUTPUTS, + )) + if output_path != default_path: + print(f" [INFO] Renamed output for {url}: {default_path} -> {output_path} " + "(title collision)") output_dirname = os.path.dirname(output_path) or "." os.makedirs(output_dirname, exist_ok=True) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/annotations.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/annotations.py index d7e252ef..8d20ff86 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/annotations.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/annotations.py @@ -26,6 +26,10 @@ ET.register_namespace('', SVG_NS) ET.register_namespace('xlink', XLINK_NS) +# Only ids this tool generates; a user id like "_edit_gradient" is not transient. +_TEMP_ID_RE = re.compile(r'_edit_\d+') + + def assign_temp_ids(root: ET.Element) -> None: """Assign deterministic temp ids (_edit_0, _edit_1, ...) to elements without one. @@ -34,7 +38,7 @@ def assign_temp_ids(root: ET.Element) -> None: """ for elem in root.iter(): eid = elem.get('id', '') - if eid.startswith('_edit_'): + if _TEMP_ID_RE.fullmatch(eid): elem.attrib.pop('id', None) counter = 0 @@ -72,7 +76,7 @@ def _local_name(elem: ET.Element) -> str: def _first_number(value: Optional[str]) -> Optional[float]: if value is None: return None - match = re.search(r'-?\d+(?:\.\d+)?', value) + match = re.search(r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?', value) return float(match.group(0)) if match else None @@ -336,5 +340,5 @@ def strip_unused_temp_ids(root: ET.Element, keep_ids: set) -> None: protected.add(eid) for elem in root.iter(): eid = elem.get('id', '') - if eid.startswith('_edit_') and eid not in protected: + if _TEMP_ID_RE.fullmatch(eid) and eid not in protected: elem.attrib.pop('id', None) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py index 3d669b43..a3483373 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py @@ -19,6 +19,8 @@ Dependencies: import argparse import atexit +import functools +import hashlib import html import json import logging @@ -44,6 +46,9 @@ logger = logging.getLogger('svg_editor') LIVE_PREVIEW_DIR_NAME = 'live_preview' LOCK_FILE_NAME = 'lock.json' LEGACY_LOCK_FILE_NAME = '.live_preview.lock' +# Unapplied staged work, rewritten after every staging change (issue #319). +DRAFT_FILE_NAME = 'draft.json' +DRAFT_VERSION = 1 # Local — sys.path injection for sibling module (code-style.md §3) _SCRIPTS_DIR = Path(__file__).resolve().parent @@ -341,6 +346,25 @@ def _append_annotation_log(project_path: Path, record: dict) -> None: _append_live_preview_log(project_path, ANNOTATION_LOG_NAME, record) +def _file_digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _atomic_replace(path: Path, write) -> None: + """Write through ``write(tmp_path)`` and swap the result into ``path``. + + A crash mid-write leaves the original file intact instead of a truncated + SVG or draft. + """ + tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp') + try: + write(tmp) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]: for elem in root.iter(): if elem.get('id') == element_id: @@ -452,8 +476,160 @@ def create_app( # but are written to svg_output/ only by /api/save-all. app.config['PENDING_EDITS'] = {} - # Idle timeout: auto-shutdown if no one connects within idle_timeout seconds - app.config['LAST_REQUEST_TIME'] = time.time() + # Per-file staging baseline: {filename: {'digest': sha256 of the SVG the + # staged work was built on, 'annotations': its on-disk annotation map}}. + # The digest keeps a restored draft from replaying onto a changed SVG, + # where renumbered _edit_N ids would point at different elements. + app.config['DRAFT_BASES'] = {} + + # Serializes every read-modify-write of the staged state and its draft. + state_lock = threading.RLock() + + def _with_state_lock(view): + @functools.wraps(view) + def wrapper(*args, **kwargs): + with state_lock: + return view(*args, **kwargs) + return wrapper + + def _note_base(name: str, svg_file: Path, disk_annotations: Optional[dict] = None) -> None: + base = app.config['DRAFT_BASES'].setdefault(name, {'digest': None, 'annotations': None}) + if base['digest'] is None: + base['digest'] = _file_digest(svg_file) + if disk_annotations is not None and base['annotations'] is None: + base['annotations'] = dict(disk_annotations) + + def _draft_pages() -> dict: + """Pages whose staged state differs from what is on disk.""" + annotations = app.config['ANNOTATIONS'] + pending_edits = app.config['PENDING_EDITS'] + pages = {} + for name in sorted(set(annotations) | set(pending_edits)): + base = app.config['DRAFT_BASES'].get(name) or {} + entry: dict = {} + if name in annotations and annotations[name] != base.get('annotations'): + entry['annotations'] = annotations[name] + if pending_edits.get(name): + entry['edits'] = pending_edits[name] + if entry: + entry['digest'] = base.get('digest') + pages[name] = entry + return pages + + def _persist_draft() -> bool: + """Mirror the staged state to live_preview/draft.json; False on failure.""" + draft_file = _draft_file(project_path) + pages = _draft_pages() + try: + if not pages: + draft_file.unlink(missing_ok=True) + return True + payload = json.dumps( + {'version': DRAFT_VERSION, 'pages': pages}, + ensure_ascii=False, indent=2, + ).encode('utf-8') + draft_file.parent.mkdir(parents=True, exist_ok=True) + _atomic_replace(draft_file, lambda tmp: tmp.write_bytes(payload)) + except OSError as exc: + logger.warning('draft save failed: %s', exc) + return False + return True + + def _restore_page(name: str, entry) -> Optional[dict]: + """Load one draft page into memory; None when it no longer applies.""" + if not isinstance(entry, dict) or not isinstance(entry.get('digest'), str): + return None + anns = entry.get('annotations') + edits = entry.get('edits') or [] + if anns is not None and not ( + isinstance(anns, dict) + and all(isinstance(k, str) and isinstance(v, str) for k, v in anns.items()) + ): + return None + if not isinstance(edits, list) or not all(isinstance(e, dict) for e in edits): + return None + svg_file = _safe_svg_path(name) + if svg_file is None or not svg_file.exists(): + return None + try: + if _file_digest(svg_file) != entry['digest']: + return None + root = ET.parse(str(svg_file)).getroot() + materialize_inline_geometry_properties(root) + except (OSError, ET.ParseError, GeometryStyleError): + return None + assign_temp_ids(root) + disk_annotations = { + item['element_id']: item['annotation'] + for item in parse_annotations(root) + } + ok, _reason = _apply_edit_records(root, edits) + if not ok: + return None + app.config['DRAFT_BASES'][name] = { + 'digest': entry['digest'], + 'annotations': disk_annotations, + } + if anns is not None: + app.config['ANNOTATIONS'][name] = dict(anns) + if edits: + app.config['PENDING_EDITS'][name] = edits + return {'annotations': anns is not None, 'edits': len(edits)} + + def _restore_draft() -> dict: + """Reload a draft left by a previous server; set aside what no longer fits.""" + status = {'restored': {}, 'conflicts': [], 'conflict_file': None, 'unreadable': False} + draft_file = _draft_file(project_path) + if not draft_file.exists(): + return status + aside = draft_file.with_name(f'draft_conflict_{time.strftime("%Y%m%d-%H%M%S")}.json') + try: + data = json.loads(draft_file.read_text(encoding='utf-8')) + pages = data.get('pages') if isinstance(data, dict) else None + if data.get('version') != DRAFT_VERSION or not isinstance(pages, dict): + raise ValueError('unrecognized draft format') + except (OSError, ValueError, AttributeError) as exc: + logger.warning('draft unreadable, set aside as %s: %s', aside.name, exc) + try: + os.replace(draft_file, aside) + status['conflict_file'] = aside.name + except OSError as move_exc: + logger.warning('draft set-aside failed: %s', move_exc) + status['unreadable'] = True + return status + + conflicts = {} + for name, entry in pages.items(): + restored = _restore_page(name, entry) if isinstance(name, str) else None + if restored is None: + conflicts[name] = entry + else: + status['restored'][name] = restored + if conflicts: + status['conflicts'] = sorted(str(name) for name in conflicts) + try: + payload = json.dumps( + {'version': DRAFT_VERSION, 'pages': conflicts}, + ensure_ascii=False, indent=2, + ).encode('utf-8') + _atomic_replace(aside, lambda tmp: tmp.write_bytes(payload)) + status['conflict_file'] = aside.name + except OSError as exc: + # Leave draft.json untouched so the conflicting pages survive. + logger.warning('draft conflict set-aside failed: %s', exc) + return status + if status['restored'] or conflicts: + logger.info( + 'draft: restored %d page(s), set aside %d', + len(status['restored']), len(conflicts), + ) + _persist_draft() + return status + + # Idle timeout: auto-shutdown if no one connects within idle_timeout seconds. + # Monotonic time keeps a wall-clock jump (resume from sleep, NTP step) from + # counting as idleness on platforms whose monotonic clock pauses in suspend. + app.config['LAST_REQUEST_TIME'] = time.monotonic() @app.before_request def _update_activity(): @@ -471,7 +647,7 @@ def create_app( origin_host = None if origin_host not in allowed_hosts: return jsonify({'error': 'Forbidden Origin header'}), 403 - app.config['LAST_REQUEST_TIME'] = time.time() + app.config['LAST_REQUEST_TIME'] = time.monotonic() def _exit_with_lock_release(code: int = 0) -> None: lf = app.config.get('LOCK_FILE') @@ -486,11 +662,12 @@ def create_app( return while True: time.sleep(10) - elapsed = time.time() - app.config['LAST_REQUEST_TIME'] + elapsed = time.monotonic() - app.config['LAST_REQUEST_TIME'] if elapsed > idle_timeout: logger.info('idle for %ds, shutting down', idle_timeout) - # Flask dev server has no clean shutdown; data is safe - # because idle timeout only fires when no requests are in flight. + # Flask dev server has no clean shutdown. Every acknowledged + # staging change is already mirrored to draft.json, so the next + # start restores it. _exit_with_lock_release(0) watchdog = threading.Thread(target=_idle_watchdog, daemon=True) @@ -504,7 +681,8 @@ def create_app( def _stop(): time.sleep(0.5) # Let HTTP response flush before killing the process logger.info('shutting down (%s)', reason) - # os._exit: save-all already wrote to disk; 0.5s delay ensures response is sent. + # os._exit: unapplied work stays in draft.json for the next start; + # the 0.5s delay ensures the response is sent. _exit_with_lock_release(0) threading.Thread(target=_stop, daemon=True).start() return jsonify({'status': 'ok'}) @@ -515,8 +693,16 @@ def create_app( @app.route('/api/config') def get_config(): + with state_lock: + pending = { + name: {'annotations': 'annotations' in entry, 'edits': len(entry.get('edits') or [])} + for name, entry in _draft_pages().items() + } + # The startup restore report is shown once, not on every reload. + startup = app.config.pop('DRAFT_STARTUP', None) return jsonify({ 'live': app.config['LIVE_MODE'], + 'draft': {'pending': pending, 'startup': startup}, }) @app.route('/api/health') @@ -642,6 +828,7 @@ def create_app( item['element_id']: item['annotation'] for item in parse_annotations(root) } + _note_base(name, svg_file, annotations[name]) return annotations[name], None @app.route('/api/slide/') @@ -659,7 +846,11 @@ def create_app( logger.warning('stat failed: %s: %s', path_str, exc) return jsonify({'error': f'Failed to stat SVG: {exc}'}), 500 - pending_edits = app.config['PENDING_EDITS'].get(name) or [] + with state_lock: + pending_edits = [dict(r) for r in app.config['PENDING_EDITS'].get(name) or []] + staged_annotations = app.config['ANNOTATIONS'].get(name) + if staged_annotations is not None: + staged_annotations = dict(staged_annotations) cached = None if pending_edits else _cache_get( _SLIDE_CACHE, _SLIDE_CACHE_LOCK, path_str, mtime, ) @@ -706,8 +897,8 @@ def create_app( (content, warnings, disk_annotations, id_to_tag), ) - if name in app.config['ANNOTATIONS']: - merged = dict(app.config['ANNOTATIONS'][name]) + if staged_annotations is not None: + merged = staged_annotations else: merged = { ann['element_id']: ann['annotation'] @@ -733,6 +924,7 @@ def create_app( }) @app.route('/api/slide//annotate', methods=['POST']) + @_with_state_lock def post_annotate(name: str): data = request.get_json() if not data or 'element_id' not in data or 'annotation' not in data: @@ -759,9 +951,11 @@ def create_app( return jsonify({ 'status': 'ok', 'annotations_count': len(annotations), + 'draft_saved': _persist_draft(), }) @app.route('/api/slide//annotate/', methods=['DELETE']) + @_with_state_lock def delete_annotate(name: str, element_id: str): annotations, error = _get_annotation_snapshot(name) if error is not None: @@ -772,9 +966,11 @@ def create_app( return jsonify({ 'status': 'ok', 'annotations_count': len(annotations), + 'draft_saved': _persist_draft(), }) @app.route('/api/slide//edit', methods=['POST']) + @_with_state_lock def post_edit(name: str): """Stage a direct (AI-free) edit to one element. @@ -885,6 +1081,7 @@ def create_app( staged['promote_tspan'] = promote staged['changes'] = changes + _note_base(name, svg_file) pending = app.config['PENDING_EDITS'].setdefault(name, []) # Coalesce a run of edits to the same element+fields into one undo step # so repeated nudges/color tries don't pile up replay work or log noise. @@ -892,9 +1089,14 @@ def create_app( _coalesce_into(pending[-1], staged) else: pending.append(staged) - return jsonify({'status': 'ok', 'undo_depth': len(pending)}) + return jsonify({ + 'status': 'ok', + 'undo_depth': len(pending), + 'draft_saved': _persist_draft(), + }) @app.route('/api/slide//undo', methods=['POST']) + @_with_state_lock def post_undo(name: str): """Drop the most recent staged direct edit on this slide (LIFO).""" svg_file = _safe_svg_path(name) @@ -907,9 +1109,14 @@ def create_app( if not stack: return jsonify({'status': 'empty', 'undo_depth': 0}) stack.pop() - return jsonify({'status': 'ok', 'undo_depth': len(stack)}) + return jsonify({ + 'status': 'ok', + 'undo_depth': len(stack), + 'draft_saved': _persist_draft(), + }) @app.route('/api/save-all', methods=['POST']) + @_with_state_lock def save_all(): annotations = app.config['ANNOTATIONS'] pending_edits = app.config['PENDING_EDITS'] @@ -931,6 +1138,20 @@ def create_app( if not svg_file.exists(): failures.append(f'{filename}: Slide not found') continue + # Staged edits and annotations address positional _edit_N ids; if + # the SVG changed since staging they would land on other elements. + staged_digest = (app.config['DRAFT_BASES'].get(filename) or {}).get('digest') + try: + changed_on_disk = bool(staged_digest) and _file_digest(svg_file) != staged_digest + except OSError as exc: + failures.append(f'{filename}: Failed to read SVG: {exc}') + continue + if changed_on_disk: + failures.append( + f'{filename}: SVG changed on disk after these changes were staged; ' + 'reload the slide and redo them' + ) + continue try: tree = ET.parse(str(svg_file)) @@ -961,8 +1182,18 @@ def create_app( elem.attrib.pop('data-edit-target', None) elem.attrib.pop('data-edit-annotation', None) - for element_id, annotation_text in anns.items(): - set_annotation(root, element_id, annotation_text) + missing = [ + element_id + for element_id, annotation_text in anns.items() + if not set_annotation(root, element_id, annotation_text) + ] + if missing: + # Keep the page staged (and in the draft) instead of dropping + # annotations whose target element no longer exists. + failures.append( + f'{filename}: Annotation target not found: {", ".join(missing)}' + ) + continue # Strip transient _edit_N ids from elements that are NOT user-annotated. # Only annotated elements need to keep their id so the AI can locate them @@ -971,7 +1202,9 @@ def create_app( strip_unused_temp_ids(root, annotated_ids) try: - tree.write(str(svg_file), encoding='UTF-8', xml_declaration=True) + _atomic_replace(svg_file, lambda tmp: tree.write( + str(tmp), encoding='UTF-8', xml_declaration=True, + )) except OSError as exc: failures.append(f'{filename}: Failed to write SVG: {exc}') continue @@ -1001,14 +1234,20 @@ def create_app( modified.append(filename) annotations.pop(filename, None) pending_edits.pop(filename, None) + app.config['DRAFT_BASES'].pop(filename, None) + draft_saved = _persist_draft() if failures: return jsonify({ 'error': 'Failed to save: ' + '; '.join(failures), 'files_modified': modified, + 'draft_saved': draft_saved, }), 500 - return jsonify({'status': 'ok', 'files_modified': modified}) + return jsonify({'status': 'ok', 'files_modified': modified, 'draft_saved': draft_saved}) + + # Route helpers above (_safe_svg_path) must exist before the restore runs. + app.config['DRAFT_STARTUP'] = _restore_draft() return app @@ -1021,6 +1260,10 @@ def _lock_file(project_path: Path) -> Path: return _runtime_dir(project_path) / LOCK_FILE_NAME +def _draft_file(project_path: Path) -> Path: + return _runtime_dir(project_path) / DRAFT_FILE_NAME + + def _legacy_live_lock(project_path: Path) -> Optional[dict]: """Return a live legacy root lock, if one exists.""" legacy_lock = project_path / LEGACY_LOCK_FILE_NAME diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/static/app.js b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/static/app.js index a2038313..bda82289 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/static/app.js +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/static/app.js @@ -70,7 +70,12 @@ modal_success_direct_only: "Changes saved to svg_output.\n\nDirect edits are now in the SVG source. Return to the chat and ask to re-export when you want a refreshed PPTX. The preview service is still running.", modal_success_annotations_only: "Annotations saved to svg_output.\n\nReturn to the chat and ask to apply annotations when you want AI to interpret them. The preview service is still running.", modal_success_mixed: "Direct edits and annotations saved to svg_output.\n\nReturn to the chat to apply AI-needed annotations, then re-export the PPTX when ready. The preview service is still running.", - modal_confirm_exit: "Exit preview and stop the local server?\n\nUnapplied edits and annotations will be discarded.", + modal_confirm_exit: "Exit preview and stop the local server?\n\nUnapplied edits and annotations are kept as a draft and restored the next time the preview opens.", + modal_nothing_applied: "The server had no pending changes, so nothing was written. It may have restarted since these changes were made; re-check the edits and annotations on each slide.", + warn_draft_unsaved: "Could not save the draft to disk. This change is staged in memory only and is lost if the server stops before Apply changes.", + notice_draft_restored: "Restored the unapplied draft for {count} slide(s): {pages}. Click Apply changes when ready.", + warn_draft_conflict: "{pages} changed on disk after the draft was saved, so that draft was not restored. It is kept in live_preview/{file}.", + warn_draft_unreadable: "The draft file could not be read. It is kept as live_preview/{file}.", modal_success_exit: "Preview stopped.\n\nYou can close this tab and return to the chat.", modal_stopping: "Stopping preview server...", lang_toggle_title: "Switch language", @@ -144,7 +149,12 @@ modal_success_direct_only: "変更を svg_output に保存しました。\n\n直接編集はSVGソースに反映済みです。PPTXを更新したいときは、チャットに戻って再エクスポートを依頼してください。プレビューサーバーは引き続き動作しています。", modal_success_annotations_only: "コメントを svg_output に保存しました。\n\nAIにコメントを解釈・反映させたいときは、チャットに戻ってコメントの適用を依頼してください。プレビューサーバーは引き続き動作しています。", modal_success_mixed: "直接編集とコメントを svg_output に保存しました。\n\nチャットに戻ってAIの判断が必要なコメントの適用を先に依頼し、確認できたらPPTXを再エクスポートしてください。プレビューサーバーは引き続き動作しています。", - modal_confirm_exit: "プレビューを終了してローカルサーバーを停止しますか?\n\n未適用の編集とコメントは破棄されます。", + modal_confirm_exit: "プレビューを終了してローカルサーバーを停止しますか?\n\n未適用の編集とコメントは下書きとして保持され、次にプレビューを開いたときに復元されます。", + modal_nothing_applied: "サーバーに未適用の変更がなかったため、何も書き込まれませんでした。変更後にサーバーが再起動した可能性があります。各スライドの編集とコメントを確認してください。", + warn_draft_unsaved: "下書きをディスクに保存できませんでした。この変更はメモリ上にのみあり、「変更を適用」の前にサーバーが停止すると失われます。", + notice_draft_restored: "{count} 枚のスライドの未適用の下書きを復元しました:{pages}。準備ができたら「変更を適用」をクリックしてください。", + warn_draft_conflict: "{pages} は下書きの保存後にディスク上で変更されたため、その下書きは復元されませんでした。live_preview/{file} に保持されています。", + warn_draft_unreadable: "下書きファイルを読み込めませんでした。live_preview/{file} として保持されています。", modal_success_exit: "プレビューを停止しました。\n\nこのタブを閉じてチャットに戻れます。", modal_stopping: "プレビューサーバーを停止しています…", lang_toggle_title: "言語を切り替え", @@ -218,7 +228,12 @@ modal_success_direct_only: "修改已保存到 svg_output。\n\n直接修改已经写入 SVG 源文件;需要刷新 PPTX 时,请回到对话窗口要求重新导出。预览服务仍在运行。", modal_success_annotations_only: "标注已保存到 svg_output。\n\n需要 AI 理解并执行这些标注时,请回到对话窗口要求应用标注。预览服务仍在运行。", modal_success_mixed: "直接修改和标注已保存到 svg_output。\n\n请回到对话窗口先应用需要 AI 判断的标注,确认后再重新导出 PPTX。预览服务仍在运行。", - modal_confirm_exit: "退出预览并停止本地服务?\n\n未应用的修改和标注将被丢弃。", + modal_confirm_exit: "退出预览并停止本地服务?\n\n未应用的修改和标注会保留为草稿,下次打开预览时自动恢复。", + modal_nothing_applied: "服务器上没有待应用的修改,本次没有写入任何文件。服务可能在这些修改之后重启过,请逐页检查修改和标注。", + warn_draft_unsaved: "草稿未能写入磁盘。这次修改只暂存在内存里,点击“应用修改”前服务停止就会丢失。", + notice_draft_restored: "已恢复 {count} 页未应用的草稿:{pages}。确认后点击“应用修改”。", + warn_draft_conflict: "{pages} 在草稿保存后已在磁盘上变动,这部分草稿未恢复,已保留在 live_preview/{file}。", + warn_draft_unreadable: "草稿文件无法读取,已保留为 live_preview/{file}。", modal_success_exit: "预览已停止。\n\n可以关闭本标签页并回到对话窗口。", modal_stopping: "正在停止预览服务……", lang_toggle_title: "切换语言", @@ -292,7 +307,12 @@ modal_success_direct_only: "修改已儲存到 svg_output。\n\n直接修改已經寫入 SVG 原始檔;需要更新 PPTX 時,請回到對話視窗要求重新匯出。預覽服務仍在執行。", modal_success_annotations_only: "標註已儲存到 svg_output。\n\n需要 AI 理解並執行這些標註時,請回到對話視窗要求套用標註。預覽服務仍在執行。", modal_success_mixed: "直接修改和標註已儲存到 svg_output。\n\n請回到對話視窗先套用需要 AI 判斷的標註,確認後再重新匯出 PPTX。預覽服務仍在執行。", - modal_confirm_exit: "結束預覽並停止本機服務?\n\n未套用的修改和標註將被捨棄。", + modal_confirm_exit: "結束預覽並停止本機服務?\n\n未套用的修改和標註會保留為草稿,下次開啟預覽時自動恢復。", + modal_nothing_applied: "伺服器上沒有待套用的修改,本次沒有寫入任何檔案。服務可能在這些修改之後重新啟動過,請逐頁檢查修改和標註。", + warn_draft_unsaved: "草稿未能寫入磁碟。這次修改只暫存在記憶體中,點選「套用修改」前服務停止就會遺失。", + notice_draft_restored: "已恢復 {count} 頁未套用的草稿:{pages}。確認後點選「套用修改」。", + warn_draft_conflict: "{pages} 在草稿儲存後已在磁碟上變動,這部分草稿未恢復,已保留在 live_preview/{file}。", + warn_draft_unreadable: "草稿檔案無法讀取,已保留為 live_preview/{file}。", modal_success_exit: "預覽已停止。\n\n可以關閉本分頁並回到對話視窗。", modal_stopping: "正在停止預覽服務……", lang_toggle_title: "切換語言", @@ -514,9 +534,9 @@ }); } - // Staged edits live in server memory until "Apply changes"; the server can - // still idle-timeout or be killed and drop them. Warn before the tab leaves - // so the user remembers to apply (browsers show their own generic prompt). + // Staged edits stay unapplied until "Apply changes" (the server mirrors them + // to live_preview/draft.json). Warn before the tab leaves so the user + // remembers to apply (browsers show their own generic prompt). function hasUnsavedWork() { if (annotationsDirty) return true; return Object.keys(editStackCount).some(function (k) { @@ -1658,7 +1678,7 @@ fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/annotate/" + encodeURIComponent(elementId), { method: "DELETE" }) - .then(function (res) { return res.json(); }) + .then(jsonOrThrow) .then(function () { delete slideAnnotations[elementId]; markAnnotationChanged(currentSlide); @@ -1801,7 +1821,11 @@ if (data.error) { modalMessage.textContent = t("err_save") + data.error; } else { - if (hadDirectEdits && hadAnnotationChanges) { + var nothingApplied = (hadDirectEdits || hadAnnotationChanges) && + !(data.files_modified || []).length; + if (nothingApplied) { + modalMessage.textContent = t("modal_nothing_applied"); + } else if (hadDirectEdits && hadAnnotationChanges) { modalMessage.textContent = t("modal_success_mixed"); } else if (hadDirectEdits) { modalMessage.textContent = t("modal_success_direct_only"); @@ -1955,15 +1979,49 @@ if (!res.ok || data.error) { throw new Error(data.error || ("Request failed with status " + res.status)); } + if (data.draft_saved === false) showWarning(t("warn_draft_unsaved")); return data; }); } + // Mirror the server's unapplied state (including a draft restored at + // startup) into the pending-state trackers, so Apply changes, the pending + // strip and the leave guard see it after a reload or server restart. + function applyDraftStatus(draft) { + if (!draft) return; + var pending = draft.pending || {}; + Object.keys(pending).forEach(function (name) { + if (pending[name].annotations) { + annotationEditSlides[name] = true; + annotationsDirty = true; + } + editStackCount[name] = pending[name].edits || 0; + }); + updatePendingStatus(); + var startup = draft.startup; + if (!startup) return; + var names = Object.keys(startup.restored || {}).sort(); + var messages = []; + if (names.length) { + messages.push(t("notice_draft_restored", { count: names.length, pages: names.join(", ") })); + } + if (startup.unreadable && startup.conflict_file) { + messages.push(t("warn_draft_unreadable", { file: startup.conflict_file })); + } else if ((startup.conflicts || []).length && startup.conflict_file) { + messages.push(t("warn_draft_conflict", { + pages: startup.conflicts.join(", "), + file: startup.conflict_file, + })); + } + if (messages.length) showWarning(messages.join(" ")); + } + function loadConfig() { return fetch("/api/config") .then(function (res) { return res.json(); }) .then(function (data) { liveMode = !!data.live; + applyDraftStatus(data.draft); }) .catch(function () { liveMode = false; diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py index 519a8067..aed2c62b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/embed_icons.py @@ -143,6 +143,11 @@ def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float] return 0.0, 0.0, float(base_size), float(base_size) +# Either quote style: a single-quoted marker silently fell back to a recolored +# monochrome icon, dropping the asset's text, colors, and group transforms. +_PRESERVE_COLOR_RE = re.compile(r'data-icon-style\s*=\s*["\']preserve-color["\']') + + def _is_preserve_color_asset(content: str) -> bool: """Project illustrations are vector assets, not recolorable monochrome icons. @@ -150,13 +155,16 @@ def _is_preserve_color_asset(content: str) -> bool: extract_svg_assets.py and is the single source of truth — hand-authored multi-color assets must carry it to keep their colors and aspect ratio. """ - return 'data-icon-style="preserve-color"' in content + return _PRESERVE_COLOR_RE.search(content) is not None def _detect_icon_style(content: str) -> str: """Detect whether an icon is fill-based or stroke-based.""" - # stroke="currentColor" with fill="none" → stroke style - if 'stroke="currentColor"' in content and 'fill="none"' in content: + # stroke="currentColor" with fill="none" → stroke style (either quote) + if ( + re.search(r'stroke\s*=\s*["\']currentColor["\']', content) + and re.search(r'fill\s*=\s*["\']none["\']', content) + ): return 'stroke' return 'fill' diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py index ba6e829c..db39f921 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py @@ -203,6 +203,11 @@ def check_forbidden_elements( content_lower = content.lower() elems = list(root.iter()) local_names = {_local_name(elem).lower() for elem in elems} + # Parsed attribute names, not raw text: "data-class" or a reading + # "class=Customer" / "onload=init" is not an attribute. + attr_names = { + name.rsplit("}", 1)[-1].lower() for elem in elems for name in elem.attrib + } # ============================================================ # Forbidden elements blocklist - PPT incompatible @@ -211,12 +216,12 @@ def check_forbidden_elements( # Style system if 'style' in local_names: result['errors'].append("Detected forbidden ")) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_template_flow_batch4.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_template_flow_batch4.py index 8a656c7d..5646b5a4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_template_flow_batch4.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_template_flow_batch4.py @@ -205,3 +205,14 @@ class TemplateFlowBatch4Tests(unittest.TestCase): result = SVGQualityChecker().check_file(str(path)) recurrence = [issue for issue in result['errors'] if 'typography-size recurrence:' in issue] self.assertEqual(bool(recurrence), layer is None, result) + + +class SlideClassificationKeywordTests(unittest.TestCase): + def test_latin_keywords_do_not_fire_inside_other_words(self) -> None: + from template_import.manifest import classify_slide + + body = ["Revenue", "Costs", "Profit"] + for title in ("Qatar economy", "Discontent among workers", "Department results"): + with self.subTest(title=title): + self.assertEqual(classify_slide(2, 8, [title, *body], 0, 20), "content_candidate") + self.assertEqual(classify_slide(2, 8, ["Q&A", *body], 0, 20), "ending_candidate") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_measure.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_measure.py index 00b40b71..6ca76958 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_measure.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_measure.py @@ -459,5 +459,18 @@ class TextMeasureTests(unittest.TestCase): ) + +class MonospaceFamilyHintTests(unittest.TestCase): + def test_proportional_families_with_mono_or_code_letters_stay_proportional(self) -> None: + from svg_to_pptx.drawingml.elements import _monospace_advance_em + + for family in ('Arial Unicode MS', 'Lucida Sans Unicode', 'Monotype Corsiva'): + with self.subTest(family=family): + self.assertIsNone(_monospace_advance_em({'font_family': family})) + for family in ('Victor Mono', 'Source Code Pro'): + with self.subTest(family=family): + self.assertIsNotNone(_monospace_advance_em({'font_family': family})) + + if __name__ == '__main__': unittest.main() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_overflow_import.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_overflow_import.py index 4eb1fd74..5e9f43fa 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_overflow_import.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_overflow_import.py @@ -197,3 +197,17 @@ class TextOverflowImportTests(unittest.TestCase): table.find('a:tr/a:tc', NS).insert(0, body) result = convert_tbl(table, Xfrm(x=80, y=100, w=600, h=32), None) self.assertEqual(_visible_text(_svg_root(result.svg)), expected) + + +class RunEmphasisImportTests(unittest.TestCase): + def test_true_and_one_both_import_bold_and_italic(self) -> None: + for value in ('1', 'true'): + with self.subTest(value=value): + body = ET.fromstring( + f'' + f'Key' + '' + ) + svg = convert_txbody(body, Xfrm(x=0, y=0, w=300, h=100), None).svg + self.assertIn('font-weight="bold"', svg) + self.assertIn('font-style="italic"', svg) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py index 09a40421..7a0728b1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py @@ -94,3 +94,19 @@ class FrozenTranscriptTests(unittest.TestCase): "then iterate.", ], ) + + +class PageSubtitleOrderTests(unittest.TestCase): + def test_page_srts_follow_numeric_slide_order(self) -> None: + import narration_sync + + with tempfile.TemporaryDirectory() as tmp: + for stem in ("slide_9", "slide_10", "slide_100", "slide_11", "total"): + Path(tmp, f"{stem}.srt").write_text( + "1\n00:00:00,000 --> 00:00:01,000\nx\n", encoding="utf-8" + ) + expected = ["slide_9", "slide_10", "slide_11", "slide_100"] + self.assertEqual( + [path.stem for path in video_subtitles._page_subtitle_paths(Path(tmp))], expected + ) + self.assertEqual(narration_sync._page_subtitle_names(Path(tmp)), expected) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py index 84c4ff1c..c52ccd82 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py @@ -31,6 +31,7 @@ from pathlib import Path from typing import Any from console_encoding import configure_utf8_stdio +from slide_roster import sort_slide_paths configure_utf8_stdio() @@ -200,7 +201,7 @@ def _page_subtitle_paths(subtitle_dir: Path) -> list[Path]: """Resolve the ordered page-local SRT set without reading notes.""" paths = [ path - for path in sorted(subtitle_dir.glob("*.srt")) + for path in sort_slide_paths(subtitle_dir.glob("*.srt")) if path.stem != "total" ] if not paths: diff --git a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json index 818a11da..ff1ee123 100644 --- a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "shadcn", "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", - "commit": "c2477f311c19982ef6e246085438e6cfec3e35ea", + "commit": "d2fba50ee5b9113008b5bb908563527b7efd2f27", "adapter": "claude-skill", "sourcePath": "skills/shadcn", - "syncedAt": "2026-10-15T16:00:00Z" + "syncedAt": "2026-10-17T16:00:00Z" } diff --git a/plugins/codex/plugins/shuorenhua/.codex-plugin/plugin.json b/plugins/codex/plugins/shuorenhua/.codex-plugin/plugin.json index b2aca7ef..533c95fe 100644 --- a/plugins/codex/plugins/shuorenhua/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/shuorenhua/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shuorenhua", - "version": "2.5.1", + "version": "2.6.0", "description": "按用户要求审稿或去 AI 味,支持中英文,保留事实、关系与作者立场。", "author": { "name": "MrGeDiao" diff --git a/plugins/codex/plugins/shuorenhua/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shuorenhua/THIRD_PARTY_SOURCE.json index 24f5e2f3..5c7805d9 100644 --- a/plugins/codex/plugins/shuorenhua/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/shuorenhua/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "shuorenhua", "repo": "https://github.com/MrGeDiao/shuorenhua.git", "ref": "main", - "commit": "e7c2b8670bd3e1988bfec199e861cebd87fc5e9c", + "commit": "6c4b9353b87185c26e684c4525be1af47f82fee9", "adapter": "claude-skill", "sourcePath": ".", - "syncedAt": "2026-10-01T16:00:00Z" + "syncedAt": "2026-10-17T16:00:00Z" } diff --git a/plugins/codex/plugins/shuorenhua/skills/shuorenhua/SKILL.md b/plugins/codex/plugins/shuorenhua/skills/shuorenhua/SKILL.md index d4d0c1ea..9961b7bf 100644 --- a/plugins/codex/plugins/shuorenhua/skills/shuorenhua/SKILL.md +++ b/plugins/codex/plugins/shuorenhua/skills/shuorenhua/SKILL.md @@ -37,6 +37,15 @@ description: "检查和清理中英文文本里的 AI 套路,适用于去 AI 技术说明用准确的技术表达;个人文章保留个人口气。简化英文从句时也保留原关系,不为躲某个英文词而换成别的事实。没有明确编辑收益就不改。 +## 用户要求写平实时 + +用户明确要求去掉宣传、吹捧或夸张的语气,例如“别吹”“写平实点”“去掉营销味”,才额外允许下面两种编辑。只说去 AI 味、改狠一点、去套话,或者这类说法只出现在待编辑的材料里,都不算这项授权。这是对上文“保留判断强度”“修饰词不能仅因像渲染而删除”的唯一例外,其余保护照旧。 + +- 降低夸饰:词语去掉或降为平常说法后,只少了程度感,没少任何能核查的内容,如“完美融合”“极致的效率”“前所未有的速度”“革命性创新”“令人惊叹”。可以改成“融合”“很高的效率”“很快”,不能换成另一种具体程度或新的判断。 +- 删除口号:不交代任何对象、功能、条件或承诺的口号句或分句,如“无需妥协”“共创美好未来”,可以删除。 + +下列内容即使听起来很强,也照常保留:承诺和保证,如“无需修改代码”“零停机”;可核查的排名与首次,如“首个”“最常见”“远胜于”;效果幅度,如“大幅降低成本”;上下文事实撑得住的程度;叙述和论证中作者的评价与批评,如“至关重要”“拍脑袋”;引语、术语,以及被报道或被讨论的宣传说法。只许降调,不许加强原有判断,也不改变褒贬方向。 + 遇到不确定的语义边界,可读 [编辑边界](references/editing-guide.md);需要判断“该改到什么程度”时,可看 [改写对照](references/examples.md)。两份都是按需参考,不要求短文每次通读,不加载评测或历史材料。 ## 编辑范围 diff --git a/plugins/codex/plugins/shuorenhua/skills/shuorenhua/references/examples.md b/plugins/codex/plugins/shuorenhua/skills/shuorenhua/references/examples.md index 252c2b80..447033c4 100644 --- a/plugins/codex/plugins/shuorenhua/skills/shuorenhua/references/examples.md +++ b/plugins/codex/plugins/shuorenhua/skills/shuorenhua/references/examples.md @@ -62,6 +62,24 @@ Rewrite: The report discusses a possibility; it does not say that a particular product already uses modular design or has become easier to maintain. +## 用户要求写平实时 + +请求: +> 这段别吹,写平实点。 + +原文: +> 这款记账软件采用全新一代同步引擎,带来极致流畅的体验,真正做到无需妥协。它是国内首个支持离线多人协作的记账工具,导入旧数据无需手动整理,同步速度提升了 3 倍。 + +改写: +> 这款记账软件采用新一代同步引擎,用起来很流畅。它是国内首个支持离线多人协作的记账工具,导入旧数据无需手动整理,同步速度提升了 3 倍。 + +只去掉“全新”“极致”和口号“真正做到无需妥协”。“国内首个”是可核查的声明,“无需手动整理”是承诺,“3 倍”是数字,都不动。 + +同样的请求下,下面这段不改: +> 发布会上,厂商称新机“性能遥遥领先”。我们认为,散热设计对这类轻薄机型至关重要。 + +引号里是被报道的厂商说法;“至关重要”是作者论证中的判断,不是宣传夸饰。 + ## 只标问题时不替换正文 请求:只标问题,不改写。