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/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 ENCODINGS:
167
+ for enc in candidate_encodings(data):
107
168
  try:
108
- with open(path, "r", encoding=enc, newline="") as fh:
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: how many columns are numeric in >80% of rows, tie-broken by width
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 grid if len(g) > col]
133
- if vals and sum(1 for v in vals if _is_number(v)) >= 0.8 * len(vals):
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
- if not cell or cell.upper() in ("NA", "NAN", "-", "--"):
211
- vals.append(None)
212
- continue
213
- try:
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
- warnings.append("第 %d 列 %r 解析后全为空,已跳过" % (c + 1, raw_names[c] or "col%d" % (c + 1)))
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": raw_names[c] or "col%d" % (c + 1),
390
+ "name": name,
224
391
  "units": units[c] or "",
225
392
  "values": vals,
226
393
  "numeric_rows": live,
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ T/V s/�s
2
+ 1 2
3
+ 3 4
@@ -0,0 +1,3 @@
1
+ T/�C y
2
+ 25 1.2
3
+ 30 1.4
@@ -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
 
@@ -0,0 +1,4 @@
1
+ x y
2
+ 1 <0.001
3
+ 2 0.5
4
+ 3 >1e6
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 2.5±0.1
3
+ 2 3.5 +/- 0.2
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 25 mV
3
+ 2 30 mV
@@ -0,0 +1,3 @@
1
+ time,signal
2
+ 0,"1,234.5"
3
+ 1,"2,345.6"
@@ -0,0 +1,3 @@
1
+ Zeit;Signal
2
+ 0,0;1,5
3
+ 1,0;2,5
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 2.5*
3
+ 2 3.5
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 (500)
3
+ 2 250
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 1e400
3
+ 2 1.2e308
@@ -0,0 +1,3 @@
1
+ ch V I
2
+ Ch1 1.0 2.0
3
+ Ch2 3.0 4.0
@@ -0,0 +1,3 @@
1
+ x,y
2
+ 1,"2.5 3.5"
3
+ 2,"4.0 5.0"
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) and (obj != obj or abs(obj - MISSING_SENTINEL) < 1e-310):
405
- return None
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
- target_path = os.path.abspath(path or os.path.join(folder, "%s.%s" % (name, fmt)))
1445
- if os.path.splitext(target_path)[1].lower().lstrip(".") != fmt:
1446
- target_path = os.path.splitext(target_path)[0] + "." + fmt
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
- if not os.path.isfile(target_path):
1577
+ written, note = _written_or_mangled(target_path)
1578
+ if not written:
1450
1579
  raise OriginError("export_failed", "Origin 说导出了但文件不存在:%s" % target_path,
1451
- ["路径必须可写且最好是 ASCII", "先 origin_inspect 确认图页非空"])
1452
- info = _verify_file(target_path)
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 = os.path.abspath(path)
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
- os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
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
- if not os.path.isfile(target):
1622
+ written, _note = _written_or_mangled(target)
1623
+ if not written:
1488
1624
  raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
1489
- ["确认目录可写、路径为 ASCII",
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
- ext = os.path.splitext(path)[1].lower() or ".png"
1722
- target = os.path.abspath(path)
1723
- os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
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
- if not os.path.isfile(target):
1864
+ written, note = _written_or_mangled(target)
1865
+ if not written:
1726
1866
  raise OriginError("export_failed", "矩阵图导出没落盘:%s" % target)
1727
- info = _verify_file(target, must_be_image=ext in IMAGE_MAGIC)
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.5",
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
  }