snapstep 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
snapstep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SnapStep — 录屏自动生成图文教程。"""
2
+
3
+ __version__ = "0.2.0"
snapstep/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m snapstep 入口。"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
snapstep/capture.py ADDED
@@ -0,0 +1,161 @@
1
+ """屏幕截图与步骤标注。
2
+
3
+ 导入本模块只需 Pillow;mss 延迟到真正截屏时才加载,
4
+ 保证导出器/测试在没有屏幕的环境里也能工作。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from PIL import Image, ImageDraw, ImageFont
13
+
14
+ # 点击高亮圈颜色(快照红)
15
+ ACCENT = (245, 63, 63)
16
+
17
+
18
+ class ScreenCapture:
19
+ """基于 mss 的截屏器。线程注意:mss 实例不跨线程,按线程懒加载。"""
20
+
21
+ def __init__(self) -> None:
22
+ self._sct = None
23
+
24
+ def _client(self):
25
+ if self._sct is None:
26
+ import mss
27
+
28
+ self._sct = mss.mss()
29
+ return self._sct
30
+
31
+ def monitor_for_point(self, x: int, y: int) -> dict:
32
+ """返回包含该点显示器的 mss monitor 字典;找不到时退回主显示器。"""
33
+ monitors = self._client().monitors
34
+ for mon in monitors[1:]:
35
+ if (
36
+ mon["left"] <= x < mon["left"] + mon["width"]
37
+ and mon["top"] <= y < mon["top"] + mon["height"]
38
+ ):
39
+ return dict(mon)
40
+ return dict(monitors[1] if len(monitors) > 1 else monitors[0])
41
+
42
+ def capture_point(self, x: int, y: int) -> tuple[Image.Image, dict]:
43
+ """截取包含 (x, y) 的显示器,返回 (PIL 图像, monitor 字典)。"""
44
+ mon = self.monitor_for_point(x, y)
45
+ shot = self._client().grab(mon)
46
+ img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
47
+ return img, mon
48
+
49
+
50
+ def save_screenshot(
51
+ img: Image.Image,
52
+ session_dir: Path,
53
+ index: int,
54
+ image_format: str = "png",
55
+ suffix: str = "",
56
+ ) -> str:
57
+ """保存原始截图,返回相对 session 目录的 POSIX 风格路径。
58
+
59
+ suffix 用于区分同一步骤的候选帧(如 "-imm" / "-set")。
60
+ """
61
+ images_dir = session_dir / "images"
62
+ images_dir.mkdir(parents=True, exist_ok=True)
63
+ path = images_dir / f"step-{index:02d}{suffix}.{image_format}"
64
+ img.save(path)
65
+ return path.relative_to(session_dir).as_posix()
66
+
67
+
68
+ # 图片相似度:缩到 96x54 灰度后逐像素求平均绝对差(0~255)。
69
+ # < 2.0 视为「画面没变」,> 4.0 视为「明显变化」。
70
+ _SIMILARITY_SIZE = (96, 54)
71
+
72
+
73
+ def mean_diff(a: Path | Image.Image, b: Path | Image.Image) -> float:
74
+ """两张截图的平均像素差(0~255),越小越相似。"""
75
+ pa = a if isinstance(a, Image.Image) else Image.open(a)
76
+ pb = b if isinstance(b, Image.Image) else Image.open(b)
77
+ ga = pa.convert("L").resize(_SIMILARITY_SIZE)
78
+ gb = pb.convert("L").resize(_SIMILARITY_SIZE)
79
+ da = list(ga.getdata())
80
+ db = list(gb.getdata())
81
+ total = sum(abs(x - y) for x, y in zip(da, db, strict=True))
82
+ return total / len(da)
83
+
84
+
85
+ def settle_grab(
86
+ capture: ScreenCapture,
87
+ monitor: dict,
88
+ max_wait_ms: int,
89
+ poll_ms: int = 150,
90
+ stable_diff: float = 1.5,
91
+ ) -> tuple[Image.Image, int]:
92
+ """轮询截屏直到画面连续两帧几乎不变(界面稳定),或超时。
93
+
94
+ 返回 (稳定帧图像, 实际等待毫秒数)。
95
+ """
96
+ deadline = time.monotonic() + max(max_wait_ms, 0) / 1000
97
+ last = capture.grab_monitor(monitor)
98
+ waited = 0
99
+ while True:
100
+ if time.monotonic() >= deadline:
101
+ return last, waited
102
+ time.sleep(poll_ms / 1000)
103
+ waited += poll_ms
104
+ current = capture.grab_monitor(monitor)
105
+ if mean_diff(last, current) < stable_diff:
106
+ return current, waited
107
+ last = current
108
+
109
+
110
+ def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
111
+ for name in ("msyh.ttc", "arial.ttf"): # 优先微软雅黑,回退 Arial
112
+ try:
113
+ return ImageFont.truetype(name, size)
114
+ except OSError:
115
+ continue
116
+ return ImageFont.load_default()
117
+
118
+
119
+ def annotate(
120
+ img: Image.Image, rel_x: float, rel_y: float, number: int, radius: int = 28
121
+ ) -> Image.Image:
122
+ """在截图上标注点击位置:红色高亮圈 + 序号徽章,返回新图。"""
123
+ out = img.convert("RGBA")
124
+ overlay = Image.new("RGBA", out.size, (0, 0, 0, 0))
125
+ d = ImageDraw.Draw(overlay)
126
+
127
+ cx, cy = rel_x * out.width, rel_y * out.height
128
+ ring_w = max(3, out.width // 450)
129
+ # 外圈柔光 + 内圈实线
130
+ d.ellipse(
131
+ [cx - radius - 8, cy - radius - 8, cx + radius + 8, cy + radius + 8],
132
+ outline=ACCENT + (80,),
133
+ width=ring_w + 3,
134
+ )
135
+ d.ellipse(
136
+ [cx - radius, cy - radius, cx + radius, cy + radius],
137
+ outline=ACCENT + (255,),
138
+ width=ring_w,
139
+ )
140
+
141
+ # 序号徽章放在圈右上,越界时往里收
142
+ badge_r = max(14, int(radius * 0.62))
143
+ bx = min(max(cx + radius + 6, badge_r), out.width - badge_r - 2)
144
+ by = max(min(cy - radius - 6, out.height - badge_r - 2), badge_r + 2)
145
+ d.ellipse(
146
+ [bx - badge_r, by - badge_r, bx + badge_r, by + badge_r],
147
+ fill=ACCENT + (235,),
148
+ )
149
+ out = Image.alpha_composite(out, overlay)
150
+
151
+ dd = ImageDraw.Draw(out)
152
+ text = str(number)
153
+ font = _load_font(badge_r * 2 - 4)
154
+ left, top, right, bottom = dd.textbbox((0, 0), text, font=font)
155
+ dd.text(
156
+ (bx - (right - left) / 2 - left, by - (bottom - top) / 2 - top),
157
+ text,
158
+ font=font,
159
+ fill=(255, 255, 255, 255),
160
+ )
161
+ return out.convert("RGB")
snapstep/cli.py ADDED
@@ -0,0 +1,298 @@
1
+ """命令行入口。
2
+
3
+ snapstep 启动托盘 GUI(默认)
4
+ snapstep record 命令行录制,回车结束
5
+ snapstep export 导出已有会话
6
+ snapstep config 查看/修改配置
7
+ snapstep demo 生成一份示例教程(无需录制,验证安装)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from dataclasses import fields as dc_fields
16
+ from pathlib import Path
17
+
18
+ from . import __version__
19
+ from .config import Config, load_config, save_config
20
+ from .models import Session
21
+ from .writer import AIWriter, TemplateWriter, apply_copy
22
+
23
+
24
+ def generate_and_export(
25
+ session: Session,
26
+ session_dir: Path,
27
+ cfg: Config,
28
+ fmt: str | None = None,
29
+ use_ai: bool | None = None,
30
+ ) -> tuple[str, list[Path]]:
31
+ """生成文案并导出。CLI 与 GUI 共用。返回 (writer 名称, 导出文件列表)。"""
32
+ from .export import export_session
33
+
34
+ use_ai = cfg.ai_enabled() if use_ai is None else (use_ai and cfg.ai_enabled())
35
+ ai = (
36
+ AIWriter(cfg.api.base_url, cfg.api.api_key, cfg.api.model, cfg.api.language)
37
+ if use_ai
38
+ else None
39
+ )
40
+ used = apply_copy(session, TemplateWriter(cfg.api.language), ai)
41
+ session.save(session_dir)
42
+ paths = export_session(
43
+ session, session_dir, fmt or cfg.export.format, None, cfg.export.embed_images
44
+ )
45
+ return used, paths
46
+
47
+
48
+ # ---------- 子命令 ----------
49
+
50
+
51
+ def cmd_record(args, cfg: Config) -> int:
52
+ from .recorder import Recorder
53
+
54
+ privacy = "开启" if cfg.privacy.privacy_mode else "关闭"
55
+ ai = "已配置" if cfg.ai_enabled() else "未配置(使用本地模板)"
56
+ print(f"SnapStep v{__version__} 开始录制")
57
+ print(f" 隐私模式:{privacy}(截屏{'被跳过' if cfg.privacy.privacy_mode else '正常'})")
58
+ print(f" AI 文案:{ai}")
59
+ print(" 进行你的操作,回到本窗口按回车结束录制。\n")
60
+
61
+ recorder = Recorder(cfg, out_dir=Path(args.out) if args.out else None)
62
+ recorder.on_step_captured = lambda step: print(
63
+ f" [步骤 {step.index}] {step.window_title or '未知窗口'}"
64
+ )
65
+ recorder.start()
66
+ try:
67
+ input()
68
+ except KeyboardInterrupt:
69
+ pass
70
+ session, session_dir = recorder.stop_and_save()
71
+ print(f"\n录制完成:{len(session.steps)} 个步骤 → {session_dir}")
72
+
73
+ used, paths = generate_and_export(
74
+ session, session_dir, cfg, fmt=args.format, use_ai=not args.no_ai
75
+ )
76
+ _print_export_result(used, paths)
77
+ return 0
78
+
79
+
80
+ def cmd_export(args, cfg: Config) -> int:
81
+ session_dir = Path(args.session_dir)
82
+ if not (session_dir / "session.json").exists():
83
+ print(f"错误:{session_dir} 下没有 session.json", file=sys.stderr)
84
+ return 1
85
+ session = Session.load(session_dir)
86
+ used, paths = generate_and_export(
87
+ session, session_dir, cfg, fmt=args.format, use_ai=not args.no_ai
88
+ )
89
+ _print_export_result(used, paths)
90
+ return 0
91
+
92
+
93
+ def _print_export_result(used: str, paths: list[Path]) -> None:
94
+ if used == "ai":
95
+ print("文案:AI 生成")
96
+ else:
97
+ print("文案:本地模板(AI 未配置或调用失败,已自动兜底)")
98
+ for path in paths:
99
+ print(f"已导出:{path}")
100
+
101
+
102
+ def cmd_config(args, cfg: Config) -> int:
103
+ if args.config_action == "list" or args.key is None:
104
+ print(json.dumps(_config_to_dict(cfg), ensure_ascii=False, indent=2))
105
+ return 0
106
+ if args.config_action == "get":
107
+ print(_get_key(cfg, args.key))
108
+ return 0
109
+ # set
110
+ _set_key(cfg, args.key, args.value)
111
+ save_config(cfg)
112
+ print(f"已保存 {args.key} = {args.value}")
113
+ return 0
114
+
115
+
116
+ def _config_to_dict(cfg: Config) -> dict:
117
+ return {
118
+ "hotkey": cfg.hotkey,
119
+ "capture": {f.name: getattr(cfg.capture, f.name) for f in dc_fields(cfg.capture)},
120
+ "privacy": {f.name: getattr(cfg.privacy, f.name) for f in dc_fields(cfg.privacy)},
121
+ "api": {f.name: getattr(cfg.api, f.name) for f in dc_fields(cfg.api)},
122
+ "export": {f.name: getattr(cfg.export, f.name) for f in dc_fields(cfg.export)},
123
+ }
124
+
125
+
126
+ def _get_key(cfg: Config, key: str) -> object:
127
+ target: object = cfg
128
+ for part in key.split("."):
129
+ target = getattr(target, part)
130
+ return target
131
+
132
+
133
+ def _set_key(cfg: Config, key: str, value: str) -> None:
134
+ parts = key.split(".")
135
+ target: object = cfg
136
+ for part in parts[:-1]:
137
+ target = getattr(target, part)
138
+ leaf = parts[-1]
139
+ if not hasattr(target, leaf):
140
+ valid = _valid_keys(cfg)
141
+ raise SystemExit(f"未知配置项 {key}。可用项:\n " + "\n ".join(valid))
142
+ current = getattr(target, leaf)
143
+ if isinstance(current, bool):
144
+ setattr(target, leaf, value.lower() in ("1", "true", "yes", "on"))
145
+ elif isinstance(current, int):
146
+ setattr(target, leaf, int(value))
147
+ else:
148
+ setattr(target, leaf, value)
149
+
150
+
151
+ def _valid_keys(cfg: Config) -> list[str]:
152
+ keys = ["hotkey"]
153
+ for section in ("capture", "privacy", "api", "export"):
154
+ obj = getattr(cfg, section)
155
+ keys += [f"{section}.{f.name}" for f in dc_fields(obj)]
156
+ return keys
157
+
158
+
159
+ def cmd_demo(args, cfg: Config) -> int:
160
+ session = _build_demo_session()
161
+ session_dir = Path(args.out)
162
+ _render_demo_screenshots(session, session_dir)
163
+ used, paths = generate_and_export(
164
+ session, session_dir, cfg, fmt=args.format, use_ai=not args.no_ai
165
+ )
166
+ print(f"示例会话已生成:{session_dir}")
167
+ _print_export_result(used, paths)
168
+ return 0
169
+
170
+
171
+ def _build_demo_session() -> Session:
172
+ from .models import Step, TypedRun
173
+
174
+ session = Session(title=None, intro=None)
175
+ demo_steps = [
176
+ ("发布新文章 - 后台管理", (0.5, 0.12), [("操作手册", False)]),
177
+ ("发布新文章 - 后台管理", (0.32, 0.45), []),
178
+ ("确认发布 - 后台管理", (0.5, 0.62), [("文章已就绪,点击确认", False)]),
179
+ ]
180
+ for i, (win, (rx, ry), runs) in enumerate(demo_steps, 1):
181
+ step = Step(
182
+ index=i,
183
+ created_at=f"2026-01-01T10:0{i}:00",
184
+ window_title=win,
185
+ click_x=int(rx * 1920),
186
+ click_y=int(ry * 1080),
187
+ monitor_width=1920,
188
+ monitor_height=1080,
189
+ typed_runs=[TypedRun(text=t, is_secret=s) for t, s in runs],
190
+ )
191
+ session.steps.append(step)
192
+ return session
193
+
194
+
195
+ def _render_demo_screenshots(session: Session, session_dir: Path) -> None:
196
+ """用 Pillow 画出假窗口截图,让 demo 无需真实录屏。"""
197
+ from PIL import Image, ImageDraw, ImageFont
198
+
199
+ from .capture import save_screenshot
200
+
201
+ def font(size: int):
202
+ for name in ("msyh.ttc", "arial.ttf"):
203
+ try:
204
+ return ImageFont.truetype(name, size)
205
+ except OSError:
206
+ continue
207
+ return ImageFont.load_default()
208
+
209
+ for step in session.steps:
210
+ img = Image.new("RGB", (1280, 800), (247, 248, 250))
211
+ d = ImageDraw.Draw(img)
212
+ # 假窗口
213
+ win = [120, 90, 1160, 700]
214
+ d.rounded_rectangle(win, 12, fill=(255, 255, 255), outline=(229, 230, 235), width=2)
215
+ d.rounded_rectangle([win[0], win[1], win[2], win[1] + 44], 12, fill=(242, 243, 245))
216
+ d.rectangle([win[0], win[1] + 30, win[2], win[1] + 44], fill=(242, 243, 245))
217
+ for k, color in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
218
+ x0, x1 = win[0] + 18 + k * 26, win[0] + 32 + k * 26
219
+ d.ellipse([x0, win[1] + 15, x1, win[1] + 29], fill=color)
220
+ d.text(
221
+ (win[0] + 110, win[1] + 10),
222
+ step.window_title or "示例窗口",
223
+ font=font(17),
224
+ fill=(31, 35, 41),
225
+ )
226
+ # 几行假正文
227
+ for k in range(4):
228
+ y = win[1] + 80 + k * 46
229
+ x1 = win[2] - 40 - k * 130
230
+ d.rounded_rectangle([win[0] + 40, y, x1, y + 22], 6, fill=(240, 241, 244))
231
+ # 点击处的假按钮
232
+ cx = win[0] + step.rel_x * (win[2] - win[0])
233
+ cy = win[1] + step.rel_y * (win[3] - win[1])
234
+ d.rounded_rectangle([cx - 80, cy - 22, cx + 80, cy + 22], 8, fill=(245, 63, 63))
235
+ d.text((cx - 32, cy - 11), "确 定", font=font(16), fill=(255, 255, 255))
236
+ step.screenshot = save_screenshot(img, session_dir, step.index)
237
+
238
+
239
+ # ---------- 入口 ----------
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ for stream in (sys.stdout, sys.stderr):
244
+ try:
245
+ stream.reconfigure(encoding="utf-8")
246
+ except Exception:
247
+ pass
248
+
249
+ parser = argparse.ArgumentParser(
250
+ prog="snapstep",
251
+ description="录屏自动生成图文教程 / Record your screen, get a step-by-step guide.",
252
+ )
253
+ parser.add_argument("--version", action="version", version=f"SnapStep {__version__}")
254
+ sub = parser.add_subparsers(dest="command")
255
+
256
+ p_record = sub.add_parser("record", help="开始录制,回车结束")
257
+ p_record.add_argument("--out", help="会话输出目录(默认 ~/.snapstep/sessions/<时间戳>)")
258
+ p_record.add_argument("--format", choices=["md", "html", "docx", "all"], help="导出格式")
259
+ p_record.add_argument("--no-ai", action="store_true", help="跳过 AI 文案,直接用本地模板")
260
+
261
+ p_export = sub.add_parser("export", help="导出已有会话")
262
+ p_export.add_argument("session_dir", help="会话目录(含 session.json)")
263
+ p_export.add_argument("--format", choices=["md", "html", "docx", "all"])
264
+ p_export.add_argument("--out", help="导出目录(默认 <会话>/export)")
265
+ p_export.add_argument("--no-ai", action="store_true")
266
+
267
+ p_config = sub.add_parser("config", help="查看/修改配置")
268
+ p_config.add_argument(
269
+ "config_action", nargs="?", choices=["list", "get", "set"], default="list"
270
+ )
271
+ p_config.add_argument("key", nargs="?", help="如 api.api_key、privacy.privacy_mode、hotkey")
272
+ p_config.add_argument("value", nargs="?", help="set 时的新值")
273
+
274
+ p_demo = sub.add_parser("demo", help="生成示例教程,验证安装")
275
+ p_demo.add_argument("--out", default="snapstep-demo")
276
+ p_demo.add_argument("--format", choices=["md", "html", "docx", "all"])
277
+ p_demo.add_argument("--no-ai", action="store_true")
278
+
279
+ args = parser.parse_args(argv)
280
+ cfg = load_config()
281
+
282
+ if args.command == "record":
283
+ return cmd_record(args, cfg)
284
+ if args.command == "export":
285
+ return cmd_export(args, cfg)
286
+ if args.command == "config":
287
+ return cmd_config(args, cfg)
288
+ if args.command == "demo":
289
+ return cmd_demo(args, cfg)
290
+
291
+ from .ui.tray import run_gui
292
+
293
+ run_gui()
294
+ return 0
295
+
296
+
297
+ if __name__ == "__main__":
298
+ raise SystemExit(main())
snapstep/config.py ADDED
@@ -0,0 +1,109 @@
1
+ """SnapStep 配置:JSON 持久化在 ~/.snapstep/config.json。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+
9
+ # 设置界面里的 API 预设(OpenAI 兼容端点)
10
+ API_PRESETS: dict[str, dict[str, str]] = {
11
+ "GLM 智谱": {
12
+ "base_url": "https://open.bigmodel.cn/api/paas/v4",
13
+ "model": "glm-4-flash",
14
+ },
15
+ "DeepSeek": {
16
+ "base_url": "https://api.deepseek.com/v1",
17
+ "model": "deepseek-chat",
18
+ },
19
+ "OpenAI": {
20
+ "base_url": "https://api.openai.com/v1",
21
+ "model": "gpt-4o-mini",
22
+ },
23
+ "自定义": {"base_url": "", "model": ""},
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class CaptureConfig:
29
+ settle_max_ms: int = 1500 # 点击后等待界面稳定的上限,期间持续比对帧,稳定即取「稳定帧」
30
+ image_format: str = "png"
31
+ filter_idle_clicks: bool = True # 停止时自动剔除画面无变化且无输入的无效点击
32
+
33
+
34
+ @dataclass
35
+ class PrivacyConfig:
36
+ privacy_mode: bool = False # 开启后完全不截屏,只记录步骤
37
+ mask_passwords: bool = True # 密码框键入不记录明文(尽力检测)
38
+
39
+
40
+ @dataclass
41
+ class ApiConfig:
42
+ base_url: str = "" # 为空时不调用 AI,走本地模板文案
43
+ api_key: str = ""
44
+ model: str = ""
45
+ language: str = "zh" # AI 文案语言:zh / en
46
+
47
+
48
+ @dataclass
49
+ class ExportConfig:
50
+ format: str = "html" # md / html / docx
51
+ embed_images: bool = True # HTML 单文件内嵌 base64,方便直接分享
52
+
53
+
54
+ @dataclass
55
+ class Config:
56
+ hotkey: str = "<ctrl>+<alt>+s" # 开始/停止录制(GUI 生效)
57
+ capture: CaptureConfig = field(default_factory=CaptureConfig)
58
+ privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
59
+ api: ApiConfig = field(default_factory=ApiConfig)
60
+ export: ExportConfig = field(default_factory=ExportConfig)
61
+
62
+ def ai_enabled(self) -> bool:
63
+ return bool(self.api.base_url and self.api.api_key and self.api.model)
64
+
65
+
66
+ def config_dir() -> Path:
67
+ return Path.home() / ".snapstep"
68
+
69
+
70
+ def sessions_dir() -> Path:
71
+ return config_dir() / "sessions"
72
+
73
+
74
+ def config_path() -> Path:
75
+ return config_dir() / "config.json"
76
+
77
+
78
+ def load_config() -> Config:
79
+ """加载配置;文件缺失或字段不完整时按默认值补齐。"""
80
+ path = config_path()
81
+ cfg = Config()
82
+ if not path.exists():
83
+ return cfg
84
+ try:
85
+ data = json.loads(path.read_text(encoding="utf-8"))
86
+ except (json.JSONDecodeError, OSError):
87
+ return cfg
88
+ cfg.hotkey = data.get("hotkey", cfg.hotkey)
89
+ for section, cls in (
90
+ ("capture", CaptureConfig),
91
+ ("privacy", PrivacyConfig),
92
+ ("api", ApiConfig),
93
+ ("export", ExportConfig),
94
+ ):
95
+ raw = data.get(section, {})
96
+ current = getattr(cfg, section)
97
+ for f in cls.__dataclass_fields__:
98
+ if f in raw:
99
+ setattr(current, f, raw[f])
100
+ return cfg
101
+
102
+
103
+ def save_config(cfg: Config) -> Path:
104
+ path = config_path()
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_text(
107
+ json.dumps(asdict(cfg), ensure_ascii=False, indent=2), encoding="utf-8"
108
+ )
109
+ return path
@@ -0,0 +1,68 @@
1
+ """导出器包:Markdown / HTML / Word + 标注图准备。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PIL import Image
8
+
9
+ from ..models import Session
10
+
11
+ FORMATS = ("md", "html", "docx")
12
+
13
+
14
+ def prepare_images(
15
+ session: Session, session_dir: Path, out_dir: Path
16
+ ) -> dict[int, Path]:
17
+ """为每一步生成带点击高亮和序号徽章的标注图。
18
+
19
+ 返回 {step.index: 标注图路径};没有截图或原始文件缺失的步骤跳过。
20
+ """
21
+ from ..capture import annotate
22
+
23
+ images_dir = out_dir / "images"
24
+ images_dir.mkdir(parents=True, exist_ok=True)
25
+ mapping: dict[int, Path] = {}
26
+ for step in session.steps:
27
+ if not step.screenshot:
28
+ continue
29
+ raw = session_dir / step.screenshot
30
+ if not raw.exists():
31
+ continue
32
+ img = Image.open(raw)
33
+ annotated = annotate(img, step.rel_x, step.rel_y, step.index)
34
+ path = images_dir / f"step-{step.index:02d}.png"
35
+ annotated.save(path)
36
+ mapping[step.index] = path
37
+ return mapping
38
+
39
+
40
+ def export_session(
41
+ session: Session,
42
+ session_dir: Path,
43
+ fmt: str,
44
+ out_dir: Path | None = None,
45
+ embed_images: bool = True,
46
+ ) -> list[Path]:
47
+ """把 Session 导出为指定格式(fmt=all 时导出全部三种)。"""
48
+ from .docx_export import export_docx
49
+ from .html_export import export_html
50
+ from .markdown_export import export_markdown
51
+
52
+ out_dir = out_dir or session_dir / "export"
53
+ out_dir.mkdir(parents=True, exist_ok=True)
54
+ fmts = list(FORMATS) if fmt == "all" else [fmt]
55
+ unknown = set(fmts) - set(FORMATS)
56
+ if unknown:
57
+ raise ValueError(f"未知导出格式:{'、'.join(sorted(unknown))}(支持 {FORMATS})")
58
+
59
+ images = prepare_images(session, session_dir, out_dir)
60
+ results: list[Path] = []
61
+ for f in fmts:
62
+ if f == "md":
63
+ results.append(export_markdown(session, images, out_dir))
64
+ elif f == "html":
65
+ results.append(export_html(session, images, out_dir, embed_images))
66
+ elif f == "docx":
67
+ results.append(export_docx(session, images, out_dir))
68
+ return results
@@ -0,0 +1,38 @@
1
+ """Word(docx)导出。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from docx import Document
8
+ from docx.shared import Inches
9
+
10
+ from ..models import Session
11
+
12
+
13
+ def export_docx(
14
+ session: Session, images: dict[int, Path], out_dir: Path
15
+ ) -> Path:
16
+ doc = Document()
17
+ doc.add_heading(session.title or "操作教程", level=0)
18
+ if session.intro:
19
+ doc.add_paragraph(session.intro)
20
+
21
+ for step in session.steps:
22
+ doc.add_heading(f"{step.index}. {step.title or f'步骤 {step.index}'}", level=2)
23
+ if step.description:
24
+ doc.add_paragraph(step.description)
25
+ typed = step.typed_text()
26
+ if typed:
27
+ p = doc.add_paragraph()
28
+ run = p.add_run(f"输入:{typed}")
29
+ run.italic = True
30
+ if step.has_secret():
31
+ doc.add_paragraph("(本步包含密码输入,内容已隐藏)")
32
+ img = images.get(step.index)
33
+ if img:
34
+ doc.add_picture(str(img), width=Inches(5.9))
35
+
36
+ path = out_dir / "guide.docx"
37
+ doc.save(path)
38
+ return path