itol-cli 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.
- itol_cli/__init__.py +8 -0
- itol_cli/account.py +161 -0
- itol_cli/aesthetics.py +248 -0
- itol_cli/cli.py +887 -0
- itol_cli/client.py +200 -0
- itol_cli/errors.py +56 -0
- itol_cli/learn.py +174 -0
- itol_cli/lint.py +250 -0
- itol_cli/render.py +137 -0
- itol_cli/templates.py +389 -0
- itol_cli/tree.py +507 -0
- itol_cli-0.2.0.dist-info/METADATA +97 -0
- itol_cli-0.2.0.dist-info/RECORD +16 -0
- itol_cli-0.2.0.dist-info/WHEEL +5 -0
- itol_cli-0.2.0.dist-info/entry_points.txt +2 -0
- itol_cli-0.2.0.dist-info/top_level.txt +1 -0
itol_cli/lint.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""itol lint — iTOL 注解文件本地校验器(差异化护城河:人/AI 手写模板的第一道关)。
|
|
2
|
+
|
|
3
|
+
校验规则基于官方模板实证(workflows/itol_research/agent2_formats.md + downloads/templates/):
|
|
4
|
+
- 首行关键字是模板锚(不是文件名!)
|
|
5
|
+
- 官方 CONNECTION 是单数;DOMAINS 不是 PROTEINDOMAINS
|
|
6
|
+
- 默认 SPACE 分隔;SEPARATOR 行可改 TAB/COMMA
|
|
7
|
+
- 内部节点一律 LCA 写法 ID1|ID2
|
|
8
|
+
- DATA 段前全是 KEY VALUE 控制行
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# 官方模板类型(首行关键字,单一权威)——以 30 个官方模板实测全集为准(2026-11-02 校准)
|
|
17
|
+
KNOWN_KEYWORDS = {
|
|
18
|
+
# 树本身控制
|
|
19
|
+
"TREE_COLORS", "DATASET_STYLE", "MULTI_STYLE", "LABELS", "POPUP_INFO", "COLLAPSE", "PRUNE", "SPACING",
|
|
20
|
+
"AUTO_TAXONOMY", "METADATA",
|
|
21
|
+
# 数据集
|
|
22
|
+
"DATASET_COLORSTRIP", "DATASET_BINARY", "DATASET_GRADIENT", "DATASET_HEATMAP",
|
|
23
|
+
"DATASET_SYMBOL", "DATASET_EXTERNALSHAPE", "DATASET_DOMAINS", "DATASET_SIMPLEBAR",
|
|
24
|
+
"DATASET_MULTIBAR", "DATASET_BOXPLOT", "DATASET_LINECHART", "DATASET_PIECHART",
|
|
25
|
+
"DATASET_ALIGNMENT", "DATASET_CONNECTION", "DATASET_IMAGE", "DATASET_TEXT",
|
|
26
|
+
"DATASET_ARROWS", "DATASET_RANGE", "DATASET_TANGLEGRAM", "DATASET_TIMESCALE",
|
|
27
|
+
}
|
|
28
|
+
# 常见写错/别名(给诊断建议)
|
|
29
|
+
ALIASES = {
|
|
30
|
+
"DATASET_CONNECTIONS": "DATASET_CONNECTION(官方是单数!)",
|
|
31
|
+
"DATASET_PROTEINDOMAINS": "DATASET_DOMAINS",
|
|
32
|
+
"DATASET_COLOR_STRIP": "DATASET_COLORSTRIP",
|
|
33
|
+
"TREE_COLOR": "TREE_COLORS",
|
|
34
|
+
}
|
|
35
|
+
SEPARATORS = {"SPACE": None, "TAB": "\t", "COMMA": ","} # None = 任意空白
|
|
36
|
+
# 官方允许 hex / rgb() / rgba()(COLORSTRIP 模板实证:rgba(0,255,0,0.5) 合法)
|
|
37
|
+
HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
|
|
38
|
+
RGB_RE = re.compile(r"^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*[\d.]+\s*)?\)$", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_color(s: str) -> bool:
|
|
42
|
+
return bool(HEX_RE.match(s) or RGB_RE.match(s))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LintIssue:
|
|
47
|
+
level: str # "error" | "warning"
|
|
48
|
+
line: int # 0-based;-1 = 文件级
|
|
49
|
+
message: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LintReport:
|
|
54
|
+
path: str
|
|
55
|
+
keyword: str = ""
|
|
56
|
+
issues: list = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def errors(self):
|
|
60
|
+
return [i for i in self.issues if i.level == "error"]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def warnings(self):
|
|
64
|
+
return [i for i in self.issues if i.level == "warning"]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def ok(self) -> bool:
|
|
68
|
+
return not self.errors
|
|
69
|
+
|
|
70
|
+
def add(self, level: str, line: int, message: str) -> None:
|
|
71
|
+
self.issues.append(LintIssue(level, line, message))
|
|
72
|
+
|
|
73
|
+
def summary(self) -> str:
|
|
74
|
+
head = f"{'✅' if self.ok else '❌'} {self.path} (类型: {self.keyword or '未知'})"
|
|
75
|
+
lines = [f" L{i.line + 1}: [{i.level}] {i.message}" for i in self.issues]
|
|
76
|
+
return "\n".join([head] + lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _detect_separator(header_lines: list[str]) -> tuple[str, str | None]:
|
|
80
|
+
for ln in header_lines:
|
|
81
|
+
parts = ln.split(None, 1)
|
|
82
|
+
if parts and parts[0].upper() == "SEPARATOR" and len(parts) == 2:
|
|
83
|
+
val = parts[1].strip().upper()
|
|
84
|
+
if val in SEPARATORS:
|
|
85
|
+
return val, SEPARATORS[val]
|
|
86
|
+
return val, None # 未知分隔符 → 按 SPACE 处理但告警
|
|
87
|
+
return "SPACE", None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def lint_file(path: Path) -> LintReport:
|
|
91
|
+
path = Path(path)
|
|
92
|
+
rep = LintReport(path=str(path))
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
rep.add("error", -1, "文件不存在")
|
|
95
|
+
return rep
|
|
96
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
97
|
+
lines = raw.splitlines()
|
|
98
|
+
|
|
99
|
+
# 1. 首行关键字
|
|
100
|
+
first = next((ln.strip() for ln in lines if ln.strip()), "")
|
|
101
|
+
if not first:
|
|
102
|
+
rep.add("error", -1, "空文件")
|
|
103
|
+
return rep
|
|
104
|
+
rep.keyword = first
|
|
105
|
+
if first not in KNOWN_KEYWORDS:
|
|
106
|
+
hint = ALIASES.get(first)
|
|
107
|
+
rep.add("error", 0, f"未知首行关键字 {first!r}" + (f"——你是不是想要 {hint}?" if hint else ""))
|
|
108
|
+
|
|
109
|
+
# 2. 找 DATA 段(官方模板示例形态可能连 DATA 标记行本身也被注释)
|
|
110
|
+
try:
|
|
111
|
+
data_idx = next(i for i, ln in enumerate(lines) if ln.strip().upper() == "DATA")
|
|
112
|
+
except StopIteration:
|
|
113
|
+
commented_data = next((i for i, ln in enumerate(lines) if ln.strip().lstrip("#").strip().upper() == "DATA"), None)
|
|
114
|
+
if commented_data is not None:
|
|
115
|
+
data_idx = commented_data
|
|
116
|
+
rep.add("warning", data_idx, "DATA 标记行被注释(官方模板示例形态)")
|
|
117
|
+
else:
|
|
118
|
+
rep.add("error", -1, "缺 DATA 段标记行(独立一行 'DATA')")
|
|
119
|
+
return rep
|
|
120
|
+
header = [ln for ln in lines[1:data_idx] if ln.strip() and not ln.strip().startswith("#")]
|
|
121
|
+
data = [ln for ln in lines[data_idx + 1:] if ln.strip() and not ln.strip().startswith("#")]
|
|
122
|
+
if not data:
|
|
123
|
+
# 官方模板形态:示例数据行全部被 # 注释——是模板示例不是真数据,降级 warning
|
|
124
|
+
commented = [ln for ln in lines[data_idx + 1:] if ln.strip().startswith("#")]
|
|
125
|
+
if commented:
|
|
126
|
+
rep.add("warning", data_idx, "DATA 段数据行全部被注释(官方模板示例形态;实数据文件须有非注释数据行)")
|
|
127
|
+
else:
|
|
128
|
+
rep.add("error", data_idx, "DATA 段为空")
|
|
129
|
+
|
|
130
|
+
# 3. 分隔符一致性
|
|
131
|
+
sep_name, sep = _detect_separator(header)
|
|
132
|
+
if sep_name not in SEPARATORS:
|
|
133
|
+
rep.add("warning", -1, f"未知 SEPARATOR {sep_name!r}(官方只有 SPACE/TAB/COMMA),按 SPACE 校验")
|
|
134
|
+
sep_name, sep = "SPACE", None
|
|
135
|
+
|
|
136
|
+
def split_row(ln: str) -> list[str]:
|
|
137
|
+
return ln.split(sep) if sep else ln.split()
|
|
138
|
+
|
|
139
|
+
# 4. 数据行校验(ALIGNMENT 的 DATA 是 FASTA 序列,不做表格列数检查)
|
|
140
|
+
widths = set()
|
|
141
|
+
if rep.keyword != "DATASET_ALIGNMENT":
|
|
142
|
+
for i, ln in enumerate(data):
|
|
143
|
+
row = split_row(ln)
|
|
144
|
+
lineno = data_idx + 1 + i
|
|
145
|
+
if len(row) < 2:
|
|
146
|
+
rep.add("error", lineno, f"数据行列数不足({len(row)} 列):{ln[:60]!r}")
|
|
147
|
+
continue
|
|
148
|
+
widths.add(len(row))
|
|
149
|
+
# 节点 ID 列非空 + 内部节点 LCA 写法检查
|
|
150
|
+
nid = row[0]
|
|
151
|
+
if not nid:
|
|
152
|
+
rep.add("error", lineno, "节点 ID 为空")
|
|
153
|
+
if sep == "," and "|" not in nid and " " in nid:
|
|
154
|
+
rep.add("error", lineno, f"COMMA 分隔下节点 ID 含空格:{nid!r}——内部节点请用 LCA 写法 ID1|ID2")
|
|
155
|
+
|
|
156
|
+
# 4.5 类型必填项(官方模板 MANDATORY SETTINGS 区实证:20/20 dataset_* 模板全有 DATASET_LABEL)
|
|
157
|
+
header_keys = {re.split(r"[\s,]+", ln, maxsplit=1)[0].upper() for ln in header}
|
|
158
|
+
if rep.keyword.startswith("DATASET_") and "DATASET_LABEL" not in header_keys:
|
|
159
|
+
rep.add("error", -1, f"{rep.keyword} 缺 MANDATORY 字段 DATASET_LABEL(官方模板必填)")
|
|
160
|
+
if rep.keyword == "DATASET_COLORSTRIP" and "COLOR" not in header_keys:
|
|
161
|
+
rep.add("warning", -1, "COLORSTRIP 建议带 COLOR(图例/默认色,官方模板 MANDATORY 区)")
|
|
162
|
+
if ("LEGEND_COLORS" in header_keys or "LEGEND_LABELS" in header_keys) and "LEGEND_SHAPES" not in header_keys:
|
|
163
|
+
rep.add("warning", -1, "图例规范=每行 shape+color+label:有 LEGEND_COLORS/LABELS 建议补 LEGEND_SHAPES")
|
|
164
|
+
|
|
165
|
+
# 5. 类型特定校验
|
|
166
|
+
if rep.keyword == "DATASET_COLORSTRIP":
|
|
167
|
+
for i, ln in enumerate(data):
|
|
168
|
+
row = split_row(ln)
|
|
169
|
+
if len(row) >= 2 and not _is_color(row[1]):
|
|
170
|
+
rep.add("error", data_idx + 1 + i, f"COLORSTRIP 第 2 列应为颜色(hex/rgb/rgba):{row[1]!r}")
|
|
171
|
+
if rep.keyword in ("DATASET_HEATMAP", "DATASET_MULTIBAR", "DATASET_BINARY"):
|
|
172
|
+
labels = None
|
|
173
|
+
for ln in header:
|
|
174
|
+
if ln.upper().startswith("FIELD_LABELS"):
|
|
175
|
+
labels = split_row(ln)[1:] if sep is None else ln.split(sep)[1].split(",")
|
|
176
|
+
if labels is None:
|
|
177
|
+
rep.add("warning", -1, f"{rep.keyword} 建议带 FIELD_LABELS(图例列名)")
|
|
178
|
+
elif widths and len(widths) == 1:
|
|
179
|
+
ncol = next(iter(widths)) - 1
|
|
180
|
+
if ncol != len(labels):
|
|
181
|
+
rep.add("error", -1, f"FIELD_LABELS 数({len(labels)})≠ 数据列数({ncol})")
|
|
182
|
+
if rep.keyword == "DATASET_CONNECTION":
|
|
183
|
+
for i, ln in enumerate(data):
|
|
184
|
+
row = split_row(ln)
|
|
185
|
+
lineno = data_idx + 1 + i
|
|
186
|
+
if len(row) < 2:
|
|
187
|
+
rep.add("error", lineno, f"CONNECTION 行需 ≥2 列(node1 node2…):{ln[:60]!r}")
|
|
188
|
+
continue
|
|
189
|
+
if "|" in row[0] or "|" in row[1]:
|
|
190
|
+
rep.add("warning", lineno, "CONNECTION 的节点通常是叶名对,LCA 写法确认是否符合预期")
|
|
191
|
+
if len(row) >= 3 and row[2]:
|
|
192
|
+
try:
|
|
193
|
+
float(row[2])
|
|
194
|
+
except ValueError:
|
|
195
|
+
rep.add("error", lineno, f"CONNECTION 第 3 列 WIDTH 须为数字:{row[2]!r}")
|
|
196
|
+
if len(row) >= 4 and row[3] and not _is_color(row[3]):
|
|
197
|
+
rep.add("error", lineno, f"CONNECTION 颜色非 hex/rgb/rgba:{row[3]!r}")
|
|
198
|
+
if len(row) >= 5 and row[4] and row[4] not in ("normal", "dashed", "dotted"):
|
|
199
|
+
rep.add("warning", lineno, f"CONNECTION 线型 {row[4]!r} 不在 normal/dashed/dotted 内")
|
|
200
|
+
if rep.keyword == "DATASET_RANGE":
|
|
201
|
+
for i, ln in enumerate(data):
|
|
202
|
+
row = split_row(ln)
|
|
203
|
+
if len(row) < 2:
|
|
204
|
+
rep.add("error", data_idx + 1 + i, f"RANGE 行需 ≥2 列(start_node,end_node…):{ln[:60]!r}")
|
|
205
|
+
if rep.keyword == "DATASET_TIMESCALE":
|
|
206
|
+
for i, ln in enumerate(data):
|
|
207
|
+
row = split_row(ln)
|
|
208
|
+
lineno = data_idx + 1 + i
|
|
209
|
+
if len(row) < 2:
|
|
210
|
+
rep.add("error", lineno, f"TIMESCALE 行需 ≥2 列(start end…):{ln[:60]!r}")
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
s, e = float(row[0]), float(row[1])
|
|
214
|
+
if s <= e:
|
|
215
|
+
rep.add("error", lineno, f"TIMESCALE 约定 start(老)>end(新),此行 {s}<={e}")
|
|
216
|
+
except ValueError:
|
|
217
|
+
rep.add("error", lineno, f"TIMESCALE 区间端点须为数字:{row[:2]!r}")
|
|
218
|
+
if rep.keyword == "DATASET_DOMAINS":
|
|
219
|
+
for i, ln in enumerate(data):
|
|
220
|
+
row = split_row(ln)
|
|
221
|
+
lineno = data_idx + 1 + i
|
|
222
|
+
if len(row) < 3:
|
|
223
|
+
rep.add("error", lineno, f"DOMAINS 数据行需 ≥3 列(ID/蛋白长度/域定义…):{ln[:60]!r}")
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
plen = int(row[1])
|
|
227
|
+
except ValueError:
|
|
228
|
+
rep.add("error", lineno, f"DOMAINS 第 2 列须为蛋白长度整数:{row[1]!r}")
|
|
229
|
+
continue
|
|
230
|
+
for d in row[2:]:
|
|
231
|
+
parts = d.split("|")
|
|
232
|
+
if len(parts) != 5:
|
|
233
|
+
rep.add("error", lineno, f"域定义需 5 段 SHAPE|START|END|COLOR|LABEL:{d!r}")
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
s, e = int(parts[1]), int(parts[2])
|
|
237
|
+
if s >= e:
|
|
238
|
+
rep.add("error", lineno, f"域 START>=END:{d!r}")
|
|
239
|
+
elif e > plen:
|
|
240
|
+
rep.add("error", lineno, f"域 END>{plen}(超蛋白长度):{d!r}")
|
|
241
|
+
except ValueError:
|
|
242
|
+
rep.add("error", lineno, f"域 START/END 须为整数:{d!r}")
|
|
243
|
+
if not _is_color(parts[3]):
|
|
244
|
+
rep.add("error", lineno, f"域颜色非 hex/rgb/rgba:{parts[3]!r}")
|
|
245
|
+
|
|
246
|
+
return rep
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def lint_paths(paths: list[Path]) -> list[LintReport]:
|
|
250
|
+
return [lint_file(p) for p in paths]
|
itol_cli/render.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""浏览器自动化渲染:Xvfb 虚拟显示 + playwright headful Chrome 驱动 iTOL 树页出带注解图。
|
|
2
|
+
|
|
3
|
+
突破点(2026-11-04):iTOL 渲染引擎全在浏览器端,无头 SwiftShader 跑不动树页(~1MB JS),
|
|
4
|
+
但 Xvfb 虚拟显示 + headful Chrome 可正常加载并 exportSVG。这是免费档出带注解图的唯一程序化路。
|
|
5
|
+
|
|
6
|
+
坑位:DATASET_* 注解必须含 DATASET_LABEL 头行,否则 iTOL handler 静默拒绝注册
|
|
7
|
+
(错误只进 UI 解析日志面板,无异常无返回值)。本模块注入前自动补 + 注册后校验。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .errors import ItolError
|
|
20
|
+
|
|
21
|
+
CHROME = "/usr/bin/google-chrome"
|
|
22
|
+
|
|
23
|
+
# 显示模式 → l1I1l.III111I 键值(1矩形/2环形/3无根)。JS 逆向实证:llII1ll(mode) 是官方设置函数
|
|
24
|
+
# (@26599,全代码库反复调用);@538204 实证 <100 叶强制矩形,≥100 叶默认环形。
|
|
25
|
+
DISPLAY_MODES = {"rect": 1, "circular": 2, "unrooted": 3}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_display(argv: list[str]) -> None:
|
|
29
|
+
"""有 DISPLAY 直接返回;无则 xvfb-run 重跑本命令(结束后 SystemExit 替换当前进程)。"""
|
|
30
|
+
if os.environ.get("DISPLAY"):
|
|
31
|
+
return
|
|
32
|
+
if not shutil.which("xvfb-run"):
|
|
33
|
+
raise ItolError("IT006", "itol render 需显示环境或 xvfb(虚拟显示)",
|
|
34
|
+
diagnosis="apt install xvfb;或改在有显示的环境跑")
|
|
35
|
+
code = subprocess.call(["xvfb-run", "-a", "--server-args=-screen 0 1600x1200x24",
|
|
36
|
+
sys.executable, "-m", "itol_cli.cli", *argv])
|
|
37
|
+
raise SystemExit(code)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ensure_dataset_label(path: str) -> str:
|
|
41
|
+
"""DATASET_* 文件缺 DATASET_LABEL 则补(生成器本应已带,这里是安全网)。返回文件内容。"""
|
|
42
|
+
txt = Path(path).read_text(encoding="utf-8")
|
|
43
|
+
lines = txt.splitlines()
|
|
44
|
+
if not lines or not lines[0].startswith("DATASET_"):
|
|
45
|
+
return txt # TREE_COLORS/LABELS 等非数据集类不需要
|
|
46
|
+
if "DATASET_LABEL" in "\n".join(lines[:20]):
|
|
47
|
+
return txt # 已有
|
|
48
|
+
lines.insert(1, f"DATASET_LABEL\t{Path(path).stem}")
|
|
49
|
+
return "\n".join(lines) + "\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _status(msg: str) -> None:
|
|
53
|
+
"""进度反馈(stderr,不污染 stdout 的 SVG 输出)——对标 csvtk/snakemake 的阶段提示。"""
|
|
54
|
+
import sys
|
|
55
|
+
print(f" … {msg}", file=sys.stderr, flush=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render(tree_id: str, anno_files: list, out_svg: str | Path,
|
|
59
|
+
out_png: str | Path | None = None, wait: int = 60,
|
|
60
|
+
viewport: tuple[int, int] = (1600, 1200), mode: str | None = None) -> dict:
|
|
61
|
+
"""核心渲染:加载树页 → 注入注解 → exportSVG。
|
|
62
|
+
|
|
63
|
+
返回 {injected:{文件名:bool}, svg, svg_chars, pageerrors, png?, png_warn?, ok}。
|
|
64
|
+
ok = 全部注解注入确认;单个失败不抛(由 CLI 汇总),硬失败(模型未就绪/导出失败)抛 ItolError。
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
from playwright.sync_api import Error as PWError
|
|
68
|
+
from playwright.sync_api import sync_playwright
|
|
69
|
+
except ImportError:
|
|
70
|
+
raise ItolError("IT006", "itol render 需要 playwright(浏览器自动化渲染路)",
|
|
71
|
+
diagnosis="pip install playwright") from None
|
|
72
|
+
url = f"https://itol.embl.de/tree/{tree_id}"
|
|
73
|
+
with sync_playwright() as p:
|
|
74
|
+
_status(f"启动浏览器(Xvfb+Chrome),打开 {url}")
|
|
75
|
+
browser = p.chromium.launch(headless=False, executable_path=CHROME,
|
|
76
|
+
args=["--no-sandbox", "--disable-dev-shm-usage"])
|
|
77
|
+
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
|
78
|
+
errors: list[str] = []
|
|
79
|
+
page.on("pageerror", lambda e: errors.append(repr(e)[:200]))
|
|
80
|
+
try:
|
|
81
|
+
page.goto(url, timeout=90000, wait_until="domcontentloaded")
|
|
82
|
+
_status("树页已加载,等待数据模型就绪…")
|
|
83
|
+
ready = False
|
|
84
|
+
for _ in range(max(1, wait // 2)):
|
|
85
|
+
time.sleep(2)
|
|
86
|
+
try:
|
|
87
|
+
ready = bool(page.evaluate(
|
|
88
|
+
"() => typeof project!=='undefined'&&project&&"
|
|
89
|
+
"typeof project.exportSVG==='function'&&"
|
|
90
|
+
"typeof treeData!=='undefined'&&typeof lI1lll1==='function'"))
|
|
91
|
+
except PWError:
|
|
92
|
+
ready = False # 轮询期间瞬时 JS/上下文错误=未就绪,继续等
|
|
93
|
+
if ready:
|
|
94
|
+
break
|
|
95
|
+
if not ready:
|
|
96
|
+
raise ItolError("IT003", f"iTOL 树页数据模型 {wait}s 未就绪",
|
|
97
|
+
diagnosis="查树 ID 有效性/网络;大树(千叶级)可加 --wait")
|
|
98
|
+
_status("数据模型就绪,注入注解…")
|
|
99
|
+
# 显示模式切换(官方函数 llII1ll,含重算+重绘)
|
|
100
|
+
if mode:
|
|
101
|
+
_status(f"切换显示模式:{mode}")
|
|
102
|
+
page.evaluate("(m) => llII1ll(m)", DISPLAY_MODES[mode])
|
|
103
|
+
time.sleep(2) # 等模式切换重渲
|
|
104
|
+
injected: dict[str, bool] = {}
|
|
105
|
+
for f in anno_files:
|
|
106
|
+
b64 = base64.b64encode(_ensure_dataset_label(f).encode()).decode()
|
|
107
|
+
pre = page.evaluate("() => Object.keys(currentData.info.datasets||{}).length")
|
|
108
|
+
r = page.evaluate(
|
|
109
|
+
"""(b64) => { try { lI1lll1({name:'a.txt',type:'text/plain',
|
|
110
|
+
data:'data:text/plain;base64,'+b64}); return 'ok'; }
|
|
111
|
+
catch(e){ return 'ERR '+e; } }""", b64)
|
|
112
|
+
time.sleep(1.5)
|
|
113
|
+
post = page.evaluate("() => Object.keys(currentData.info.datasets||{}).length")
|
|
114
|
+
tcp = page.evaluate("() => !!tree_changes_pending")
|
|
115
|
+
injected[Path(f).name] = (r == "ok") and (post > pre or tcp)
|
|
116
|
+
time.sleep(2) # flush 渲染循环
|
|
117
|
+
_status("导出 SVG…")
|
|
118
|
+
svg = page.evaluate(
|
|
119
|
+
"""() => { try { var a=project.exportSVG();
|
|
120
|
+
return new XMLSerializer().serializeToString(a); }
|
|
121
|
+
catch(e){ return 'ERR '+e; } }""")
|
|
122
|
+
if not (isinstance(svg, str) and svg.startswith("<")):
|
|
123
|
+
raise ItolError("IT003", f"exportSVG 失败:{str(svg)[:200]}")
|
|
124
|
+
Path(out_svg).write_text(svg, encoding="utf-8")
|
|
125
|
+
result: dict = {"injected": injected, "svg": str(out_svg),
|
|
126
|
+
"svg_chars": len(svg), "pageerrors": len(errors),
|
|
127
|
+
"ok": all(injected.values())}
|
|
128
|
+
if out_png:
|
|
129
|
+
if shutil.which("rsvg-convert"):
|
|
130
|
+
subprocess.run(["rsvg-convert", "-b", "white", "-w", "1600", str(out_svg), "-o", str(out_png)],
|
|
131
|
+
check=True, capture_output=True)
|
|
132
|
+
result["png"] = str(out_png)
|
|
133
|
+
else:
|
|
134
|
+
result["png_warn"] = "rsvg-convert 未装,跳过 PNG(apt install librsvg2-bin)"
|
|
135
|
+
return result
|
|
136
|
+
finally:
|
|
137
|
+
browser.close()
|