dsh-origin-bridge 0.4.5 → 0.4.6
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.
- package/README.md +329 -303
- package/asciiio.py +184 -17
- package/fixtures/j_utf16_bom.dat +0 -0
- package/fixtures/k_utf16_nobom.dat +0 -0
- package/fixtures/l_cp1252_micro.dat +3 -0
- package/fixtures/m_cp1252_degree.dat +3 -0
- package/fixtures/make_fixtures.py +15 -0
- package/fixtures/n_detection_limit.dat +4 -0
- package/fixtures/o_plusminus.dat +3 -0
- package/fixtures/p_unit_in_cell.dat +3 -0
- package/fixtures/q_thousands.csv +3 -0
- package/fixtures/r_decimal_comma.txt +3 -0
- package/fixtures/s_star_flag.dat +3 -0
- package/fixtures/t_paren_negative.dat +3 -0
- package/fixtures/u_overflow.dat +3 -0
- package/fixtures/v_row_labels.dat +3 -0
- package/fixtures/w_two_numbers_cell.csv +3 -0
- package/originlab.py +164 -22
- package/package.json +12 -5
- package/server.py +24 -8
- package/smoke_test.py +24 -2
- package/test_asciiio.py +118 -0
- package/test_json_safety.py +103 -0
- package/test_paths.py +142 -0
package/asciiio.py
CHANGED
|
@@ -10,6 +10,7 @@ This reader sniffs the delimiter, handles BOM and GBK, skips comment lines, and
|
|
|
10
10
|
understands the common "long name / units" two-row header.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import codecs
|
|
13
14
|
import csv
|
|
14
15
|
import os
|
|
15
16
|
import re
|
|
@@ -100,13 +101,72 @@ def read_metadata(head, k):
|
|
|
100
101
|
return meta
|
|
101
102
|
|
|
102
103
|
|
|
104
|
+
_BOMS = ((codecs.BOM_UTF32_LE, "utf-32-le"), (codecs.BOM_UTF32_BE, "utf-32-be"),
|
|
105
|
+
(codecs.BOM_UTF16_LE, "utf-16-le"), (codecs.BOM_UTF16_BE, "utf-16-be"),
|
|
106
|
+
(codecs.BOM_UTF8, "utf-8-sig"))
|
|
107
|
+
|
|
108
|
+
# Some instrument software and Windows' "Unicode Text" save write UTF-16 with no
|
|
109
|
+
# BOM at all. Half of every byte is then 0x00, which no other codec survives.
|
|
110
|
+
_NUL_RATIO = 0.2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def high_byte_runs(data):
|
|
114
|
+
"""Lengths of maximal runs of bytes >= 0x80.
|
|
115
|
+
|
|
116
|
+
GBK-family text pairs them: one Chinese character is two high bytes. cp1252 /
|
|
117
|
+
latin-1 punctuation shows up as isolated singles — µ, °, ±, ·. That is the only
|
|
118
|
+
honest way to tell "温度/C" from "T/°C", because gb18030 will decode *both*
|
|
119
|
+
without raising and silently mojibake the second one into 祍/癈.
|
|
120
|
+
"""
|
|
121
|
+
runs, cur = [], 0
|
|
122
|
+
for byte in data:
|
|
123
|
+
if byte >= 0x80:
|
|
124
|
+
cur += 1
|
|
125
|
+
elif cur:
|
|
126
|
+
runs.append(cur)
|
|
127
|
+
cur = 0
|
|
128
|
+
if cur:
|
|
129
|
+
runs.append(cur)
|
|
130
|
+
return runs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def candidate_encodings(data):
|
|
134
|
+
order = list(ENCODINGS)
|
|
135
|
+
runs = high_byte_runs(data)
|
|
136
|
+
if runs and any(run % 2 for run in runs):
|
|
137
|
+
gb = order.index("gb18030")
|
|
138
|
+
singles = [enc for enc in ("cp1252", "latin-1") if enc in order]
|
|
139
|
+
for enc in singles:
|
|
140
|
+
order.remove(enc)
|
|
141
|
+
order[gb:gb] = singles
|
|
142
|
+
return order
|
|
143
|
+
|
|
144
|
+
|
|
103
145
|
def read_text(path):
|
|
104
146
|
"""Decode a data file, trying the encodings that actually occur in labs."""
|
|
147
|
+
with open(path, "rb") as fh:
|
|
148
|
+
data = fh.read()
|
|
149
|
+
for bom, enc in _BOMS:
|
|
150
|
+
if data.startswith(bom):
|
|
151
|
+
body = data[len(bom):]
|
|
152
|
+
if enc == "utf-8-sig":
|
|
153
|
+
return body.decode("utf-8"), enc
|
|
154
|
+
try:
|
|
155
|
+
return body.decode(enc), enc
|
|
156
|
+
except UnicodeDecodeError as exc:
|
|
157
|
+
raise ValueError("文件带 %s 字节头但解不开:%s(%s)" % (enc, path, exc))
|
|
158
|
+
if len(data) >= 8 and data.count(b"\x00") / float(len(data)) > _NUL_RATIO:
|
|
159
|
+
for enc in ("utf-16-le", "utf-16-be"):
|
|
160
|
+
try:
|
|
161
|
+
text = data.decode(enc)
|
|
162
|
+
except UnicodeDecodeError:
|
|
163
|
+
continue
|
|
164
|
+
if "\x00" not in text:
|
|
165
|
+
return text, "%s(无 BOM,按 NUL 分布判断)" % enc
|
|
105
166
|
last = None
|
|
106
|
-
for enc in
|
|
167
|
+
for enc in candidate_encodings(data):
|
|
107
168
|
try:
|
|
108
|
-
|
|
109
|
-
return fh.read(), enc
|
|
169
|
+
return data.decode(enc), enc
|
|
110
170
|
except (UnicodeDecodeError, LookupError) as exc:
|
|
111
171
|
last = exc
|
|
112
172
|
raise ValueError("无法解码 %s(试过 %s),最后错误:%s" % (path, "/".join(ENCODINGS), last))
|
|
@@ -126,13 +186,21 @@ def sniff_delimiter(rows):
|
|
|
126
186
|
width = max(widths)
|
|
127
187
|
if width < 2:
|
|
128
188
|
continue
|
|
129
|
-
# score:
|
|
189
|
+
# score: numeric columns first, then how many rows actually have that width,
|
|
190
|
+
# then FEWER columns. Ranking by width alone let a wrong comma split of a
|
|
191
|
+
# European "0,0;1,5" file beat the correct ';' because it made three columns.
|
|
192
|
+
consistent = sum(1 for g in grid if len(g) == width) / float(len(grid))
|
|
193
|
+
# The first row is a header candidate, so keep it out of the numeric ratio —
|
|
194
|
+
# otherwise a European "Zeit;Signal / 0,0;1,5" file scores every delimiter as
|
|
195
|
+
# non-numeric and the widest wrong split wins by default.
|
|
196
|
+
data = grid[1:] if len(grid) >= 2 else grid
|
|
130
197
|
hits = 0
|
|
131
198
|
for col in range(width):
|
|
132
|
-
vals = [g[col] for g in
|
|
133
|
-
if vals and sum(1 for v in vals if _is_number(v)
|
|
199
|
+
vals = [g[col] for g in data if len(g) > col]
|
|
200
|
+
if vals and sum(1 for v in vals if _is_number(v) or _CELL_DECCOMA.match(v.strip())) \
|
|
201
|
+
>= 0.8 * len(vals):
|
|
134
202
|
hits += 1
|
|
135
|
-
score = (hits, width)
|
|
203
|
+
score = (hits, round(consistent, 3), -width)
|
|
136
204
|
if score > best_score:
|
|
137
205
|
best, best_score = delim, score
|
|
138
206
|
if best is not None:
|
|
@@ -149,6 +217,92 @@ def sniff_delimiter(rows):
|
|
|
149
217
|
raise ValueError("识别不出分隔符,也没有多列空白对齐的数据")
|
|
150
218
|
|
|
151
219
|
|
|
220
|
+
_NUM_SRC = r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eEdD][+-]?\d+)?"
|
|
221
|
+
# Lab exports decorate their numbers. Dropping a whole column because one cell is
|
|
222
|
+
# decorated silently loses real data, so each shape below is recognised, unwrapped,
|
|
223
|
+
# and reported as a warning instead of quietly skipped.
|
|
224
|
+
_CELL_DL = re.compile(r"^(?:[<>]=?|≥|≤)\s*(%s)$" % _NUM_SRC) # <0.001, >1e6
|
|
225
|
+
_CELL_PM = re.compile(r"^(%s)\s*(?:±|\+/-|\+-)\s*\d*\.?\d+$" % _NUM_SRC) # 2.5±0.1
|
|
226
|
+
_CELL_UNIT = re.compile(r"^(%s)\s+\S+$" % _NUM_SRC) # "25 mV"
|
|
227
|
+
# Two numbers sharing one cell (a space-aligned dump read with the wrong delimiter)
|
|
228
|
+
# must not be dressed up as "number + unit", or the second one disappears quietly.
|
|
229
|
+
_CELL_TWO = re.compile(r"^(%s)\s+(%s)\s*$" % (_NUM_SRC, _NUM_SRC))
|
|
230
|
+
_CELL_FLAG = re.compile(r"^(%s)[*†‡°\s]+$" % _NUM_SRC) # "2.5*"
|
|
231
|
+
_CELL_THOUSANDS = re.compile(r"^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$") # "1,234.5"
|
|
232
|
+
# European exports use the comma as the decimal mark, which only parses when the
|
|
233
|
+
# field separator is something else (usually ';').
|
|
234
|
+
_CELL_DECCOMA = re.compile(r"^[+-]?\d+,\d+$") # "0,5"
|
|
235
|
+
_CELL_PAREN = re.compile(r"^\((%s)\)$" % _NUM_SRC) # "(500)"
|
|
236
|
+
_MISSING_TOKENS = ("NA", "NAN", "-", "--", "NIL", "N/A", "NULL", "#N/A", "#DIV/0!")
|
|
237
|
+
|
|
238
|
+
CELL_KIND_NOTES = {
|
|
239
|
+
"检测限": "第 %d 列 %r 有 %d 个检测限标记(例如 <0.001),已按边界值代入",
|
|
240
|
+
"不确定度": "第 %d 列 %r 有 %d 个 ± 写法(例如 2.5±0.1),已取中心值",
|
|
241
|
+
"带单位的数": "第 %d 列 %r 有 %d 个数值把单位写在同一格(例如 25 mV),已取数值",
|
|
242
|
+
"一格两个数": "第 %d 列 %r 有 %d 个格子里挤了两个数(空格分隔),已取第一个;"
|
|
243
|
+
"若那本是两列,请把分隔符设为空格或改用制表符/CSV 再导",
|
|
244
|
+
"千分位": "第 %d 列 %r 有 %d 个千分位写法(例如 1,234.5),已去掉分隔逗号",
|
|
245
|
+
"小数逗号": "第 %d 列 %r 有 %d 个用逗号当小数点的写法(例如 0,5),已按小数解析",
|
|
246
|
+
"标记后缀": "第 %d 列 %r 有 %d 个带 * / † 标记的值,已按数值本身代入",
|
|
247
|
+
"括号负数": "第 %d 列 %r 有 %d 个用括号表示负数的写法(例如 (500)),已取负值",
|
|
248
|
+
"溢出": "第 %d 列 %r 有 %d 个数值超出浮点范围(例如 1e400),已按缺值处理",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _to_float(text):
|
|
253
|
+
return float(text.replace("d", "E").replace("D", "E"))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _finite(value):
|
|
257
|
+
return value == value and value not in (float("inf"), float("-inf"))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cell_number(cell, decimal_comma=False):
|
|
261
|
+
"""One cell -> (value or None, kind or None). kind is what we had to forgive.
|
|
262
|
+
|
|
263
|
+
Lab exports decorate their numbers: detection limits, uncertainties, a unit in
|
|
264
|
+
the same cell, thousands separators, outlier flags. Dropping the whole column
|
|
265
|
+
because one cell is decorated silently loses real data, so each shape is
|
|
266
|
+
recognised, unwrapped, and reported as a warning instead of quietly skipped.
|
|
267
|
+
"""
|
|
268
|
+
text = str(cell).strip().strip('"').strip("'")
|
|
269
|
+
if not text or text.upper() in _MISSING_TOKENS:
|
|
270
|
+
return None, None
|
|
271
|
+
if decimal_comma and _CELL_DECCOMA.match(text):
|
|
272
|
+
value = _to_float(text.replace(",", "."))
|
|
273
|
+
return (value, "小数逗号") if _finite(value) else (None, "溢出")
|
|
274
|
+
try:
|
|
275
|
+
value = _to_float(text)
|
|
276
|
+
except ValueError:
|
|
277
|
+
pass
|
|
278
|
+
else:
|
|
279
|
+
return (value, None) if _finite(value) else (None, "溢出")
|
|
280
|
+
if _CELL_THOUSANDS.match(text):
|
|
281
|
+
value = _to_float(text.replace(",", ""))
|
|
282
|
+
return (value, "千分位") if _finite(value) else (None, "溢出")
|
|
283
|
+
m = _CELL_PAREN.match(text)
|
|
284
|
+
if m:
|
|
285
|
+
value = -abs(_to_float(m.group(1)))
|
|
286
|
+
return (value, "括号负数") if _finite(value) else (None, "溢出")
|
|
287
|
+
m = _CELL_TWO.match(text)
|
|
288
|
+
if m:
|
|
289
|
+
# Checked before "number + unit": "2.5 3.5" is a mis-split column, not a
|
|
290
|
+
# measurement with a unit, and calling it a unit loses the second number
|
|
291
|
+
# without ever telling anyone.
|
|
292
|
+
value = _to_float(m.group(1))
|
|
293
|
+
return (value, "一格两个数") if _finite(value) else (None, "溢出")
|
|
294
|
+
for rx, kind in ((_CELL_DL, "检测限"), (_CELL_PM, "不确定度"),
|
|
295
|
+
(_CELL_UNIT, "带单位的数"), (_CELL_FLAG, "标记后缀")):
|
|
296
|
+
m = rx.match(text)
|
|
297
|
+
if m:
|
|
298
|
+
try:
|
|
299
|
+
value = _to_float(m.group(1))
|
|
300
|
+
except ValueError:
|
|
301
|
+
return None, None
|
|
302
|
+
return (value, kind) if _finite(value) else (None, "溢出")
|
|
303
|
+
return None, None
|
|
304
|
+
|
|
305
|
+
|
|
152
306
|
def parse(path, comment=None):
|
|
153
307
|
"""Return {columns:[{name,units,values}], rows, delimiters, encoding, warnings}."""
|
|
154
308
|
text, enc = read_text(path)
|
|
@@ -167,6 +321,10 @@ def parse(path, comment=None):
|
|
|
167
321
|
grid = [next(csv.reader([ln], delimiter=delim)) for ln in kept]
|
|
168
322
|
|
|
169
323
|
width = max(len(r) for r in grid)
|
|
324
|
+
# Deciding this here matters twice: the header/data split below and the cell
|
|
325
|
+
# reader both need to know whether a bare "0,5" is a European decimal number
|
|
326
|
+
# rather than two comma-separated fields.
|
|
327
|
+
decimal_comma = delim not in (",", None)
|
|
170
328
|
if len({len(r) for r in grid}) > 1:
|
|
171
329
|
warnings.append("行长度不一致,已按最宽 %d 列补齐" % width)
|
|
172
330
|
grid = [r + [""] * (width - len(r)) for r in grid]
|
|
@@ -174,7 +332,8 @@ def parse(path, comment=None):
|
|
|
174
332
|
# split leading header rows (rows that are mostly non-numeric) from data
|
|
175
333
|
head, body = [], []
|
|
176
334
|
for i, row in enumerate(grid):
|
|
177
|
-
numeric = sum(1 for c in row if _is_number(c)
|
|
335
|
+
numeric = sum(1 for c in row if _is_number(c)
|
|
336
|
+
or (decimal_comma and _CELL_DECCOMA.match(c.strip())))
|
|
178
337
|
if numeric >= max(1, int(0.6 * width)):
|
|
179
338
|
body = grid[i:]
|
|
180
339
|
break
|
|
@@ -205,22 +364,30 @@ def parse(path, comment=None):
|
|
|
205
364
|
columns = []
|
|
206
365
|
for c in range(width):
|
|
207
366
|
vals = []
|
|
367
|
+
kinds = {}
|
|
208
368
|
for row in body:
|
|
209
369
|
cell = row[c].strip()
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
vals.append(float(cell.replace("d", "E").replace("D", "E")))
|
|
215
|
-
except ValueError:
|
|
216
|
-
vals.append(None)
|
|
370
|
+
value, kind = cell_number(cell, decimal_comma=decimal_comma)
|
|
371
|
+
if kind:
|
|
372
|
+
kinds[kind] = kinds.get(kind, 0) + 1
|
|
373
|
+
vals.append(value)
|
|
217
374
|
live = sum(1 for v in vals if v is not None)
|
|
375
|
+
name = raw_names[c] or "col%d" % (c + 1)
|
|
376
|
+
# Report what we forgave even when the column ends up dropped: "全为空" alone
|
|
377
|
+
# hides that every cell was text (a row-label column) or overflowed.
|
|
378
|
+
for kind, count in sorted(kinds.items()):
|
|
379
|
+
warnings.append(CELL_KIND_NOTES[kind] % (c + 1, name, count))
|
|
218
380
|
if live == 0:
|
|
219
|
-
|
|
381
|
+
blanks = sum(1 for row in body if not row[c].strip())
|
|
382
|
+
if blanks == len(body):
|
|
383
|
+
continue # a genuinely empty trailing column, nothing to explain
|
|
384
|
+
warnings.append("第 %d 列 %r 没有一个能读成数值(%d 行),已跳过;"
|
|
385
|
+
"如果它是行标签/文本列属正常,要画它请选别的列"
|
|
386
|
+
% (c + 1, name, len(body)))
|
|
220
387
|
continue
|
|
221
388
|
columns.append({
|
|
222
389
|
"index": c + 1,
|
|
223
|
-
"name":
|
|
390
|
+
"name": name,
|
|
224
391
|
"units": units[c] or "",
|
|
225
392
|
"values": vals,
|
|
226
393
|
"numeric_rows": live,
|
|
Binary file
|
|
Binary file
|
|
@@ -20,6 +20,21 @@ FILES = {
|
|
|
20
20
|
"g_na_and_D.dat": ("utf-8", "X\tY\tZ\n0\t1.5D+00\tNA\n1\t2.5d+00\t-\n2\tNA\t3.1\n"),
|
|
21
21
|
"h_gbk_units.dat": ("gbk", "温度\t电导\n25.0\t1.23\n30.0\t1.45\n"),
|
|
22
22
|
"i_bom_utf8.csv": ("utf-8-sig", "波长,吸光度\n200,0.12\n210,0.34\n"),
|
|
23
|
+
# Encodings and decorated numbers that come out of real instruments.
|
|
24
|
+
"j_utf16_bom.dat": ("utf-16", "X\tY\n0\t1.5\n1\t2.5\n"),
|
|
25
|
+
"k_utf16_nobom.dat": ("utf-16-le", "X\tY\n0\t1.5\n1\t2.5\n"),
|
|
26
|
+
"l_cp1252_micro.dat": ("cp1252", "T/V\ts/µs\n1\t2\n3\t4\n"),
|
|
27
|
+
"m_cp1252_degree.dat": ("cp1252", "T/°C\ty\n25\t1.2\n30\t1.4\n"),
|
|
28
|
+
"n_detection_limit.dat": ("utf-8", "x\ty\n1\t<0.001\n2\t0.5\n3\t>1e6\n"),
|
|
29
|
+
"o_plusminus.dat": ("utf-8", "x\ty\n1\t2.5±0.1\n2\t3.5 +/- 0.2\n"),
|
|
30
|
+
"p_unit_in_cell.dat": ("utf-8", "x\ty\n1\t25 mV\n2\t30 mV\n"),
|
|
31
|
+
"q_thousands.csv": ("utf-8", "time,signal\n0,\"1,234.5\"\n1,\"2,345.6\"\n"),
|
|
32
|
+
"r_decimal_comma.txt": ("utf-8", "Zeit;Signal\n0,0;1,5\n1,0;2,5\n"),
|
|
33
|
+
"s_star_flag.dat": ("utf-8", "x\ty\n1\t2.5*\n2\t3.5\n"),
|
|
34
|
+
"t_paren_negative.dat": ("utf-8", "x\ty\n1\t(500)\n2\t250\n"),
|
|
35
|
+
"u_overflow.dat": ("utf-8", "x\ty\n1\t1e400\n2\t1.2e308\n"),
|
|
36
|
+
"v_row_labels.dat": ("utf-8", "ch\tV\tI\nCh1\t1.0\t2.0\nCh2\t3.0\t4.0\n"),
|
|
37
|
+
"w_two_numbers_cell.csv": ("utf-8", "x,y\n1,\"2.5 3.5\"\n2,\"4.0 5.0\"\n"),
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
|
package/originlab.py
CHANGED
|
@@ -24,6 +24,7 @@ import asciiio
|
|
|
24
24
|
import binio
|
|
25
25
|
|
|
26
26
|
MISSING_SENTINEL = -1.23456789e-300
|
|
27
|
+
_INF = float("inf")
|
|
27
28
|
# Verified against OriginPro 2024 SR1 by exporting each code and looking at the
|
|
28
29
|
# image: 200 line, 201 symbol, 202 line+symbol, 203 column.
|
|
29
30
|
PLOT_CODES = {"line": 200, "scatter": 201, "line_symbol": 202, "column": 203}
|
|
@@ -396,13 +397,20 @@ def _graph_of(op, ref):
|
|
|
396
397
|
|
|
397
398
|
|
|
398
399
|
def sanitize(obj):
|
|
399
|
-
"""Origin encodes missing numbers as -1.23456789e-300; never leak that upstream.
|
|
400
|
+
"""Origin encodes missing numbers as -1.23456789e-300; never leak that upstream.
|
|
401
|
+
|
|
402
|
+
+-Infinity belongs here too. json.dumps happily writes a bare ``Infinity``,
|
|
403
|
+
which is not valid JSON, so a single overflowed fit parameter or a ``1e400``
|
|
404
|
+
read out of a file would make the whole tool response unparseable for a strict
|
|
405
|
+
client — the failure looks like "the bridge is broken", not like one bad number.
|
|
406
|
+
"""
|
|
400
407
|
if isinstance(obj, dict):
|
|
401
408
|
return {k: sanitize(v) for k, v in obj.items()}
|
|
402
409
|
if isinstance(obj, (list, tuple)):
|
|
403
410
|
return [sanitize(v) for v in obj]
|
|
404
|
-
if isinstance(obj, float)
|
|
405
|
-
|
|
411
|
+
if isinstance(obj, float):
|
|
412
|
+
if obj != obj or obj in (_INF, -_INF) or abs(obj - MISSING_SENTINEL) < 1e-310:
|
|
413
|
+
return None
|
|
406
414
|
return obj
|
|
407
415
|
|
|
408
416
|
|
|
@@ -510,6 +518,127 @@ def _verify_file(path, must_be_image=True, min_bytes=1024):
|
|
|
510
518
|
return {"ok": ok, "path": path, "bytes": size, "dimensions": dims, "head": head[:8].hex()}
|
|
511
519
|
|
|
512
520
|
|
|
521
|
+
# Measured 2026-10-12 against the live bridge: Origin's export filter treats '#'
|
|
522
|
+
# in the filename as a numbering token — asking for 100%_a#b[1].png produced
|
|
523
|
+
# 100%_a1b[1].png (the '#' became "1"), while '%', '[]', spaces and 中文 survive.
|
|
524
|
+
# A trailing space or dot in any component is stripped by Win32 before creation,
|
|
525
|
+
# so isfile() on the literal path we asked for says False while the bytes really
|
|
526
|
+
# are on disk. And a path that carries no real filename (".", " ", ".png")
|
|
527
|
+
# used to resolve into the process CWD and come back as a dotfile named ".png" —
|
|
528
|
+
# littering the plugin install folder.
|
|
529
|
+
_HASH_RUN = re.compile(r"#+")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _clean_component(part):
|
|
533
|
+
stripped = part.rstrip(" .")
|
|
534
|
+
return stripped if stripped else part
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _norm_target(path, fmt=None):
|
|
538
|
+
"""Absolute, expanduser'd, and normalised the way Windows will do it anyway."""
|
|
539
|
+
raw = str(path or "").strip()
|
|
540
|
+
if not raw:
|
|
541
|
+
return ""
|
|
542
|
+
target = os.path.abspath(os.path.expanduser(raw))
|
|
543
|
+
drive, rest = os.path.splitdrive(target)
|
|
544
|
+
target = drive + os.sep.join(_clean_component(p) for p in rest.split(os.sep))
|
|
545
|
+
base = os.path.basename(target)
|
|
546
|
+
if not base.split(".")[0].strip(" ."):
|
|
547
|
+
raise OriginError("bad_path", "路径里没有真正的文件名:%r" % path,
|
|
548
|
+
["给完整的文件名,例如 D:\\out\\图.png",
|
|
549
|
+
"只想用默认目录就别传 path"])
|
|
550
|
+
if os.path.isdir(target):
|
|
551
|
+
raise OriginError("bad_path", "path 指向一个目录,不是文件:%s" % target,
|
|
552
|
+
["把文件名补上,例如 %s\\figure.png" % target])
|
|
553
|
+
stem, ext = os.path.splitext(base)
|
|
554
|
+
if fmt and ext.lower().lstrip(".") != fmt:
|
|
555
|
+
target = os.path.splitext(target)[0] + "." + fmt
|
|
556
|
+
return target
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _prepare_write(target):
|
|
560
|
+
"""Make the directory and refuse up front what Origin cannot write anyway."""
|
|
561
|
+
folder = os.path.dirname(target) or "."
|
|
562
|
+
try:
|
|
563
|
+
os.makedirs(folder, exist_ok=True)
|
|
564
|
+
except OSError as exc:
|
|
565
|
+
raise OriginError("bad_path", "输出目录建不出来:%s(%s)" % (folder, exc.strerror or exc),
|
|
566
|
+
["盘符存在吗(例如 Q: 未映射)",
|
|
567
|
+
"网络路径要用 \\服务器\\共享\\… 且当前身份有写权限",
|
|
568
|
+
"路径里去掉尾部空格或点,Windows 会静默截掉"])
|
|
569
|
+
if os.path.isdir(target):
|
|
570
|
+
raise OriginError("bad_path", "path 是一个目录,不是文件:%s" % target,
|
|
571
|
+
["把文件名补上,例如 %s\\figure.png" % target])
|
|
572
|
+
if os.path.exists(target) and not os.access(target, os.W_OK):
|
|
573
|
+
raise OriginError("target_locked", "目标文件已存在且不可写(只读或被占用):%s" % target,
|
|
574
|
+
["去掉只读属性或换文件名", "文件可能正被 Origin/图片查看器占用,先关掉"])
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _rel_note(path):
|
|
578
|
+
"""A relative path resolves against the process CWD, which under dsh is the
|
|
579
|
+
plugin's own install folder — worth saying so instead of littering it."""
|
|
580
|
+
raw = str(path or "").strip()
|
|
581
|
+
if raw and not os.path.isabs(os.path.expanduser(raw)):
|
|
582
|
+
return ("path 是相对路径,已解析到进程工作目录 %s;dsh 下那是插件安装目录,"
|
|
583
|
+
"交付文件请给绝对路径" % os.path.abspath(raw))
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _lift_warnings(out):
|
|
588
|
+
"""Nested export/project notes belong at the top of the result too.
|
|
589
|
+
|
|
590
|
+
A model reads the head of a tool result first; if Origin rewrote a filename
|
|
591
|
+
and the note only lives under out["export"]["warnings"], the delivery looks
|
|
592
|
+
clean when it is not.
|
|
593
|
+
"""
|
|
594
|
+
lifted = list(out.get("warnings") or [])
|
|
595
|
+
for part in ("export", "project"):
|
|
596
|
+
sub = out.get(part) or {}
|
|
597
|
+
for note in (sub.get("warnings") or []):
|
|
598
|
+
if note not in lifted:
|
|
599
|
+
lifted.append(note)
|
|
600
|
+
if lifted:
|
|
601
|
+
out["warnings"] = lifted
|
|
602
|
+
return out
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _written_or_mangled(target):
|
|
606
|
+
"""Locate what Origin actually wrote; rename it back when its filter rewrote '#'."""
|
|
607
|
+
if os.path.isfile(target):
|
|
608
|
+
return target, None
|
|
609
|
+
folder = os.path.dirname(target) or "."
|
|
610
|
+
stem, ext = os.path.splitext(os.path.basename(target))
|
|
611
|
+
if not _HASH_RUN.search(stem):
|
|
612
|
+
return None, None
|
|
613
|
+
# Origin replaced every run of '#' with digits; turn the requested name into a
|
|
614
|
+
# pattern and accept a single unambiguous match, then rename it back.
|
|
615
|
+
pattern = []
|
|
616
|
+
i = 0
|
|
617
|
+
while i < len(stem):
|
|
618
|
+
m = _HASH_RUN.match(stem, i)
|
|
619
|
+
if m:
|
|
620
|
+
pattern.append(r"\d+")
|
|
621
|
+
i = m.end()
|
|
622
|
+
else:
|
|
623
|
+
pattern.append(re.escape(stem[i]))
|
|
624
|
+
i += 1
|
|
625
|
+
rx = re.compile("^%s%s$" % ("".join(pattern), re.escape(ext)))
|
|
626
|
+
try:
|
|
627
|
+
hits = [n for n in os.listdir(folder) if rx.match(n)]
|
|
628
|
+
except OSError:
|
|
629
|
+
hits = []
|
|
630
|
+
if len(hits) != 1:
|
|
631
|
+
return None, None
|
|
632
|
+
cand = os.path.join(folder, hits[0])
|
|
633
|
+
try:
|
|
634
|
+
os.replace(cand, target)
|
|
635
|
+
return target, ("Origin 的导出滤镜把文件名里的 # 换成了编号(实际写成 %s),已改名回你要求的路径;"
|
|
636
|
+
"要避免就别说文件名里带 #" % hits[0])
|
|
637
|
+
except OSError as exc:
|
|
638
|
+
return cand, ("Origin 把文件名写成了 %s(# 被换成编号),改名回原路径失败:%s"
|
|
639
|
+
% (hits[0], exc.strerror or exc))
|
|
640
|
+
|
|
641
|
+
|
|
513
642
|
# ---------------------------------------------------------------- operations
|
|
514
643
|
|
|
515
644
|
|
|
@@ -1441,18 +1570,24 @@ def export(graph, path="", fmt="png", width=1600):
|
|
|
1441
1570
|
# No path is not an invitation to write into the process CWD: under dsh
|
|
1442
1571
|
# that is the plugin's node_modules folder. Origin's User Files wins.
|
|
1443
1572
|
folder = _safe(lambda: op.path("u"), "") or os.getcwd()
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
|
|
1573
|
+
raw_path = str(path or "").strip()
|
|
1574
|
+
target_path = _norm_target(raw_path or os.path.join(folder, "%s.%s" % (name, fmt)), fmt)
|
|
1575
|
+
_prepare_write(target_path)
|
|
1448
1576
|
_safe(lambda: page.save_fig(target_path))
|
|
1449
|
-
|
|
1577
|
+
written, note = _written_or_mangled(target_path)
|
|
1578
|
+
if not written:
|
|
1450
1579
|
raise OriginError("export_failed", "Origin 说导出了但文件不存在:%s" % target_path,
|
|
1451
|
-
["
|
|
1452
|
-
|
|
1580
|
+
["路径必须可写;UNC/未映射盘先排除",
|
|
1581
|
+
"先 origin_inspect 确认图页非空"])
|
|
1582
|
+
info = _verify_file(written)
|
|
1453
1583
|
if not info["ok"]:
|
|
1454
1584
|
raise OriginError("export_suspect", "导出文件过小或文件头不对:%s" % info,
|
|
1455
1585
|
["图页可能是空的:origin_inspect 看 plots 数"])
|
|
1586
|
+
if note:
|
|
1587
|
+
info["warnings"] = [note]
|
|
1588
|
+
rel = _rel_note(path)
|
|
1589
|
+
if rel:
|
|
1590
|
+
info["warnings"] = (info.get("warnings") or []) + [rel]
|
|
1456
1591
|
return sanitize(dict(info, channel="originpro.save_fig", graph=name))
|
|
1457
1592
|
|
|
1458
1593
|
return call(run)
|
|
@@ -1463,8 +1598,8 @@ def _save_project(path=""):
|
|
|
1463
1598
|
def run():
|
|
1464
1599
|
global _project_file
|
|
1465
1600
|
op = origin()
|
|
1466
|
-
if path:
|
|
1467
|
-
target =
|
|
1601
|
+
if str(path).strip():
|
|
1602
|
+
target = _norm_target(path)
|
|
1468
1603
|
elif _project_file:
|
|
1469
1604
|
target = _project_file
|
|
1470
1605
|
else:
|
|
@@ -1474,7 +1609,7 @@ def _save_project(path=""):
|
|
|
1474
1609
|
time.strftime("origin_bridge_%Y%m%d_%H%M%S.opju"))
|
|
1475
1610
|
if not target.lower().endswith((".opju", ".opj")):
|
|
1476
1611
|
target += ".opju"
|
|
1477
|
-
|
|
1612
|
+
_prepare_write(target)
|
|
1478
1613
|
|
|
1479
1614
|
# op.save(file) is Origin's Save-As: once the project is bound to that
|
|
1480
1615
|
# file, saving the same name again returns False without meaning failure.
|
|
@@ -1484,10 +1619,12 @@ def _save_project(path=""):
|
|
|
1484
1619
|
mtime_before = os.path.getmtime(target) if os.path.isfile(target) else 0
|
|
1485
1620
|
ok = bool(op.save()) if same_file else bool(op.save(target))
|
|
1486
1621
|
|
|
1487
|
-
|
|
1622
|
+
written, _note = _written_or_mangled(target)
|
|
1623
|
+
if not written:
|
|
1488
1624
|
raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
|
|
1489
|
-
["
|
|
1625
|
+
["确认目录可写;中文、空格、超长路径实测都能存,UNC/未映射盘不行",
|
|
1490
1626
|
"换一个不冲突的文件名,或省略 path 用自动时间戳命名"])
|
|
1627
|
+
target = written
|
|
1491
1628
|
fresh = os.path.getmtime(target) > mtime_before
|
|
1492
1629
|
if not ok and not (same_file and not fresh):
|
|
1493
1630
|
raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
|
|
@@ -1647,7 +1784,7 @@ def figure(source="", columns=None, x=1, y=1, plot_type="line", title="",
|
|
|
1647
1784
|
out["proof_level"] = "verified" if deliver.get("image") else "readback_only"
|
|
1648
1785
|
out["steps"] = steps
|
|
1649
1786
|
out["duration_ms"] = int((time.time() - t0) * 1000)
|
|
1650
|
-
return out
|
|
1787
|
+
return _lift_warnings(out)
|
|
1651
1788
|
|
|
1652
1789
|
|
|
1653
1790
|
# ------------------------------------------------------- matrix / 3D / heat maps
|
|
@@ -1711,22 +1848,27 @@ def matrix_plot(rows, template="heat_map", book_name="", graph_name="",
|
|
|
1711
1848
|
out["export"] = _export_on_com_thread(gr, export_path)
|
|
1712
1849
|
if project_path:
|
|
1713
1850
|
out["project"] = _save_project(project_path)
|
|
1714
|
-
return sanitize(out)
|
|
1851
|
+
return sanitize(_lift_warnings(out))
|
|
1715
1852
|
|
|
1716
1853
|
return call(run)
|
|
1717
1854
|
|
|
1718
1855
|
|
|
1719
1856
|
def _export_on_com_thread(page, path):
|
|
1720
1857
|
"""export() body, for reuse inside another run() that is already on the thread."""
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1858
|
+
target = _norm_target(path)
|
|
1859
|
+
if not os.path.splitext(target)[1]:
|
|
1860
|
+
target += ".png"
|
|
1861
|
+
ext = os.path.splitext(target)[1].lower()
|
|
1862
|
+
_prepare_write(target)
|
|
1724
1863
|
_safe(lambda: page.save_fig(target))
|
|
1725
|
-
|
|
1864
|
+
written, note = _written_or_mangled(target)
|
|
1865
|
+
if not written:
|
|
1726
1866
|
raise OriginError("export_failed", "矩阵图导出没落盘:%s" % target)
|
|
1727
|
-
info = _verify_file(
|
|
1867
|
+
info = _verify_file(written, must_be_image=ext in IMAGE_MAGIC)
|
|
1728
1868
|
if not info["ok"]:
|
|
1729
1869
|
raise OriginError("export_suspect", "导出文件异常:%s" % info)
|
|
1870
|
+
if note:
|
|
1871
|
+
info["warnings"] = [note]
|
|
1730
1872
|
return info
|
|
1731
1873
|
|
|
1732
1874
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-origin-bridge",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "在对话里驱动本机 Origin / OriginPro:导入 .dat/.csv/.txt 与电化学工作站 .bin,绘图、拟合、改样式,导出图片并保存可在 Origin 里继续编辑的 .opju 工程。dsh 插件,27 个 MCP 工具。第三方工具,与 OriginLab Corporation 无关联、未获认可。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,9 +14,6 @@
|
|
|
14
14
|
"homepage": "https://github.com/caob23/dsh-origin-bridge#readme",
|
|
15
15
|
"main": "./index.js",
|
|
16
16
|
"icon": "./icon.svg",
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.1"
|
|
19
|
-
},
|
|
20
17
|
"bin": {
|
|
21
18
|
"origin-bridge": "bin/origin-bridge.mjs"
|
|
22
19
|
},
|
|
@@ -53,6 +50,8 @@
|
|
|
53
50
|
"fixtures/*.txt",
|
|
54
51
|
"smoke_test.py",
|
|
55
52
|
"edge_checks.py",
|
|
53
|
+
"test_paths.py",
|
|
54
|
+
"test_json_safety.py",
|
|
56
55
|
"test_asciiio.py",
|
|
57
56
|
"test_binio.py",
|
|
58
57
|
"test_lifecycle.py",
|
|
@@ -78,5 +77,13 @@
|
|
|
78
77
|
"opju",
|
|
79
78
|
"scientific-plotting",
|
|
80
79
|
"electrochemistry"
|
|
81
|
-
]
|
|
80
|
+
],
|
|
81
|
+
"peerDependencies": {
|
|
82
|
+
"@deepseek-ai/dsh-mcp-client": "0.2.0-rc.2 || >=0.1.0-rc.1 <1"
|
|
83
|
+
},
|
|
84
|
+
"peerDependenciesMeta": {
|
|
85
|
+
"@deepseek-ai/dsh-mcp-client": {
|
|
86
|
+
"optional": true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
82
89
|
}
|