galbook 0.0.0-stage → 1.2.4

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.
Files changed (61) hide show
  1. package/.agents/skills/galbook/SKILL.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +114 -0
  4. package/README.md +133 -3
  5. package/bin/galbook.js +53 -0
  6. package/cordis.patch.yml +30 -0
  7. package/dsh-plugin/README.md +49 -0
  8. package/dsh-plugin/install.py +190 -0
  9. package/galbook/__init__.py +15 -0
  10. package/galbook/__main__.py +138 -0
  11. package/galbook/build.py +89 -0
  12. package/galbook/cli.py +29 -0
  13. package/galbook/demo/fog-harbor/assets/cg_dawn_together.svg +1 -0
  14. package/galbook/demo/fog-harbor/assets/cg_old_letter.svg +1 -0
  15. package/galbook/demo/fog-harbor/assets/cg_ship.svg +1 -0
  16. package/galbook/demo/fog-harbor/assets/harbor_night.svg +1 -0
  17. package/galbook/demo/fog-harbor/assets/lamp_room.svg +1 -0
  18. package/galbook/demo/fog-harbor/assets/lamp_room_dawn.svg +1 -0
  19. package/galbook/demo/fog-harbor/assets/path_tide.svg +1 -0
  20. package/galbook/demo/fog-harbor/assets/shio_normal.svg +1 -0
  21. package/galbook/demo/fog-harbor/assets/shio_smile.svg +1 -0
  22. package/galbook/demo/fog-harbor/assets/shio_surprised.svg +1 -0
  23. package/galbook/demo/fog-harbor/assets/warehouse.svg +1 -0
  24. package/galbook/demo/fog-harbor/story.gal +97 -0
  25. package/galbook/demo/fog-harbor-en/assets/cg_dawn_together.svg +1 -0
  26. package/galbook/demo/fog-harbor-en/assets/cg_old_letter.svg +1 -0
  27. package/galbook/demo/fog-harbor-en/assets/cg_ship.svg +1 -0
  28. package/galbook/demo/fog-harbor-en/assets/harbor_night.svg +1 -0
  29. package/galbook/demo/fog-harbor-en/assets/lamp_room.svg +1 -0
  30. package/galbook/demo/fog-harbor-en/assets/lamp_room_dawn.svg +1 -0
  31. package/galbook/demo/fog-harbor-en/assets/path_tide.svg +1 -0
  32. package/galbook/demo/fog-harbor-en/assets/shio_normal.svg +1 -0
  33. package/galbook/demo/fog-harbor-en/assets/shio_smile.svg +1 -0
  34. package/galbook/demo/fog-harbor-en/assets/shio_surprised.svg +1 -0
  35. package/galbook/demo/fog-harbor-en/assets/warehouse.svg +1 -0
  36. package/galbook/demo/fog-harbor-en/story.gal +97 -0
  37. package/galbook/demo_boot.py +63 -0
  38. package/galbook/engine.py +325 -0
  39. package/galbook/expr.py +107 -0
  40. package/galbook/i18n.py +85 -0
  41. package/galbook/importers/__init__.py +106 -0
  42. package/galbook/importers/common.py +94 -0
  43. package/galbook/importers/galscript.py +218 -0
  44. package/galbook/importers/kag.py +326 -0
  45. package/galbook/importers/legacy.py +122 -0
  46. package/galbook/importers/nscripter.py +314 -0
  47. package/galbook/importers/qlie.py +498 -0
  48. package/galbook/importers/renpy.py +333 -0
  49. package/galbook/importers/textdump.py +128 -0
  50. package/galbook/ir.py +266 -0
  51. package/galbook/mcp_server.py +102 -0
  52. package/galbook/play.py +374 -0
  53. package/galbook/server.py +199 -0
  54. package/galbook/static/play.html +151 -0
  55. package/galbook/store.py +160 -0
  56. package/galbook/tools.py +99 -0
  57. package/galbook/web.py +316 -0
  58. package/icon.svg +21 -0
  59. package/locale/en.json +6 -0
  60. package/locale/zh.json +6 -0
  61. package/package.json +58 -6
