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 committed 2026-10-18 00:02:41 +08:00
1 parent 3b56fa43b1
commit 6056566972
46 files changed
+1171 -248

No files matched your search

+10 -10
View File
@@ -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"
}
]
}
@@ -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"
}
@@ -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 <!-- nocompress --> 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 <!-- nocompress --> 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 <!-- nocompress --> 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 <!-- nocompress --> block.")
print(" Skipping this attempt.")
continue
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-10-15T16:02:39Z"
"syncedAt": "2026-10-17T16:02:40Z"
}
@@ -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"
}
@@ -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"
}
@@ -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
@@ -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
`<working folder>/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 `<name>_<YYYYMMDD>`; a name that already
@@ -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 <seconds>` 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 `<project_path>/.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_<timestamp>.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 `<use data-icon>` 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). `<g>` 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
@@ -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(
@@ -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"(?<![a-z0-9])(?:{forms})(?![a-z0-9])", text) is not None
return term in text or compact_term in text.replace(" ", "")
def _term_group_alternatives(term_group: str) -> 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"(?<![a-z])(?:stations?|subways?|metro|rail(?:way|road)?s?|transit"
r"|airports?|bus(?:es)?)(?![a-z])|地铁|车站|轨道"
)
def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> 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()
@@ -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:
@@ -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"
]
@@ -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")
@@ -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 <stem>_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():
@@ -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"))
@@ -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")
@@ -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
@@ -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 "<html" not in head and "<body" not in head and "<!doctype html" not in head
# An HTML page starts as one; a README whose code fence shows "<body>" or
# "<html>" further down is still Markdown.
start = body[:4096].lstrip("\ufeff \t\r\n").lower()
return not start.startswith(("<!doctype html", "<html", "<head", "<body"))
def _save_plain_text_document(
@@ -1381,6 +1384,10 @@ def _meta_refresh_target(soup: BeautifulSoup, base_url: str) -> 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)
@@ -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)
@@ -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/<name>')
@@ -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/<name>/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/<name>/annotate/<element_id>', 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/<name>/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/<name>/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
@@ -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;
@@ -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'
@@ -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 <text> 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 <style> element (use inline attributes instead)")
if re.search(r'\bclass\s*=', content):
if 'class' in attr_names:
result['errors'].append("Detected forbidden class attribute (use inline styles instead)")
# id attribute: only report error when <style> also exists (id is harmful only with CSS selectors)
# id inside <defs> for linearGradient/filter etc. is required, Inkscape also auto-adds id to elements,
# standalone id attributes have no impact on PPT export
if 'style' in local_names and re.search(r'\bid\s*=', content):
if 'style' in local_names and 'id' in attr_names:
result['errors'].append(
"Detected id attribute used with <style> (CSS selectors forbidden, use inline styles instead)"
)
@@ -224,7 +229,12 @@ def check_forbidden_elements(
result['errors'].append("Detected forbidden xml-stylesheet (external CSS references forbidden)")
if re.search(r'<link[^>]*rel\s*=\s*["\']stylesheet["\']', content_lower):
result['errors'].append("Detected forbidden <link rel=\"stylesheet\"> (external CSS references forbidden)")
if re.search(r'@import\s+', content_lower):
# CSS only: a code slide whose visible text shows "@import url(...)" is fine.
css_text = " ".join(
"".join(elem.itertext()) if _local_name(elem).lower() == "style" else elem.get("style", "")
for elem in elems
)
if re.search(r'@import\s+', css_text.lower()):
result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
if _validate_inline_geometry_properties is None:
result['warnings'].append(
@@ -271,7 +281,7 @@ def check_forbidden_elements(
result['errors'].append("Detected forbidden SMIL animation element <set> (SVG animations are not exported)")
if 'script' in local_names:
result['errors'].append("Detected forbidden <script> element (scripts and event handlers forbidden)")
if re.search(r'\bon\w+\s*=', content): # onclick, onload etc.
if any(re.fullmatch(r'on\w+', name) for name in attr_names): # onclick, onload etc.
result['errors'].append("Detected forbidden event attributes (e.g., onclick, onload)")
# Other discouraged elements
@@ -2282,7 +2282,8 @@ def _run_primary_family(run: dict[str, Any]) -> str:
# Fixed-pitch faces outside the table almost always say so in their name
# (Cascadia Mono, Fira Code, Victor Mono, Noto Sans Mono CJK); 0.60 em is the
# common advance of the Courier-derived and modern coding families alike.
_MONOSPACE_NAME_HINTS = ('mono', 'code', 'courier', 'consol', 'typewriter')
# "Arial Unicode MS" and "Monotype Corsiva" are proportional despite the letters.
_MONOSPACE_NAME_HINT_RE = re.compile(r'mono(?!type)|(?<!uni)code|courier|consol|typewriter')
_MONOSPACE_DEFAULT_ADVANCE_EM = 0.60
@@ -2294,7 +2295,7 @@ def _monospace_advance_em(run: dict[str, Any]) -> float | None:
advance = _MONOSPACE_ADVANCE_EM.get(family)
if advance is not None:
return advance
if any(hint in family for hint in _MONOSPACE_NAME_HINTS):
if _MONOSPACE_NAME_HINT_RE.search(family):
return _MONOSPACE_DEFAULT_ADVANCE_EM
return None
@@ -28,6 +28,8 @@ from pathlib import Path, PurePosixPath
from typing import Any
from xml.etree import ElementTree as ET
# Latin keywords match whole words ("qa" must not fire on "Qatar").
from pptx_ooxml.analyzer import _has_keyword
from pptx_to_svg.ooxml_loader import (
blip_embed_relationship_ids,
parse_ooxml_boolean,
@@ -404,11 +406,11 @@ def count_slide_shapes(root: ET.Element | None) -> int:
def classify_slide(index: int, total: int, texts: list[str], image_count: int, shape_count: int) -> str:
joined = " ".join(texts).lower()
if any(keyword in joined for keyword in THANKS_KEYWORDS):
if _has_keyword(joined, THANKS_KEYWORDS):
return "ending_candidate"
if any(keyword in joined for keyword in TOC_KEYWORDS):
if _has_keyword(joined, TOC_KEYWORDS):
return "toc_candidate"
if any(keyword in joined for keyword in CHAPTER_KEYWORDS):
if _has_keyword(joined, CHAPTER_KEYWORDS):
return "chapter_candidate"
if index == 1 and image_count <= 3:
return "cover_candidate"
@@ -633,6 +633,47 @@ class SameStemProjectImportTests(unittest.TestCase):
self.assertIn("22500", Path(summary["markdown"][0]).read_text(encoding="utf-8"))
class TextSourceEncodingTests(unittest.TestCase):
def test_gb18030_text_source_is_decoded_not_replaced(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manager = project_cli.ProjectManager(base_dir=root / "projects")
with contextlib.redirect_stdout(io.StringIO()):
project = Path(manager.init_project("target", quick_generate=True))
source = root / "report.txt"
source.write_bytes("收入增长和利润说明".encode("gb18030"))
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
result = manager.import_sources(str(project), [str(source)], copy=True)
text = Path(result["markdown"][0]).read_text(encoding="utf-8")
self.assertIn("收入增长和利润说明", text)
class MarkdownDuplicateAssetTests(unittest.TestCase):
def test_same_text_with_different_companion_assets_is_not_a_duplicate(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manager = project_cli.ProjectManager(base_dir=root / "projects")
with contextlib.redirect_stdout(io.StringIO()):
project = Path(manager.init_project("deck", quick_generate=True))
def export(folder: str, stem: str, revenue: int) -> Path:
directory = root / folder
(directory / f"{stem}_files").mkdir(parents=True)
(directory / f"{stem}_files" / "chart.svg").write_text(
f"<svg><text>Revenue {revenue}</text></svg>", encoding="utf-8")
source = directory / f"{stem}.md"
source.write_text(f"# R\n\n![chart]({stem}_files/chart.svg)\n", encoding="utf-8")
return source
imports = [export("a", "old", 10), export("b", "new", 20), export("c", "same", 10)]
results = []
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
for source in imports:
results.append(manager.import_sources(str(project), [str(source)], copy=True))
self.assertEqual([Path(item).name for item in results[1]["markdown"]], ["new.md"])
self.assertEqual([Path(item).name for item in results[2]["markdown"]], ["old.md"])
class _CurlHeaders(dict):
"""Represent repeated curl headers without importing optional curl_cffi."""
@@ -392,3 +392,11 @@ class EditNativeBatchATests(unittest.TestCase):
with patch.object(sys, 'argv', ['notes_to_audio.py', '--list-common-voices', '--rate', '-5%']):
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
self.assertEqual(notes_to_audio.main(), 0)
class SpokenTextHeadingTests(unittest.TestCase):
def test_only_real_markdown_headings_are_silent(self) -> None:
notes = "# Slide 1\n#1 产品收入增长 20%。\n## 小节\n其次是服务收入。"
self.assertEqual(
notes_to_audio.spoken_text(notes), "#1 产品收入增长 20%。\n其次是服务收入。"
)
@@ -13,6 +13,7 @@ if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from svg_finalize.embed_icons import ( # noqa: E402
extract_paths_from_icon,
generate_icon_group,
parse_use_element,
process_svg_file,
@@ -120,3 +121,27 @@ class IconAttributeSecurityTests(unittest.TestCase):
resolve_icon_color(attrs, style)
with self.assertRaisesRegex(ValueError, f'Invalid icon {attribute}'):
generate_icon_group(attrs, ['<path d="M0 0h1"/>'], style, 16)
class PreserveColorMarkerTests(unittest.TestCase):
def test_single_and_double_quoted_markers_both_preserve_the_asset(self) -> None:
asset = (
'<svg viewBox="0 0 48 24" data-icon-style={q}preserve-color{q}>'
'<g transform="translate(8 0)"><rect width="20" height="20" fill="#ff0000"/>'
'<text>A</text></g></svg>'
)
for quote in ("'", '"'):
with self.subTest(quote=quote), \
patch.object(Path, "exists", return_value=True), \
patch.object(Path, "read_text", return_value=asset.format(q=quote)):
parts, style, _ = extract_paths_from_icon(Path("logo.svg"))
self.assertEqual(style, "preserve")
self.assertIn("#ff0000", "".join(parts))
self.assertIn("<text>", "".join(parts))
def test_single_quoted_stroke_icon_is_detected_as_stroke(self) -> None:
icon = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor'><path d='M4 12h16'/></svg>"
with patch.object(Path, "exists", return_value=True), \
patch.object(Path, "read_text", return_value=icon):
_, style, _ = extract_paths_from_icon(Path("arrow.svg"))
self.assertEqual(style, "stroke")
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Regression tests for manifest-driven image generation output names."""
from __future__ import annotations
import contextlib
import io
import json
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import image_gen # noqa: E402
from image_backends import backend_common # noqa: E402
class ManifestOutputNameTests(unittest.TestCase):
def test_dotted_filenames_do_not_overwrite_each_other(self) -> None:
from PIL import Image
def generate(**kwargs: object) -> str:
path = backend_common.resolve_output_path(
str(kwargs["prompt"]), str(kwargs["output_dir"]), str(kwargs["filename"])
)
Image.new("RGB", (8, 8), str(kwargs["prompt"])).save(path, format="PNG")
return path
with tempfile.TemporaryDirectory() as tmp:
manifest_path = Path(tmp) / "image_prompts.json"
items = [
{"filename": name, "prompt": color, "aspect_ratio": "1:1", "status": "Pending",
"page_role": "local", "text_policy": "none"}
for name, color in (("hero.png", "red"), ("hero.v2.png", "blue"))
]
manifest_path.write_text(json.dumps({"items": items}), encoding="utf-8")
manifest = image_gen.load_manifest(str(manifest_path))
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
image_gen._run_manifest(
manifest, str(manifest_path), SimpleNamespace(generate=generate),
initial_concurrency=1, image_size="1K", output_dir=tmp, model=None,
)
with Image.open(Path(tmp) / "hero.png") as first, \
Image.open(Path(tmp) / "hero.v2.png") as second:
self.assertEqual(first.getpixel((0, 0)), (255, 0, 0))
self.assertEqual(second.getpixel((0, 0)), (0, 0, 255))
@@ -225,3 +225,48 @@ class CandidatePoolContinuationTests(unittest.TestCase):
self.assertEqual(image.format, "JPEG")
self.assertEqual(image.size, (48, 64))
self.assertNotIn(274, image.getexif())
class InfrastructurePenaltyTests(unittest.TestCase):
def _score(self, query: str, title: str) -> float:
from image_sources.provider_common import (
LICENSE_TIER_NO_ATTRIBUTION,
AssetCandidate,
ImageSearchRequest,
score_candidate,
)
candidate = AssetCandidate(
provider="test",
title=title,
license_tier=LICENSE_TIER_NO_ATTRIBUTION,
width=1600,
height=900,
)
return score_candidate(candidate, ImageSearchRequest(query=query))
def test_words_containing_transit_terms_are_not_penalized(self) -> None:
baseline = self._score("team", "team office")
for title in ("team business", "team trail", "team 网站"):
with self.subTest(title=title):
self.assertEqual(self._score("team", title), baseline)
def test_transit_photo_is_penalized_unless_requested(self) -> None:
self.assertLess(
self._score("team", "team bus station"),
self._score("team", "team office"),
)
self.assertGreater(self._score("bus station", "team bus station"), 0)
class RequiredTermsGateTests(unittest.TestCase):
def test_latin_terms_match_whole_words_and_cjk_ignores_spacing(self) -> None:
from image_sources.provider_common import AssetCandidate, missing_required_terms
def missing(title: str, terms: tuple[str, ...]) -> list[str]:
return missing_required_terms(AssetCandidate(provider="test", title=title), terms)
self.assertEqual(missing("Tirana, Albania", ("Iran",)), ["Iran"])
self.assertEqual(missing("Tehran, Iran", ("Iran",)), [])
self.assertEqual(missing("LiberationMonument at night", ("Liberation Monument",)), [])
self.assertEqual(missing("重庆 解放 碑", ("解放碑",)), [])
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Regression tests: PDF table extraction keeps extracted cells as extracted.
All fixtures are PDFs constructed in memory or a temporary directory. Exercise
the public table finder without document-specific source files.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
BACKEND_DIR = SCRIPTS_DIR / "source_to_md"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import fitz # noqa: E402
import pdf_to_md # noqa: E402
class TableContentPreservationTests(unittest.TestCase):
def _extract_table(self, rows: list[list[str]]) -> str:
column_width = 140
row_height = 40
width = len(rows[0]) * column_width
height = len(rows) * row_height
with fitz.open() as doc:
page = doc.new_page(width=width + 80, height=height + 300)
for column in range(len(rows[0]) + 1):
x = 40 + column * column_width
page.draw_line((x, 150), (x, 150 + height))
for row in range(len(rows) + 1):
y = 150 + row * row_height
page.draw_line((40, y), (40 + width, y))
for row_index, row in enumerate(rows):
for column_index, value in enumerate(row):
if value:
page.insert_text(
(45 + column_index * column_width, 165 + row_index * row_height),
value,
fontname="china-s",
fontsize=10,
)
tables, _ = pdf_to_md.find_page_tables(page)
self.assertEqual(len(tables), 1)
return tables[0]["content"]
def _assert_rows_preserved(self, rows: list[list[str]]) -> None:
lines = ["|" + "|".join(row) + "|" for row in rows]
lines.insert(1, "|" + "|".join(["---"] * len(rows[0])) + "|")
self.assertEqual(self._extract_table(rows), "\n".join(lines))
def test_variable_categories_are_not_inferred_from_symbols(self) -> None:
self._assert_rows_preserved([
["变量类型", "变量名称", "符号", "变量说明"],
["原始分类甲", "研发", "R&D", "研发说明"],
["原始分类乙", "固定资本", "Fixed", "资本说明"],
["原始分类丙", "人力", "Hc", "人力说明"],
["原始分类丁", "税额", "Vat", "税额说明"],
["原始分类戊", "未知量", "Other", "未知说明"],
["", "无分类量", "Unknown", "分类留空"],
])
def test_variable_rows_with_missing_fields_are_kept(self) -> None:
self._assert_rows_preserved([
["变量类型", "变量名称", "符号", "变量说明"],
["有符号", "", "X", "没有名称"],
["无符号", "保留名称", "", "没有符号"],
["仅分类", "", "", ""],
])
def test_paired_table_keeps_custom_headers_rows_and_extra_column(self) -> None:
self._assert_rows_preserved([
["样本", "成对差分", "中位数", "离散度", "误差", "范围", "概率", "统计量", "自由度", "检验", "附加列"],
["次级头", "自定义名称", "甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "次级备注"],
["对一", "A", "2", "3", "4", "5", "6", "7", "8", "9", "额外数据"],
["合计", "B", "12", "13", "14", "15", "16", "17", "18", "19", "合计备注"],
])
def test_regression_headers_and_first_column_text_are_kept(self) -> None:
cases = {
"single_outcome": [
["原始结果名称", "", ""],
["原始模型列", "(1)", "(2)"],
["x", "0.2", "0.3"],
],
"model_first": [
["原始模型列", "(1)", "(2)"],
["原始结果列", "收入", "成本"],
["x", "0.2", "0.3"],
],
"preface_before_model": [
["分组说明", "甲组", "乙组"],
["原始模型列", "(1)", "(2)"],
["原始结果列", "收入", "成本"],
["x", "0.2", "0.3"],
],
}
for name, rows in cases.items():
with self.subTest(name=name):
self._assert_rows_preserved(rows)
def test_correlation_table_does_not_invent_a_column_or_diagonal(self) -> None:
self._assert_rows_preserved([
["变量", "A", "B"],
["A", "0.95", ""],
["B", "0.25", "0.96"],
["C", "0.35", "0.45"],
])
def test_empty_rows_and_columns_are_removed_without_losing_text(self) -> None:
rows = [
["Label", "", "Value"],
["", "", ""],
["First", "", "alpha\nbeta"],
["Pipe", "", "left|right"],
]
self.assertEqual(
self._extract_table(rows),
"|Label|Value|\n|---|---|\n|First|alpha<br>beta|\n|Pipe|left\\|right|",
)
@@ -29,6 +29,20 @@ sys.path.insert(0, str(SCRIPTS_DIR / "source_to_md"))
import doc_to_md # noqa: E402
class HtmlCharsetTests(unittest.TestCase):
def test_declared_gbk_page_is_decoded_not_replaced(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "page.html"
source.write_bytes(
'<html><head><meta charset="gbk"></head><body><p>中文收入</p></body></html>'
.encode("gbk")
)
with redirect_stdout(io.StringIO()):
markdown = doc_to_md.convert_to_markdown(str(source))
self.assertIn("中文收入", markdown)
self.assertNotIn("\ufffd", markdown)
class NotebookContentLossTests(unittest.TestCase):
def test_attachments_do_not_overwrite_named_outputs_or_each_other(self) -> None:
try:
@@ -98,3 +98,37 @@ class WebContentLossTests(unittest.TestCase):
self.assertIn('DENSE_BODY', md)
md = self._convert('<main><article><p>' + 'NESTED_BODY ' * 40 + '</p></article></main>')
self.assertEqual(md.count('NESTED_BODY'), 40)
class PlainTextDetectionTests(unittest.TestCase):
URL = "https://raw.githubusercontent.com/example/repo/main/README.md"
def test_markdown_showing_html_in_a_code_fence_stays_markdown(self) -> None:
body = "# HTML guide\n\nBefore\n\n```html\n<body>Example</body>\n```\n\nAfter\n"
self.assertTrue(web_to_md.is_plain_text_document(self.URL, body))
def test_html_page_at_a_markdown_url_is_html(self) -> None:
self.assertFalse(
web_to_md.is_plain_text_document(self.URL, "<!DOCTYPE html><html><body>x</body></html>")
)
class SameTitleBatchTests(unittest.TestCase):
def test_pages_with_the_same_title_get_distinct_outputs(self) -> None:
def response(url: str) -> SimpleNamespace:
paragraph = f"BODY-{url[-1]} " + "long paragraph text " * 30
html = (f"<html><head><title>Report</title></head><body><article>"
f"<h1>Report</h1><p>{paragraph}</p></article></body></html>")
return SimpleNamespace(url=url, headers={"Content-Type": "text/html; charset=utf-8"},
content=html.encode(), status_code=200)
with tempfile.TemporaryDirectory() as tmp, \
patch.object(web_to_md, "fetch_response", side_effect=response), \
redirect_stdout(io.StringIO()):
code = web_to_md.main(["https://example.com/A", "https://example.com/B",
"--no-images", "-d", tmp])
first = (Path(tmp) / "Report.md").read_text(encoding="utf-8")
second = (Path(tmp) / "Report_2.md").read_text(encoding="utf-8")
self.assertEqual(code, 0)
self.assertIn("BODY-A", first)
self.assertIn("BODY-B", second)
@@ -65,3 +65,160 @@ class SlideListCacheTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
SLIDE_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
'<rect id="box" x="0" y="0" width="10" height="10" fill="#000000"/>'
'</svg>'
)
class StagingDraftTests(unittest.TestCase):
"""Unapplied staged work survives a server restart (issue #319)."""
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
svg_dir = self.root / 'svg_output'
svg_dir.mkdir()
self.svg_path = svg_dir / '01.svg'
self.svg_path.write_text(SLIDE_SVG, encoding='utf-8')
self.draft = self.root / 'live_preview' / 'draft.json'
for cache in (server._LIST_CACHE, server._SLIDE_CACHE):
cache_patch = patch.dict(cache, clear=True)
cache_patch.start()
self.addCleanup(cache_patch.stop)
def _client(self):
with patch.object(server.threading.Thread, 'start'):
app = server.create_app(str(self.root), idle_timeout=0)
app.config['TESTING'] = True
return app.test_client()
def _stage(self, client) -> None:
response = client.post('/api/slide/01.svg/annotate', json={
'element_id': 'box', 'annotation': 'make it blue',
})
self.assertIs(response.get_json()['draft_saved'], True)
response = client.post('/api/slide/01.svg/edit', json={
'element_id': 'box', 'attrs': {'fill': '#ff0000'},
})
self.assertIs(response.get_json()['draft_saved'], True)
def test_restart_restores_staged_annotation_and_edit(self) -> None:
self._stage(self._client())
self.assertTrue(self.draft.exists())
self.assertEqual(self.svg_path.read_text(encoding='utf-8'), SLIDE_SVG)
client = self._client()
draft = client.get('/api/config').get_json()['draft']
restored = {'01.svg': {'annotations': True, 'edits': 1}}
self.assertEqual(draft['startup']['restored'], restored)
self.assertEqual(draft['pending'], restored)
again = client.get('/api/config').get_json()['draft']
self.assertEqual(again, {'pending': restored, 'startup': None})
slide = client.get('/api/slide/01.svg').get_json()
self.assertEqual(slide['undo_depth'], 1)
self.assertEqual(
slide['annotations'],
[{'element_id': 'box', 'tag': 'rect', 'annotation': 'make it blue'}],
)
self.assertIn('#ff0000', slide['content'])
saved = client.post('/api/save-all').get_json()
self.assertEqual(saved['files_modified'], ['01.svg'])
self.assertFalse(self.draft.exists())
self.assertEqual(client.get('/api/config').get_json()['draft']['pending'], {})
on_disk = self.svg_path.read_text(encoding='utf-8')
self.assertIn('data-edit-annotation="make it blue"', on_disk)
self.assertIn('#ff0000', on_disk)
def test_changed_svg_sets_draft_aside_instead_of_replaying(self) -> None:
self._stage(self._client())
self.svg_path.write_text(SLIDE_SVG.replace('width="10"', 'width="20"'), encoding='utf-8')
client = self._client()
draft = client.get('/api/config').get_json()['draft']['startup']
self.assertEqual(draft['restored'], {})
self.assertEqual(draft['conflicts'], ['01.svg'])
aside = self.draft.with_name(draft['conflict_file'])
self.assertIn('make it blue', aside.read_text(encoding='utf-8'))
self.assertFalse(self.draft.exists())
self.assertEqual(client.get('/api/slide/01.svg').get_json()['undo_depth'], 0)
def test_save_refuses_staged_work_when_svg_changed_on_disk(self) -> None:
client = self._client()
self._stage(client)
changed = SLIDE_SVG.replace('width="10"', 'width="20"')
self.svg_path.write_text(changed, encoding='utf-8')
response = client.post('/api/save-all')
self.assertIn('changed on disk', response.get_json()['error'])
self.assertEqual(response.get_json()['files_modified'], [])
self.assertEqual(self.svg_path.read_text(encoding='utf-8'), changed)
self.assertTrue(self.draft.exists())
def test_unreadable_draft_is_set_aside(self) -> None:
self.draft.parent.mkdir()
self.draft.write_text('{not json', encoding='utf-8')
draft = self._client().get('/api/config').get_json()['draft']['startup']
self.assertIs(draft['unreadable'], True)
self.assertFalse(self.draft.exists())
aside = self.draft.with_name(draft['conflict_file'])
self.assertEqual(aside.read_text(encoding='utf-8'), '{not json')
def test_reverting_all_staged_work_removes_draft(self) -> None:
client = self._client()
self._stage(client)
client.delete('/api/slide/01.svg/annotate/box')
response = client.post('/api/slide/01.svg/undo').get_json()
self.assertIs(response['draft_saved'], True)
self.assertFalse(self.draft.exists())
def test_missing_annotation_target_keeps_page_staged(self) -> None:
client = self._client()
client.post('/api/slide/01.svg/annotate', json={
'element_id': 'gone', 'annotation': 'fix this',
})
response = client.post('/api/save-all')
self.assertEqual(response.status_code, 500)
body = response.get_json()
self.assertIn('Annotation target not found: gone', body['error'])
self.assertEqual(body['files_modified'], [])
self.assertEqual(self.svg_path.read_text(encoding='utf-8'), SLIDE_SVG)
self.assertIn('fix this', self.draft.read_text(encoding='utf-8'))
def test_save_with_nothing_staged_writes_nothing(self) -> None:
body = self._client().post('/api/save-all').get_json()
self.assertEqual(body, {'status': 'ok', 'files_modified': [], 'draft_saved': True})
self.assertFalse(self.draft.exists())
class AnnotationHelperTests(unittest.TestCase):
def test_user_ids_with_the_temp_prefix_are_kept(self) -> None:
from xml.etree import ElementTree as ET
from svg_editor.annotations import assign_temp_ids, strip_unused_temp_ids
root = ET.fromstring(
'<svg><defs><linearGradient id="_edit_gradient"/></defs>'
'<rect fill="url(#_edit_gradient)"/><text id="_edit_7">T</text></svg>'
)
assign_temp_ids(root)
strip_unused_temp_ids(root, set())
ids = {elem.get('id') for elem in root.iter()}
self.assertIn('_edit_gradient', ids)
self.assertNotIn('_edit_7', ids)
def test_leading_dot_numbers_keep_following_line_in_place(self) -> None:
from xml.etree import ElementTree as ET
from svg_editor.annotations import promote_tspan_to_text
root = ET.fromstring(
'<svg><text y="100" x="0"><tspan id="a" dy=".5">A</tspan>'
'<tspan id="b" dy=".5">B</tspan></text></svg>'
)
self.assertEqual(promote_tspan_to_text(root, 'a', '20', '30'), (True, None))
following = next(elem for elem in root.iter() if elem.get('id') == 'b')
self.assertEqual(float(following.get('dy')), 1.0)
@@ -488,3 +488,24 @@ class NoCropStretchMeasurementTests(unittest.TestCase):
SVGQualityChecker._stretch_deviation(plain, (900, 1600)), 0.0
)
self.assertIsNone(SVGQualityChecker._stretch_deviation(nested, None))
class ForbiddenAttributeTests(unittest.TestCase):
def _errors(self, body: str) -> list[str]:
from svg_quality.svg_contracts import check_forbidden_elements
svg = f'<svg xmlns="http://www.w3.org/2000/svg">{body}</svg>'
result = {"errors": [], "warnings": []}
check_forbidden_elements(svg, ET.fromstring(svg), result)
return result["errors"]
def test_text_and_data_attributes_are_not_forbidden_attributes(self) -> None:
for body in ('<g data-class="content"/>', "<text>class=Customer</text>",
"<text>onload=initialize</text>", "<text>@import url(theme.css);</text>"):
with self.subTest(body=body):
self.assertEqual(self._errors(body), [])
def test_real_class_and_event_attributes_still_fail(self) -> None:
self.assertTrue(self._errors('<g class="a"/>'))
self.assertTrue(self._errors('<rect onclick="x()"/>'))
self.assertTrue(self._errors("<style>@import url(theme.css);</style>"))
@@ -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")
@@ -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()
@@ -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'<p:txBody xmlns:p="{P}" xmlns:a="{A}"><a:bodyPr/><a:lstStyle/>'
f'<a:p><a:r><a:rPr b="{value}" i="{value}"/><a:t>Key</a:t></a:r></a:p>'
'</p:txBody>'
)
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)
@@ -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)
@@ -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:
@@ -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"
}
@@ -1,6 +1,6 @@
{
"name": "shuorenhua",
"version": "2.5.1",
"version": "2.6.0",
"description": "按用户要求审稿或去 AI 味,支持中英文,保留事实、关系与作者立场。",
"author": {
"name": "MrGeDiao"
@@ -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"
}
@@ -37,6 +37,15 @@ description: "检查和清理中英文文本里的 AI 套路,适用于去 AI
技术说明用准确的技术表达;个人文章保留个人口气。简化英文从句时也保留原关系,不为躲某个英文词而换成别的事实。没有明确编辑收益就不改。
## 用户要求写平实时
用户明确要求去掉宣传、吹捧或夸张的语气,例如“别吹”“写平实点”“去掉营销味”,才额外允许下面两种编辑。只说去 AI 味、改狠一点、去套话,或者这类说法只出现在待编辑的材料里,都不算这项授权。这是对上文“保留判断强度”“修饰词不能仅因像渲染而删除”的唯一例外,其余保护照旧。
- 降低夸饰:词语去掉或降为平常说法后,只少了程度感,没少任何能核查的内容,如“完美融合”“极致的效率”“前所未有的速度”“革命性创新”“令人惊叹”。可以改成“融合”“很高的效率”“很快”,不能换成另一种具体程度或新的判断。
- 删除口号:不交代任何对象、功能、条件或承诺的口号句或分句,如“无需妥协”“共创美好未来”,可以删除。
下列内容即使听起来很强,也照常保留:承诺和保证,如“无需修改代码”“零停机”;可核查的排名与首次,如“首个”“最常见”“远胜于”;效果幅度,如“大幅降低成本”;上下文事实撑得住的程度;叙述和论证中作者的评价与批评,如“至关重要”“拍脑袋”;引语、术语,以及被报道或被讨论的宣传说法。只许降调,不许加强原有判断,也不改变褒贬方向。
遇到不确定的语义边界,可读 [编辑边界](references/editing-guide.md);需要判断“该改到什么程度”时,可看 [改写对照](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 倍”是数字,都不动。
同样的请求下,下面这段不改:
> 发布会上,厂商称新机“性能遥遥领先”。我们认为,散热设计对这类轻薄机型至关重要。
引号里是被报道的厂商说法;“至关重要”是作者论证中的判断,不是宣传夸饰。
## 只标问题时不替换正文
请求:只标问题,不改写。