@@ -0,0 +1,333 @@
1
+ """Ren'Py(.rpy 源码)→ galbook/1。新游戏最常见的引擎之一;只吃 .rpy 源码(.rpyc 先用 unrpyc 反编译)。
2
+
3
+ 认的写法:
4
+ define e = Character("名字", …) 说话人
5
+ image eileen happy = "eileen_happy.png" 图片名 → 文件
6
+ default x = 0 / define persistent… 初始变量(persistent.* = 系统变量 → s.*)
7
+ label name: 标签(全局,跨文件)
8
+ "旁白" / e "台词" / e happy "台词" / "名字" "台词"
9
+ menu: / menu name: (可选一句提问)+ "选项" [if 条件]: 块
10
+ jump x / call x / return / pass
11
+ $ x = 1 / $ persistent.y = True
12
+ if 条件: / elif 条件: / else:
13
+ scene bg room / show eileen happy [at left] / hide eileen / play music "x"
14
+ 其它块(python:/init:/screen/transform/style/ATL)整块跳过。
15
+ """
16
+ import os
17
+ import re
18
+
19
+ from .common import SceneB, Assets, read_text, is_h_title
20
+
21
+ STR = r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\''
22
+ SAY = re.compile(r"^(?:(%s)|([A-Za-z_]\w*)(?:\s+[A-Za-z_][\w\s]*?)?)\s+(%s)(?:\s+with\s+\w+)?\s*$" % (STR, STR))
23
+ NARR = re.compile(r"^(%s)(?:\s+with\s+\w+)?\s*$" % STR)
24
+ SKIP_BLOCK = re.compile(r"^(init\b.*|python\b.*|screen\b.*|transform\b.*|style\b.*|translate\b.*|image\s+[^=]+:\s*$|"
25
+ r"layeredimage\b.*|testcase\b.*)$")
26
+
27
+
28
+ def _unq(s):
29
+ s = s.strip()
30
+ if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]:
31
+ s = s[1:-1]
32
+ return s.replace('\\"', '"').replace("\\'", "'").replace("\\n", "\n")
33
+
34
+
35
+ def conv_expr(e):
36
+ e = e.strip()
37
+ parts = re.split(r"(%s)" % STR, e)
38
+ out = []
39
+ for i, p in enumerate(parts):
40
+ if i % 2:
41
+ out.append(p)
42
+ continue
43
+ p = re.sub(r"\bpersistent\.(\w+)", r"s.\1", p)
44
+ p = re.sub(r"\bTrue\b", "true", p)
45
+ p = re.sub(r"\bFalse\b", "false", p)
46
+ p = re.sub(r"\bNone\b", "0", p)
47
+
48
+ def rep(m):
49
+ w = m.group(0)
50
+ if w in ("and", "or", "not", "true", "false", "s", "f"):
51
+ return w
52
+ return "f." + w
53
+ p = re.sub(r"(?<![\w.])(?!s\.)([A-Za-z_]\w*)(?![\w(])", rep, p)
54
+ p = p.replace("f.s.", "s.")
55
+ out.append(p)
56
+ return "".join(out)
57
+
58
+
59
+ class _Line(object):
60
+ __slots__ = ("ind", "text", "no")
61
+
62
+ def __init__(self, ind, text, no):
63
+ self.ind, self.text, self.no = ind, text, no
64
+
65
+
66
+ def _lines(src):
67
+ out = []
68
+ for no, raw in enumerate(src.splitlines(), 1):
69
+ if not raw.strip():
70
+ continue
71
+ # 去掉行尾注释(不在字符串里的 #)
72
+ s, q, buf = raw.rstrip(), None, []
73
+ for ch in s:
74
+ if q:
75
+ buf.append(ch)
76
+ if ch == q:
77
+ q = None
78
+ elif ch in "\"'":
79
+ q = ch
80
+ buf.append(ch)
81
+ elif ch == "#":
82
+ break
83
+ else:
84
+ buf.append(ch)
85
+ t = "".join(buf).rstrip()
86
+ if not t.strip():
87
+ continue
88
+ ind = len(t) - len(t.lstrip(" \t").replace("\t", " ")) if "\t" in t else len(t) - len(t.lstrip())
89
+ out.append(_Line(ind, t.strip(), no))
90
+ return out
91
+
92
+
93
+ class Importer(object):
94
+ def __init__(self, src, assets=None, entry="start"):
95
+ self.src, self.assets, self.entry = src, assets or Assets(), entry
96
+ self.chars, self.images, self.defaults = {}, {}, []
97
+ self.label_scene = {}
98
+ self.scenes = {}
99
+ self.warnings = []
100
+ self.unknown = {}
101
+ self.stats = {"files": 0, "says": 0, "choices": 0, "options": 0, "jumps": 0, "calls": 0, "sets": 0, "ifs": 0}
102
+ self.system_vars = set()
103
+ self.n = 0
104
+
105
+ def build(self):
106
+ files = []
107
+ for dp, _, fns in os.walk(self.src):
108
+ for fn in sorted(fns):
109
+ if fn.endswith(".rpy"):
110
+ files.append(os.path.join(dp, fn))
111
+ if not files:
112
+ raise ValueError("没找到 .rpy")
113
+ parsed = [(os.path.splitext(os.path.relpath(p, self.src))[0].replace(os.sep, "/"), _lines(read_text(p))) for p in files]
114
+ for sid, ls in parsed: # 第一遍:角色、图片、默认值、标签归属
115
+ for ln in ls:
116
+ if ln.ind == 0:
117
+ self._decl(ln.text, sid)
118
+ for sid, ls in parsed:
119
+ self.stats["files"] += 1
120
+ b = SceneB(sid, sid, tags=["h"] if is_h_title(sid) else [])
121
+ self._block(b, ls, 0, len(ls), top=True)
122
+ b.add("end", kind="title", name="%s 走到文件末尾(Ren'Py 回到主菜单)" % sid)
123
+ self.scenes[sid] = b.data()
124
+ if self.entry not in self.label_scene:
125
+ raise ValueError("没有入口标签 label %s" % self.entry)
126
+ st = SceneB("__start__", "开始", tags=["system"])
127
+ st.add("label", name="top")
128
+ for name, val in self.defaults:
129
+ st.add("set", expr="%s = %s" % (name, val))
130
+ st.add("jump", scene=self.label_scene[self.entry], label=self.entry)
131
+ self.scenes["__start__"] = st.data()
132
+ meta = {"format": "galbook/1", "title": "", "engine": "renpy", "importer": "galbook-renpy-1",
133
+ "entry": {"scene": "__start__", "label": "top"},
134
+ "scenes": {k: {"title": v["title"], "tags": v["tags"]} for k, v in self.scenes.items()},
135
+ "routes": [], "system_vars": sorted(self.system_vars),
136
+ "stats": dict(self.stats, unknown=sorted(self.unknown.items(), key=lambda x: -x[1])[:20],
137
+ assets=self.assets.report()),
138
+ "warnings": self.warnings[:200]}
139
+ return meta, self.scenes
140
+
141
+ def _decl(self, t, sid):
142
+ m = re.match(r"^define\s+([A-Za-z_]\w*)\s*=\s*(?:Character|DynamicCharacter)\s*\(\s*(?:_\()?\s*(%s|None)" % STR, t)
143
+ if m:
144
+ self.chars[m.group(1)] = "" if m.group(2) == "None" else _unq(m.group(2))
145
+ return
146
+ m = re.match(r"^image\s+([^=]+?)\s*=\s*(%s)" % STR, t)
147
+ if m:
148
+ self.images[m.group(1).strip()] = _unq(m.group(2))
149
+ return
150
+ m = re.match(r"^(?:default|define)\s+(persistent\.)?([A-Za-z_]\w*)\s*=\s*(.+)$", t)
151
+ if m and "Character" not in t:
152
+ scope = "s." if m.group(1) else "f."
153
+ try:
154
+ val = conv_expr(m.group(3))
155
+ if scope == "s.":
156
+ self.system_vars.add(m.group(2))
157
+ val = "%s%s == 0 && %s || %s%s" % (scope, m.group(2), val, scope, m.group(2)) if False else val
158
+ if scope == "f.":
159
+ self.defaults.append((scope + m.group(2), val))
160
+ except Exception: # noqa
161
+ pass
162
+ return
163
+ m = re.match(r"^label\s+([A-Za-z_][\w.]*)\s*(\(.*\))?\s*:", t)
164
+ if m:
165
+ self.label_scene.setdefault(m.group(1), sid)
166
+
167
+ def _img(self, name):
168
+ name = name.strip()
169
+ f = self.images.get(name)
170
+ if f:
171
+ return self.assets.find(f) or f
172
+ return self.assets.find(name.replace(" ", "_")) or self.assets.find(name) or ("image:" + name)
173
+
174
+ def _end_of(self, ls, i, j):
175
+ ind = ls[i].ind
176
+ k = i + 1
177
+ while k < j and ls[k].ind > ind:
178
+ k += 1
179
+ return k
180
+
181
+ def _lab(self, kind):
182
+ self.n += 1
183
+ return "__%s%d" % (kind, self.n)
184
+
185
+ def _block(self, b, ls, i, j, top=False):
186
+ while i < j:
187
+ ln = ls[i]
188
+ t = ln.text
189
+ end = self._end_of(ls, i, j)
190
+ if top and ln.ind == 0 and not t.startswith("label "):
191
+ i = end
192
+ continue
193
+ m = re.match(r"^label\s+([A-Za-z_][\w.]*)", t)
194
+ if m:
195
+ b.add("label", name=m.group(1))
196
+ self._block(b, ls, i + 1, end)
197
+ i = end
198
+ continue
199
+ if SKIP_BLOCK.match(t) or t.startswith("python:") or t.startswith("$ python"):
200
+ i = end
201
+ continue
202
+ if re.match(r"^menu(\s+[A-Za-z_]\w*)?\s*:", t):
203
+ self._menu(b, ls, i, end)
204
+ i = end
205
+ continue
206
+ m = re.match(r"^(if|elif|else)\b\s*(.*?)\s*:$", t)
207
+ if m and m.group(1) == "if":
208
+ k = i
209
+ done = self._lab("ifend")
210
+ while k < j:
211
+ mm = re.match(r"^(if|elif|else)\b\s*(.*?)\s*:$", ls[k].text)
212
+ if not mm or ls[k].ind != ln.ind or (k != i and mm.group(1) == "if"):
213
+ break
214
+ e2 = self._end_of(ls, k, j)
215
+ nxt = self._lab("ifnext")
216
+ if mm.group(1) != "else":
217
+ b.add("jump", label=nxt, **{"if": "!(%s)" % conv_expr(mm.group(2))})
218
+ self.stats["ifs"] += 1
219
+ self._block(b, ls, k + 1, e2)
220
+ b.add("jump", label=done)
221
+ b.add("label", name=nxt)
222
+ k = e2
223
+ b.add("label", name=done)
224
+ i = k
225
+ continue
226
+ self._stmt(b, t)
227
+ i = end if end > i + 1 and t.endswith(":") else i + 1
228
+
229
+ def _menu(self, b, ls, i, end):
230
+ prompt = ""
231
+ opts = []
232
+ done = self._lab("menuend")
233
+ k = i + 1
234
+ bodies = []
235
+ while k < end:
236
+ t = ls[k].text
237
+ e2 = self._end_of(ls, k, end)
238
+ m = re.match(r"^(%s)\s*(?:if\s+(.+?))?\s*:$" % STR, t)
239
+ if m:
240
+ lb = self._lab("opt")
241
+ o = {"text": _unq(m.group(1)), "goto": {"label": lb}}
242
+ if m.group(2):
243
+ o["if"] = conv_expr(m.group(2))
244
+ opts.append(o)
245
+ bodies.append((lb, k + 1, e2))
246
+ elif SAY.match(t) or NARR.match(t):
247
+ self._stmt(b, t) # 菜单的提问句照常显示
248
+ prompt = _unq((SAY.match(t) or NARR.match(t)).group(3 if SAY.match(t) else 1))
249
+ elif t.startswith("set "):
250
+ pass
251
+ else:
252
+ self.unknown["menu:" + t.split()[0]] = self.unknown.get("menu:" + t.split()[0], 0) + 1
253
+ k = e2
254
+ b.add("choice", id=self._lab("menu"), prompt=prompt, options=opts)
255
+ self.stats["choices"] += 1
256
+ self.stats["options"] += len(opts)
257
+ for lb, s, e in bodies:
258
+ b.add("label", name=lb)
259
+ self._block(b, ls, s, e)
260
+ b.add("jump", label=done)
261
+ b.add("label", name=done)
262
+
263
+ def _stmt(self, b, t):
264
+ m = NARR.match(t)
265
+ if m:
266
+ b.add("say", who="", text=_unq(m.group(1)))
267
+ self.stats["says"] += 1
268
+ return
269
+ m = SAY.match(t)
270
+ if m and (m.group(1) or m.group(2) in self.chars or m.group(2) not in
271
+ ("scene", "show", "hide", "play", "stop", "queue", "voice", "jump", "call", "return", "with", "window", "pause", "nvl")):
272
+ who = _unq(m.group(1)) if m.group(1) else self.chars.get(m.group(2), m.group(2))
273
+ b.add("say", who=who, text=_unq(m.group(3)))
274
+ self.stats["says"] += 1
275
+ return
276
+ w = t.split()
277
+ k = w[0] if w else ""
278
+ if k == "jump":
279
+ if w[1] == "expression":
280
+ self.warnings.append("jump expression 动态跳转没法静态解析:%s" % t)
281
+ return
282
+ sc = self.label_scene.get(w[1])
283
+ if sc is None:
284
+ self.warnings.append("跳到不存在的标签 %s" % w[1])
285
+ return
286
+ b.add("jump", scene=None if sc == b.id else sc, label=w[1])
287
+ self.stats["jumps"] += 1
288
+ elif k == "call":
289
+ if len(w) > 1 and w[1] != "screen" and w[1] in self.label_scene:
290
+ sc = self.label_scene[w[1]]
291
+ b.add("call", scene=None if sc == b.id else sc, label=w[1])
292
+ self.stats["calls"] += 1
293
+ elif k == "return":
294
+ b.add("ret")
295
+ b.add("end", kind="title", how="return(回到主菜单)")
296
+ elif k == "$":
297
+ e = t[1:].strip()
298
+ m2 = re.match(r"^(persistent\.)?([A-Za-z_]\w*)\s*(\+=|-=|=)\s*(.+)$", e)
299
+ if m2:
300
+ scope = "s." if m2.group(1) else "f."
301
+ if scope == "s.":
302
+ self.system_vars.add(m2.group(2))
303
+ b.add("set", expr="%s%s %s %s" % (scope, m2.group(2), m2.group(3), conv_expr(m2.group(4))))
304
+ self.stats["sets"] += 1
305
+ else:
306
+ self.unknown["$"] = self.unknown.get("$", 0) + 1
307
+ elif k == "scene":
308
+ nm = re.sub(r"\s+(with|at|onlayer|behind|as)\b.*$", "", t[5:].strip())
309
+ b.add("sprites_clear")
310
+ b.add("bg", img=self._img(nm) if nm else None)
311
+ elif k == "show":
312
+ nm = re.sub(r"\s+(with|at|onlayer|behind|zorder|as)\b.*$", "", t[4:].strip())
313
+ pos = re.search(r"\bat\s+(\w+)", t)
314
+ if nm.startswith("bg "):
315
+ b.add("bg", img=self._img(nm))
316
+ elif nm and not nm.startswith("screen"):
317
+ b.add("sprite", name=nm.split()[0], img=self._img(nm), pos=pos.group(1) if pos else "center")
318
+ elif k == "hide":
319
+ if len(w) > 1:
320
+ b.add("sprite", name=w[1], img=None)
321
+ elif k == "play" and len(w) > 2 and w[1] == "music":
322
+ b.add("bgm", name=_unq(" ".join(w[2:]).split(" fade")[0]))
323
+ elif k == "stop" and len(w) > 1 and w[1] == "music":
324
+ b.add("bgm", name=None)
325
+ elif k in ("pass", "with", "pause", "window", "voice", "play", "stop", "queue", "nvl", "centered", "extend",
326
+ "define", "default", "image", "init", "python", "camera", "renpy", "show_screen"):
327
+ pass
328
+ else:
329
+ self.unknown[k] = self.unknown.get(k, 0) + 1
330
+
331
+
332
+ def build(src, assets=None, entry="start", **_):
333
+ return Importer(src, assets, entry or "start").build()
@@ -0,0 +1,128 @@
1
+ """文本导出(线性)→ galbook/1:给「暂时没有专用导入器」的引擎兜底。
2
+
3
+ 很多引擎已经有成熟的文本抽取工具,抽出来的东西能直接喂进来,先「能读」:
4
+ * VNTextPatch(arcusmaximus,MIT)`extractlocal <游戏脚本目录> <输出目录>` 产出的 .json
5
+ 格式:[{"name": "灯", "message": "「…」"}, {"names": ["A","B"], "message": "…"}, {"message": "旁白"}]
6
+ 它支持 AdvHD / Artemis / BGI(Ethornell) / CatSystem2 / Majiro / Musica / RealLive / Softpal /
7
+ SystemNNN / YU-RIS / Silky's 等 20 多种引擎。
8
+ * 任意 .csv / .tsv:表头里有 name/speaker/character/who/名前/名字 之一 + text/message/line/original/原文/台词 之一
9
+ * .txt:一行一句,「名字:台词」「名字「台词」」或旁白(要 --engine textdump 显式指定,避免和 NScripter 0.txt 混)
10
+
11
+ 限制(如实说):这些工具只抽文字,**不抽选项的跳转关系**(选项文字会混在台词里),所以导出来是一条直线,mode=linear。
12
+ 要真分支,需要该引擎的专用导入器(见 docs/IMPORTERS.md,欢迎贡献)。
13
+ 文件顺序:目录里有 order.txt 就按它(一行一个文件名),否则按文件名自然排序。"""
14
+ import csv
15
+ import io
16
+ import json
17
+ import os
18
+ import re
19
+
20
+ from .common import SceneB, Assets, read_text
21
+
22
+ NAME = "textdump"
23
+ NAME_KEYS = ("name", "speaker", "character", "chara", "who", "名前", "名字", "角色", "说话人")
24
+ TEXT_KEYS = ("message", "text", "line", "original", "source", "原文", "台词", "本文", "セリフ")
25
+ SAY = re.compile(r"^([^\s::「『。,、!?!?,…—]{1,12})(?::|:\s)\s*(.+)$")
26
+ SAY_Q = re.compile(r"^([^\s::「『((。,、!?!?,…—]{1,12})([「『].*)$")
27
+
28
+
29
+ def _natkey(s):
30
+ return [int(x) if x.isdigit() else x.lower() for x in re.split(r"(\d+)", s)]
31
+
32
+
33
+ def _is_vntp_json(path):
34
+ try:
35
+ with open(path, encoding="utf-8-sig") as f:
36
+ d = json.load(f)
37
+ return isinstance(d, list) and (not d or (isinstance(d[0], dict) and "message" in d[0]))
38
+ except Exception: # noqa
39
+ return False
40
+
41
+
42
+ def detect(src):
43
+ js = []
44
+ for dp, _, fn in os.walk(src):
45
+ js += [os.path.join(dp, f) for f in fn if f.lower().endswith(".json")]
46
+ for f in fn:
47
+ if f.lower().endswith((".csv", ".tsv")):
48
+ head = read_text(os.path.join(dp, f)).splitlines()[:1]
49
+ if head and any(k in head[0].lower() for k in TEXT_KEYS):
50
+ return True, "有带表头的台词表(.csv/.tsv):按线性文本导入"
51
+ if js and any(_is_vntp_json(p) for p in js[:5]):
52
+ return True, "有 VNTextPatch 格式的 .json 文本导出:按线性文本导入(没有分支)"
53
+ return False, ""
54
+
55
+
56
+ def _rows(path):
57
+ low = path.lower()
58
+ if low.endswith(".json"):
59
+ with open(path, encoding="utf-8-sig") as f:
60
+ d = json.load(f)
61
+ if not isinstance(d, list):
62
+ raise ValueError("%s 不是 JSON 数组" % path)
63
+ for e in d:
64
+ if isinstance(e, dict):
65
+ who = e.get("name") or "・".join(e.get("names") or []) or ""
66
+ yield who, e.get("message") or ""
67
+ elif isinstance(e, str):
68
+ yield "", e
69
+ elif low.endswith((".csv", ".tsv")):
70
+ txt = read_text(path)
71
+ rd = csv.reader(io.StringIO(txt), delimiter="\t" if low.endswith(".tsv") else ",")
72
+ head = [h.strip().lower() for h in next(rd, [])]
73
+ ni = next((head.index(k) for k in NAME_KEYS if k in head), None)
74
+ ti = next((head.index(k) for k in TEXT_KEYS if k in head), None)
75
+ if ti is None:
76
+ raise ValueError("%s 表头里找不到台词列(认:%s)" % (path, "/".join(TEXT_KEYS)))
77
+ for row in rd:
78
+ if len(row) > ti:
79
+ yield (row[ni].strip() if ni is not None and len(row) > ni else ""), row[ti]
80
+ else:
81
+ for line in read_text(path).splitlines():
82
+ s = line.strip()
83
+ if not s:
84
+ continue
85
+ m = SAY.match(s) or SAY_Q.match(s)
86
+ yield (m.group(1), m.group(2)) if m else ("", s)
87
+
88
+
89
+ def build(src, assets=None, **_):
90
+ assets = assets or Assets()
91
+ exts = (".json", ".csv", ".tsv", ".txt")
92
+ files = []
93
+ for dp, _, fn in os.walk(src):
94
+ files += [os.path.relpath(os.path.join(dp, f), src) for f in fn if f.lower().endswith(exts) and f != "order.txt"]
95
+ op = os.path.join(src, "order.txt")
96
+ if os.path.isfile(op):
97
+ want = [l.strip() for l in read_text(op).splitlines() if l.strip()]
98
+ missing = [w for w in want if w not in files]
99
+ if missing:
100
+ raise ValueError("order.txt 里列的文件不存在:%s" % "、".join(missing[:5]))
101
+ files = want
102
+ else:
103
+ files.sort(key=_natkey)
104
+ if not files:
105
+ raise ValueError("%s 里没有 .json/.csv/.tsv/.txt 文本" % src)
106
+ scenes, order, stats = {}, [], {"files": len(files), "says": 0, "choices": 0, "options": 0, "jumps": 0}
107
+ for rel in files:
108
+ sid = re.sub(r"[\\/]", "__", os.path.splitext(rel)[0])
109
+ b = SceneB(sid, os.path.splitext(os.path.basename(rel))[0])
110
+ b.add("label", name="top")
111
+ for who, text in _rows(os.path.join(src, rel)):
112
+ text = (text or "").replace("\r\n", "\n").strip()
113
+ if text:
114
+ b.add("say", who=who.strip(), text=text)
115
+ stats["says"] += 1
116
+ scenes[sid] = b
117
+ order.append(sid)
118
+ for a, c in zip(order, order[1:]):
119
+ scenes[a].add("jump", scene=c, label="top")
120
+ stats["jumps"] += 1
121
+ scenes[order[-1]].add("end", kind="ending", name="全文完")
122
+ data = {k: v.data() for k, v in scenes.items()}
123
+ meta = {"format": "galbook/1", "title": "", "engine": "textdump", "importer": "galbook-textdump-1", "mode": "linear",
124
+ "entry": {"scene": order[0], "label": "top"},
125
+ "scenes": {k: {"title": v["title"], "tags": v["tags"]} for k, v in data.items()},
126
+ "routes": [], "system_vars": [], "stats": dict(stats, unknown=[], assets=assets.report()),
127
+ "warnings": ["线性文本:原作的选项文字(如果有)混在台词里,没有分支"]}
128
+ return meta, data