dsh-origin-bridge 0.4.0 → 0.4.2

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/originlab.py CHANGED
@@ -9,10 +9,13 @@ by a registry, because parsing Origin's own [Book]Sheet! range strings back and
9
9
  forth is the most common source of silent failures.
10
10
  """
11
11
 
12
+ import atexit
13
+ import math
12
14
  import os
13
15
  import queue
14
16
  import re
15
17
  import struct
18
+ import subprocess
16
19
  import sys
17
20
  import threading
18
21
  import time
@@ -28,14 +31,20 @@ PLOT_CODES = {"line": 200, "scatter": 201, "line_symbol": 202, "column": 203}
28
31
  PLOT_LETTERS = {"line": "l", "scatter": "s", "line_symbol": "y", "column": "c"}
29
32
  # originpro passes this integer straight to layadd, and the layer's own name is the
30
33
  # proof: read back as BottomXLeftY/TopX/RightY/LeftY/TopXRightY/BottomXRightY/Inset.
31
- LAYER_TYPES = {"bottom_left": 0, "top": 1, "right": 2, "left": 3,
34
+ # Humans say "bottom" for the plain below-axis layer, which is code 0 (BottomXLeftY).
35
+ LAYER_TYPES = {"bottom": 0, "bottom_left": 0, "top": 1, "right": 2, "left": 3,
32
36
  "top_right": 4, "bottom_right": 5, "inset": 6}
33
37
  # What Origin itself calls each layer, read back from layer.name after add_layer.
34
38
  LAYER_NAME_BY_CODE = {0: "BottomXLeftY", 1: "TopX", 2: "RightY", 3: "LeftY",
35
39
  4: "TopXRightY", 5: "BottomXRightY", 6: "Inset"}
36
40
  # originpro's scale setter takes a name but the getter returns the int.
37
- SCALE_TYPES = {"linear": 1, "log10": 2, "probability": 3, "probit": 4, "reciprocal": 5,
38
- "offset_reciprocal": 6, "logit": 7, "ln": 8, "log2": 9}
41
+ SCALE_TYPES = {"linear": 1, "log10": 2, "log": 2, "probability": 3, "probit": 4,
42
+ "reciprocal": 5,
43
+ "offset_reciprocal": 6, "logit": 7, "ln": 8, "natural_log": 8, "log2": 9}
44
+ # originpro's setter only understands its own names, so a friendly alias such as
45
+ # "log" has to be translated back before it reaches layer.xscale.
46
+ SCALE_NAMES = {1: "linear", 2: "log10", 3: "probability", 4: "probit", 5: "reciprocal",
47
+ 6: "offset_reciprocal", 7: "logit", 8: "ln", 9: "log2"}
39
48
  # new_graph(template=NAME) accepts these OriginPro 2024 template stems (extension
40
49
  # optional) and returns None for an unknown one, which is how they get validated.
41
50
  GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley", "dply",
@@ -46,8 +55,9 @@ GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley", "dply",
46
55
  # these were each constructed successfully on OriginPro 2024 SR1.
47
56
  FIT_PRESETS = {"gauss": "Gauss", "gaussian": "Gauss", "lorentz": "Lorentz",
48
57
  "voigt": "Voigt", "expdec1": "ExpDec1", "expdec2": "ExpDec2",
49
- "sine": "Sine", "power": "Power", "logistic": "Logistic",
50
- "boltzmann": "Boltzmann", "doseresp": "DoseResp", "cubic": "Cubic"}
58
+ "expdecay": "ExpDec1", "sine": "Sine", "power": "Power",
59
+ "logistic": "Logistic", "boltzmann": "Boltzmann", "doseresp": "DoseResp",
60
+ "cubic": "Cubic", "poly3": "Cubic"}
51
61
  # Legend anchors are layer DATA coordinates (Legend.x/.y read back exactly), so a
52
62
  # position is a fraction of the axis span rather than a page measurement.
53
63
  LEGEND_POSITIONS = {
@@ -59,6 +69,7 @@ LEGEND_POSITIONS = {
59
69
  "inside": (0.75, 0.80),
60
70
  }
61
71
  IMAGE_MAGIC = {".png": b"\x89PNG\r\n\x1a\n", ".tif": b"II*\x00", ".tiff": b"II*\x00",
72
+ ".jpg": b"\xff\xd8\xff", ".jpeg": b"\xff\xd8\xff",
62
73
  ".pdf": b"%PDF", ".svg": b"<", ".emf": b"\x01\x00\x00\x00"}
63
74
  ASCII_EXTS = (".dat", ".csv", ".txt", ".tsv")
64
75
 
@@ -78,6 +89,7 @@ _q = queue.Queue()
78
89
  _thread = None
79
90
  _thread_lock = threading.Lock()
80
91
  _op = None
92
+ _suspect = False
81
93
 
82
94
 
83
95
  def _worker():
@@ -91,22 +103,78 @@ def _worker():
91
103
 
92
104
 
93
105
  def call(fn, *args, timeout=240, **kwargs):
94
- global _thread
106
+ global _thread, _suspect
95
107
  with _thread_lock:
96
108
  if _thread is None or not _thread.is_alive():
97
109
  _thread = threading.Thread(target=_worker, daemon=True, name="origin-com")
98
110
  _thread.start()
99
- box = {"done": threading.Event()}
100
- _q.put((fn, args, kwargs, box))
101
- if not box["done"].wait(timeout):
102
- raise OriginError(
103
- "com_timeout",
104
- "Origin 在 %d 秒内没有响应" % timeout,
105
- ["看 Origin 窗口是否卡在对话框或许可证弹窗", "任务管理器结束 Origin64.exe 后重试"],
106
- )
107
- if "error" in box:
108
- raise box["error"]
109
- return box["value"]
111
+ if _suspect:
112
+ # A previous call left Origin's COM channel in a bad state (a destroyed page
113
+ # keeps a C-level error pending, which then surfaces from an unrelated call).
114
+ # Re-attach once, lazily, so one wedge does not poison the whole session.
115
+ _suspect = False
116
+ _reset_connection()
117
+ for attempt in (0, 1):
118
+ box = {"done": threading.Event()}
119
+ _q.put((fn, args, kwargs, box))
120
+ if not box["done"].wait(timeout):
121
+ raise OriginError(
122
+ "com_timeout",
123
+ "Origin 在 %d 秒内没有响应" % timeout,
124
+ ["看 Origin 窗口是否卡在对话框或许可证弹窗", "任务管理器结束 Origin64.exe 后重试"],
125
+ )
126
+ if "error" not in box:
127
+ return box["value"]
128
+ exc = box["error"]
129
+ # Once per call: Origin's cached COM pointer can go stale when the process
130
+ # dies (user closed it, instance limit, exit tool). Every later call then
131
+ # fails identically, so drop the pointer and let origin() re-attach.
132
+ if attempt == 0 and _dead_app(exc) and _reset_connection():
133
+ continue
134
+ raise exc
135
+
136
+
137
+ _DEAD_APP_MARKS = ("returned a result with an exception set", "ApplicationBase_",
138
+ "-2147417851", "-2147023170", "RPC 服务器不可用",
139
+ "The object invoked has disconnected from its clients",
140
+ "com_error", "Pywintypes.com_error")
141
+
142
+
143
+ def _dead_app(exc):
144
+ """True when Origin's COM connection itself is gone, not when an argument was bad."""
145
+ if isinstance(exc, OriginError):
146
+ # server.py flattens a raw SystemError into code="internal_error" before it
147
+ # reaches here, so judge by the message text rather than the code.
148
+ return any(m in exc.message for m in _DEAD_APP_MARKS)
149
+ text = "%s: %s" % (type(exc).__name__, exc)
150
+ return isinstance(exc, SystemError) or any(m in text for m in _DEAD_APP_MARKS)
151
+
152
+
153
+ def _reset_connection():
154
+ """Forget the cached originpro module and its dangling Application pointer."""
155
+ global _op, _suspect
156
+ _op = None
157
+ _suspect = False
158
+ # Page wrappers belong to the Origin process we just lost: keeping them would
159
+ # let the next call fail with a mystery error instead of "句柄无效,请重新导入".
160
+ _handles.clear()
161
+ _names.clear()
162
+ mod = sys.modules.get("originpro")
163
+ if mod is None:
164
+ return False
165
+ po = getattr(mod, "po", None)
166
+ if po is not None and "_app" in getattr(po, "__dict__", {}):
167
+ try:
168
+ po.__dict__["_app"] = None
169
+ except Exception:
170
+ return False
171
+ return True
172
+
173
+
174
+ def mark_suspect():
175
+ """Flag the COM channel as damaged: the next call re-attaches before running."""
176
+ global _suspect
177
+ _suspect = True
110
178
 
111
179
 
112
180
  def origin():
@@ -119,6 +187,10 @@ def origin():
119
187
  except ImportError as exc:
120
188
  raise OriginError("missing_originpro", "这个 Python 环境里没有 originpro:%s" % exc,
121
189
  ["pip install originpro"])
190
+ # Record who was already running: anything that appears after we connect was
191
+ # started by us, and anything windowless that predates us is an orphan left by
192
+ # a client that exited without releasing its Origin.
193
+ before = _process_rows()
122
194
  try:
123
195
  op.path()
124
196
  except Exception as exc:
@@ -128,12 +200,152 @@ def origin():
128
200
  ["确认装了 Origin/OriginPro 2021 或更高(originpro 的硬性下限)",
129
201
  "确认 Origin 与 Python 都是 64 位",
130
202
  "若任务管理器里有多个 Origin64.exe,只保留一个",
131
- "长会话攒页面后桥会坏:先 origin_exit 让插件重启 Origin"],
203
+ "长会话攒页面后桥会坏:先 origin_exit 让插件重启 Origin",
204
+ "实例数到上限时:origin_reclaim 会关掉没有窗口的残留实例"],
132
205
  )
206
+ existing = {row["pid"] for row in before} if before is not None else set()
207
+ after = _process_rows()
208
+ if after is not None:
209
+ _started_pids.update(row["pid"] for row in after if row["pid"] not in existing)
133
210
  _op = op
134
211
  return op
135
212
 
136
213
 
214
+ # ------------------------------------------------------- instance lifecycle
215
+
216
+ ORIGIN_EXE = "Origin64.exe"
217
+ _started_pids = set() # instances that appeared because WE connected
218
+
219
+
220
+ def _process_rows():
221
+ """List Origin64 processes as {pid, started, window}, or None if unknown.
222
+
223
+ MainWindowHandle is what separates an instance the human can see from an
224
+ orphan a departing client left behind: originpro launches Origin on demand
225
+ and Origin does not quit when its client process exits.
226
+
227
+ A failed probe returns None, never an empty list: an empty list reads as
228
+ "there is nothing to reclaim", which is the one thing a caller must not be
229
+ told when we could not actually look.
230
+ """
231
+ if os.name != "nt":
232
+ return []
233
+ script = ("Get-Process Origin64 -ErrorAction SilentlyContinue | ForEach-Object {"
234
+ "\"$($_.Id)|\" + $(try { $_.StartTime.ToString('o') } catch { '' }) + "
235
+ "\"|$($_.MainWindowHandle)\"}")
236
+ try:
237
+ out = subprocess.run(["powershell", "-NoProfile", "-Command", script],
238
+ capture_output=True, text=True, timeout=25).stdout
239
+ except Exception:
240
+ return None
241
+ rows = []
242
+ for line in out.splitlines():
243
+ parts = line.strip().split("|")
244
+ if len(parts) != 3:
245
+ continue
246
+ try:
247
+ rows.append({"pid": int(parts[0]), "started": parts[1], "window": int(parts[2] or 0)})
248
+ except ValueError:
249
+ continue
250
+ return rows
251
+
252
+
253
+ def _terminate(pid, force=False):
254
+ args = ["taskkill"] + (["/F"] if force else []) + ["/PID", str(pid)]
255
+ return subprocess.run(args, capture_output=True, text=True, timeout=30)
256
+
257
+
258
+ def _close_pid(pid):
259
+ """Ask for a polite exit first; escalate to a forced kill only if it survives."""
260
+ _terminate(pid)
261
+ for _ in range(6):
262
+ time.sleep(0.5)
263
+ rows = _process_rows()
264
+ if rows is None:
265
+ continue # probe failed; keep polling rather than guessing "gone"
266
+ if pid not in {row["pid"] for row in rows}:
267
+ return "closed"
268
+ rows = _process_rows()
269
+ if rows is not None and pid not in {row["pid"] for row in rows}:
270
+ return "closed"
271
+ _terminate(pid, force=True)
272
+ return "force_closed"
273
+
274
+
275
+ def instances():
276
+ """Report every Origin instance: background (reclaimable) vs foreground (the user's)."""
277
+ rows = _process_rows()
278
+ if rows is None:
279
+ return sanitize({"count": None, "probe": "unavailable",
280
+ "error": "读不到进程表(powershell 不可用或超时),无法确定实例数量",
281
+ "next_actions": ["重试 origin_instances", "或在任务管理器里看 Origin64.exe"]})
282
+ for row in rows:
283
+ row["foreground"] = bool(row["window"])
284
+ row["ours"] = row["pid"] in _started_pids
285
+ return sanitize({"count": len(rows), "probe": "ok",
286
+ "background": [r for r in rows if not r["foreground"]],
287
+ "foreground": [r for r in rows if r["foreground"]],
288
+ "started_by_us": sorted(_started_pids)})
289
+
290
+
291
+ def reclaim(close_background=True):
292
+ """Close windowless Origin instances; a visible window is reported, never killed.
293
+
294
+ An instance with no window has no client and no human in front of it. One with
295
+ a window may be the user's own unsaved work, so it is listed for them to close.
296
+ """
297
+ rows = _process_rows()
298
+ if rows is None:
299
+ return sanitize({"closed": [], "force_closed": [], "left_running": [],
300
+ "probe": "unavailable",
301
+ "error": "读不到进程表,没有动任何进程",
302
+ "notify": "无法确认实例情况(进程探测失败),这次什么都没关。"})
303
+ back = [r for r in rows if not r["window"]]
304
+ front = [r for r in rows if r["window"]]
305
+ result = {"probe": "ok", "closed": [], "force_closed": [],
306
+ "left_running": [r["pid"] for r in front], "notify": ""}
307
+ if not close_background:
308
+ result["skipped"] = [r["pid"] for r in back]
309
+ return sanitize(result)
310
+
311
+ for row in back:
312
+ pid = row["pid"]
313
+ try:
314
+ outcome = _close_pid(pid)
315
+ except Exception as exc:
316
+ result["left_running"].append(pid)
317
+ result["notify"] += "PID %s 关不掉:%s;" % (pid, exc)
318
+ continue
319
+ result[outcome].append(pid)
320
+
321
+ if front:
322
+ result["notify"] += ("检测到 %d 个**有窗口**的 Origin 实例,没有动它们(可能是你自己开着的项目,"
323
+ "含未保存工作)。要释放实例配额请自己关掉这些窗口,"
324
+ "或先 origin_save_project 保存。") % len(front)
325
+ if not result["notify"]:
326
+ result["notify"] = "已回收全部后台实例,没有发现前台窗口。"
327
+ _started_pids.difference_update(result["closed"] + result["force_closed"])
328
+ return sanitize(result)
329
+
330
+
331
+ def _release_on_exit():
332
+ """A client that exits must not leave its own Origin instance running."""
333
+ if not _started_pids:
334
+ return
335
+ try:
336
+ rows = _process_rows()
337
+ if rows is None:
338
+ return # cannot tell which pids are ours; leaving Origin is the safe error
339
+ for row in rows:
340
+ if row["pid"] in _started_pids and not row["window"]:
341
+ _close_pid(row["pid"])
342
+ except Exception:
343
+ pass
344
+
345
+
346
+ atexit.register(_release_on_exit)
347
+
348
+
137
349
  # ---------------------------------------------------------------- handles
138
350
 
139
351
  _handles = {}
@@ -210,11 +422,23 @@ def _round2(v):
210
422
 
211
423
  def _col_index(ws, col):
212
424
  ncol = int(_safe(lambda: ws.cols, 0) or 0)
425
+ if ncol == 0:
426
+ # A handle that resolves to a page with no columns is always either stale
427
+ # (the project was closed underneath us) or an empty book. Saying "列号超出
428
+ # 范围(共 0 列)" would send the model to re-count columns.
429
+ raise OriginError("sheet_empty", "这张工作表没有任何列,句柄可能已失效:%s"
430
+ % (_safe(lambda: ws.name, "?"),),
431
+ ["origin_inspect 看当前页面", "重新 origin_import 或 origin_write 拿新句柄"])
213
432
  idx = int(col) if isinstance(col, float) and float(col).is_integer() else (
214
433
  int(col) if isinstance(col, int) else None)
215
434
  if idx is not None:
216
- idx = idx - 1 if idx >= 1 else idx
217
- if 0 <= idx < ncol:
435
+ # Column numbers are 1-based everywhere in this API. Passing the internal
436
+ # 0-based index straight through would silently rewrite the first column.
437
+ if idx < 1:
438
+ raise OriginError("bad_column", "列号从 1 开始,收到 %s" % col,
439
+ ["想操作第 1 列就传 1,或改用列名字符串"])
440
+ idx = idx - 1
441
+ if idx < ncol:
218
442
  return idx
219
443
  raise OriginError("bad_column", "列号 %s 超出范围(共 %d 列)" % (col, ncol),
220
444
  ["列号从 1 开始,或用列名字符串"])
@@ -247,6 +471,16 @@ def _extend_to(ws, col):
247
471
  return want - 1
248
472
 
249
473
 
474
+ def _numeric_columns(ws):
475
+ """Column indices that hold at least one real number (0-based)."""
476
+ out = []
477
+ for i in range(int(_safe(lambda: ws.cols, 0) or 0)):
478
+ vals = _safe(lambda i=i: ws.to_list(i), []) or []
479
+ if any(isinstance(v, (int, float)) and not isinstance(v, bool) for v in vals):
480
+ out.append(i)
481
+ return out
482
+
483
+
250
484
  def _sheet_info(ws):
251
485
  rows = int(_safe(lambda: ws.rows, 0) or 0)
252
486
  cols = int(_safe(lambda: ws.cols, 0) or 0)
@@ -263,13 +497,13 @@ def _sheet_info(ws):
263
497
  }
264
498
 
265
499
 
266
- def _verify_file(path, must_be_image=True):
500
+ def _verify_file(path, must_be_image=True, min_bytes=1024):
267
501
  size = os.path.getsize(path)
268
502
  ext = os.path.splitext(path)[1].lower()
269
503
  with open(path, "rb") as fh:
270
504
  head = fh.read(24)
271
505
  magic = IMAGE_MAGIC.get(ext) if must_be_image else None
272
- ok = size > 1024 and (magic is None or head.startswith(magic))
506
+ ok = size > min_bytes and (magic is None or head.startswith(magic))
273
507
  dims = None
274
508
  if ext == ".png" and head[12:16] == b"IHDR":
275
509
  dims = list(struct.unpack(">II", head[16:24]))
@@ -400,7 +634,16 @@ def import_file(path, book_name=""):
400
634
  raise OriginError("import_empty", "读进来了但是 0 行:%s" % path,
401
635
  ["自研解析器也失败了:%s" % (parsed or {}).get("fallback_reason", "见上"),
402
636
  "确认文件里有数值行,或改用 origin_read_file 先看结构"])
403
- info.update({"source": path, "format": ext, "channel": "origin_from_file"})
637
+ warns = []
638
+ if not _numeric_columns(ws):
639
+ # Origin happily turns prose into a one-row text sheet; saying rows=1
640
+ # and calling it a success is how a bad file reaches a plot step.
641
+ warns.append("导进来的列里没有一列是数值(共 %d 列 %d 行),这张表画不出图:%s"
642
+ % (info.get("cols"), info.get("rows"), path))
643
+ info.update({"source": path, "format": ext, "channel": "origin_from_file",
644
+ "warnings": info.get("warnings", []) + warns})
645
+ if warns:
646
+ info["next_actions"] = ["用 origin_read_file 看文件真实结构,确认它是不是数据文件"]
404
647
  return sanitize(info)
405
648
 
406
649
  return call(run)
@@ -469,6 +712,8 @@ def column_formula(worksheet, col, formula, label="", units=""):
469
712
 
470
713
  def run():
471
714
  idx = _col_index(ws, col) if _col_exists(ws, col) else _extend_to(ws, col)
715
+ prev_formula = _safe(lambda: str(ws.get_formula(idx)), "")
716
+ before = [v for v in _safe(lambda: list(ws.to_list(idx)), []) if v is not None]
472
717
  # originpro 1.1.15 documents set_formula's column as 1-offset, but it is
473
718
  # 0-based here: passing idx+1 grabs a non-existent column and the library
474
719
  # dies inside SetStrProp on a None object.
@@ -485,9 +730,19 @@ def column_formula(worksheet, col, formula, label="", units=""):
485
730
  ["LabTalk 里底 10 对数是 log() 不是 log10()",
486
731
  "跨列引用要先绑定 range,不能内联 [Book]Sheet!col(N)",
487
732
  "列号用 Col(1) 这种当前表写法试试"])
733
+ warns = []
734
+ proof = "verified"
735
+ if before and before == vals and prev_formula.strip() != str(formula).strip():
736
+ # A bogus LabTalk formula on a column that already had data leaves the
737
+ # old numbers in place, so "computed_rows > 0" alone cannot prove much.
738
+ warns.append("改了公式但这一列的数值一个都没变(%d 行原样保留):Origin 可能没有执行它"
739
+ % len(vals))
740
+ proof = "unverified"
488
741
  return sanitize({"column": idx + 1, "formula": str(formula),
489
742
  "computed_rows": len(vals), "first": vals[0], "last": vals[-1],
490
- "columns": info["columns"], "proof_level": "verified"})
743
+ "columns": info["columns"], "warnings": warns, "proof_level": proof,
744
+ **({} if proof == "verified"
745
+ else {"next_actions": ["origin_inspect 看这一列现在到底是什么值"]})})
491
746
 
492
747
  return call(run)
493
748
 
@@ -647,13 +902,15 @@ def style(graph, layer=0, series=None, x_title="", y_title="", x_scale="", y_sca
647
902
  if want is None:
648
903
  raise OriginError("bad_scale", "未知刻度类型:%r" % x_scale,
649
904
  ["可选 %s" % sorted(SCALE_TYPES)])
650
- attempt("x_scale", lambda: setattr(lay, "xscale", x_scale), lambda: lay.xscale, want)
905
+ attempt("x_scale", lambda: setattr(lay, "xscale", SCALE_NAMES[want]),
906
+ lambda: lay.xscale, want)
651
907
  if y_scale:
652
908
  want = SCALE_TYPES.get(str(y_scale).lower())
653
909
  if want is None:
654
910
  raise OriginError("bad_scale", "未知刻度类型:%r" % y_scale,
655
911
  ["可选 %s" % sorted(SCALE_TYPES)])
656
- attempt("y_scale", lambda: setattr(lay, "yscale", y_scale), lambda: lay.yscale, want)
912
+ attempt("y_scale", lambda: setattr(lay, "yscale", SCALE_NAMES[want]),
913
+ lambda: lay.yscale, want)
657
914
  if xlim:
658
915
  attempt("xlim", lambda: lay.set_xlim(*_pair(xlim)), lambda: lay.xlim)
659
916
  if ylim:
@@ -718,16 +975,60 @@ def _pair(v):
718
975
  return vals[:3]
719
976
 
720
977
 
978
+ def _axis_span(lay, which):
979
+ """(begin, end) of an axis, via the layer's xlim/ylim — axis.from_ is not readable here."""
980
+ limits = _safe(lambda: getattr(lay, "%slim" % which))
981
+ try:
982
+ lo, hi = float(limits[0]), float(limits[1])
983
+ except (TypeError, ValueError, IndexError):
984
+ return None, None
985
+ return (lo, hi) if lo <= hi else (hi, lo)
986
+
987
+
988
+ def _line_coords(lay, item):
989
+ """Reference lines are usually given as one coordinate: x=12 is a vertical line."""
990
+ if all(k in item for k in ("x1", "y1", "x2", "y2")):
991
+ return [item[k] for k in ("x1", "y1", "x2", "y2")]
992
+ has_x, has_y = "x" in item, "y" in item
993
+ if has_x and not has_y:
994
+ lo, hi = _axis_span(lay, "y")
995
+ if lo is None or hi is None:
996
+ raise OriginError("bad_arguments", "读不到 Y 轴范围,竖参考线请给 x1/y1/x2/y2")
997
+ return [item["x"], lo, item["x"], hi]
998
+ if has_y and not has_x:
999
+ lo, hi = _axis_span(lay, "x")
1000
+ if lo is None or hi is None:
1001
+ raise OriginError("bad_arguments", "读不到 X 轴范围,横参考线请给 x1/y1/x2/y2")
1002
+ return [lo, item["y"], hi, item["y"]]
1003
+ raise OriginError("bad_arguments",
1004
+ "lines 的每一项要有 x 或 y(单坐标=参考线),或 x1/y1/x2/y2(线段):收到 %r" % (item,),
1005
+ ['竖线 {"x": 12},横线 {"y": 50},任意线段 {"x1":0,"y1":0,"x2":1,"y2":2}'])
1006
+
1007
+
721
1008
  def annotate(graph, labels=(), lines=(), layer=0):
722
1009
  """Text labels and reference lines in data coordinates."""
723
1010
  if not labels and not lines:
724
1011
  raise OriginError("bad_arguments", "labels 和 lines 至少给一个")
1012
+ for name, items in (("labels", labels or ()), ("lines", lines or ())):
1013
+ if isinstance(items, str) or not isinstance(items, (list, tuple)):
1014
+ raise OriginError("bad_arguments", "%s 要是列表,收到 %r" % (name, type(items).__name__))
1015
+ for item in items:
1016
+ if not isinstance(item, dict):
1017
+ raise OriginError("bad_arguments",
1018
+ "%s 的每一项要是对象,如 {\"text\":..,\"x\":..,\"y\":..};收到 %r"
1019
+ % (name, item),
1020
+ ["labels: [{\"text\":\"峰值\",\"x\":1.2,\"y\":30}]"])
725
1021
 
726
1022
  def run():
727
1023
  gr = _graph_of(origin(), graph)
728
1024
  if gr is None:
729
1025
  raise OriginError("graph_not_found", "图页句柄无效:%s" % graph)
730
- lay = gr[layer]
1026
+ try:
1027
+ lay = gr[layer]
1028
+ except Exception:
1029
+ raise OriginError("bad_layer", "图页 %s 没有第 %d 层(共 %d 层)"
1030
+ % (_safe(lambda: gr.name, "?"), layer, _layer_count(gr)),
1031
+ ["先用 origin_inspect 看层数"])
731
1032
  done = []
732
1033
  for item in labels or ():
733
1034
  text = str(item.get("text", ""))
@@ -735,8 +1036,8 @@ def annotate(graph, labels=(), lines=(), layer=0):
735
1036
  lab = _safe(lambda: lay.add_label(text, x, y))
736
1037
  done.append({"kind": "label", "text": text, "at": [x, y], "ok": lab is not None})
737
1038
  for item in lines or ():
738
- coords = [item.get(k) for k in ("x1", "y1", "x2", "y2")]
739
- ln = _safe(lambda: lay.add_line(*coords))
1039
+ coords = _line_coords(lay, item)
1040
+ ln = _safe(lambda c=coords: lay.add_line(*c))
740
1041
  done.append({"kind": "line", "coords": coords, "ok": ln is not None,
741
1042
  "width": _safe(lambda: ln.width) if ln is not None else None,
742
1043
  "type": _safe(lambda: ln.type) if ln is not None else None})
@@ -891,7 +1192,7 @@ def view(graph, width=900):
891
1192
  raise OriginError("graph_not_found", "没有可预览的图页;先 origin_inspect 看页面")
892
1193
  # Unique name: a fixed path would let two dsh sessions overwrite each
893
1194
  # other's preview mid-read.
894
- fd, tmp = tempfile.mkstemp(prefix="optima_plot_view_", suffix=".png")
1195
+ fd, tmp = tempfile.mkstemp(prefix="origin_bridge_view_", suffix=".png")
895
1196
  os.close(fd)
896
1197
  os.remove(tmp)
897
1198
  # save_fig rejects a type that disagrees with the extension, and its
@@ -914,7 +1215,8 @@ def view(graph, width=900):
914
1215
  return call(run)
915
1216
 
916
1217
 
917
- LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x")
1218
+ LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x",
1219
+ "close;", "close ", "system ", "shell ", "run.section", "run.open", "fopen")
918
1220
 
919
1221
 
920
1222
  def labtalk(script, readback=(), numeric=()):
@@ -943,7 +1245,15 @@ def labtalk(script, readback=(), numeric=()):
943
1245
  strs[str(name)] = _safe(lambda n=name: op.get_lt_str(n), None)
944
1246
  nums = {}
945
1247
  for name in numeric or ():
946
- nums[str(name)] = _safe(lambda n=name: op.lt_float(n), None)
1248
+ def read(n=name):
1249
+ # lt_float returns nan for integer-typed LabTalk variables
1250
+ # (layer.showlegend, Legend.Show), which would make a successful
1251
+ # change look unverifiable.
1252
+ value = op.lt_float(n)
1253
+ if isinstance(value, float) and math.isnan(value):
1254
+ return op.lt_int(n)
1255
+ return value
1256
+ nums[str(name)] = _safe(read, None)
947
1257
  return sanitize({"script": str(script), "returned": bool(ret),
948
1258
  "readback": strs, "numeric": nums,
949
1259
  "proof_level": "verified" if (ret and (strs or nums)) else
@@ -994,7 +1304,14 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
994
1304
  ws = _resolve("ws", worksheet)
995
1305
  if ws is None:
996
1306
  raise OriginError("worksheet_not_found", "工作表句柄无效:%s" % worksheet)
997
- kind = str(kind).lower()
1307
+ kind = str(kind or "").lower()
1308
+ switched = False
1309
+ if func and kind in ("", "linear"):
1310
+ # Naming a fitting function means a nonlinear fit: running the linear one
1311
+ # anyway returned slope/intercept for a caller who asked for Gauss.
1312
+ kind, switched = "nlfitsing", True
1313
+ if not kind:
1314
+ kind = "linear"
998
1315
  if kind in FIT_PRESETS:
999
1316
  func, kind = FIT_PRESETS[kind], "nlfitsing"
1000
1317
  if kind not in ("linear", "nlfitsing", "nlfit"):
@@ -1027,9 +1344,11 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
1027
1344
  try:
1028
1345
  nl = op.NLFit(func)
1029
1346
  except Exception as exc:
1030
- raise OriginError("fit_function_unavailable", "函数 %r 不可用:%s" % (func, exc),
1031
- ["NLFit 需要 OriginPro 授权",
1032
- "本机实测可用的名字:%s" % ", ".join(sorted(set(FIT_PRESETS.values())))])
1347
+ raise OriginError("fit_function_unavailable",
1348
+ "函数 %r 不是 Origin 里已定义的拟合函数:%s" % (func, exc),
1349
+ ["func 只能是 Origin 内置函数名,自定义表达式(如 a*exp(-b*x)+c)不支持",
1350
+ "本机实测可用的预设:%s" % ", ".join(sorted(set(FIT_PRESETS))),
1351
+ "NLFit 需要 OriginPro 授权"])
1033
1352
  nl.set_data(ws, xi, yi)
1034
1353
  notes = []
1035
1354
  for name, val in (starts or {}).items():
@@ -1053,6 +1372,8 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
1053
1372
  return sanitize({"kind": "nlfitsing", "func": func, "r_squared": r2,
1054
1373
  "fit_status": res.get("fitstatus") if isinstance(res, dict) else None,
1055
1374
  "constraints": notes, "trimmed": not full,
1375
+ **({"note": "你给了 func,所以按非线性拟合执行(kind 省略时默认 linear)"}
1376
+ if switched else {}),
1056
1377
  **({"result": body} if full else {"fit": body})})
1057
1378
 
1058
1379
  return call(run, timeout=300)
@@ -1060,8 +1381,11 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
1060
1381
 
1061
1382
  def export(graph, path="", fmt="png", width=1600):
1062
1383
  fmt = str(fmt).lstrip(".").lower()
1063
- if fmt not in ("png", "tif", "tiff", "svg", "pdf", "emf"):
1064
- raise OriginError("bad_format", "fmt 只支持 png/tif/svg/pdf/emf,收到 %s" % fmt)
1384
+ # Origin's export filter only recognises the short names: asking for a.tiff
1385
+ # leaves no file behind, which reads as a failed export.
1386
+ fmt = {"tiff": "tif", "jpeg": "jpg"}.get(fmt, fmt)
1387
+ if fmt not in ("png", "tif", "jpg", "svg", "pdf", "emf"):
1388
+ raise OriginError("bad_format", "fmt 只支持 png/tif/jpg/svg/pdf/emf,收到 %s" % fmt)
1065
1389
 
1066
1390
  def run():
1067
1391
  op = origin()
@@ -1070,6 +1394,8 @@ def export(graph, path="", fmt="png", width=1600):
1070
1394
  raise OriginError("graph_not_found", "找不到图页:%s(项目里可能还没有图)" % (graph or ""))
1071
1395
  name = _safe(lambda: page.name, "Graph1")
1072
1396
  target_path = os.path.abspath(path or os.path.join(os.getcwd(), "%s.%s" % (name, fmt)))
1397
+ if os.path.splitext(target_path)[1].lower().lstrip(".") != fmt:
1398
+ target_path = os.path.splitext(target_path)[0] + "." + fmt
1073
1399
  os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
1074
1400
  _safe(lambda: page.save_fig(target_path))
1075
1401
  if not os.path.isfile(target_path):
@@ -1097,7 +1423,7 @@ def _save_project(path=""):
1097
1423
  # Never default to the process CWD: under dsh that is the plugin's
1098
1424
  # node_modules folder, so an unnamed save would litter the install.
1099
1425
  target = os.path.join(_safe(lambda: op.path("u"), "") or os.getcwd(),
1100
- time.strftime("optima_plot_%Y%m%d_%H%M%S.opju"))
1426
+ time.strftime("origin_bridge_%Y%m%d_%H%M%S.opju"))
1101
1427
  if not target.lower().endswith((".opju", ".opj")):
1102
1428
  target += ".opju"
1103
1429
  os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
@@ -1119,13 +1445,18 @@ def _save_project(path=""):
1119
1445
  raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
1120
1446
  ["该文件可能正被 Origin 占用(工程绑定后会加锁)",
1121
1447
  "换一个不冲突的文件名,或省略 path 用自动时间戳命名"])
1122
- info = _verify_file(target, must_be_image=False)
1448
+ info = _verify_file(target, must_be_image=False, min_bytes=256)
1123
1449
  if not info["ok"]:
1124
- raise OriginError("save_suspect", "工程文件过小:%s" % info,
1450
+ raise OriginError("save_suspect", "工程文件异常:%s" % info,
1125
1451
  ["项目里得先有工作表或图页再保存"])
1452
+ pages_now = len(list(_safe(lambda: op.pages(), []) or []))
1126
1453
  _project_file = target
1127
- out = dict(info, editable=True, saved=bool(ok),
1454
+ out = dict(info, editable=True, saved=bool(ok), pages=pages_now,
1128
1455
  note="可在 Origin 里继续编辑的 .opju,不是图片快照")
1456
+ if not pages_now:
1457
+ # An empty project really is a few hundred bytes: calling that
1458
+ # "文件过小" was a false alarm on origin_exit(save_to=...).
1459
+ out["warnings"] = ["工程里一个页面都没有,存下来的 .opju 打开是空的"]
1129
1460
  if same_file and not ok:
1130
1461
  out["unchanged"] = True
1131
1462
  out["note"] = "项目已绑定在该文件上且无待写变更,文件保持原样"
@@ -1163,6 +1494,11 @@ def inspect(graph=""):
1163
1494
  "lname": _safe(lambda p=p: p.lname, "")} for p in op.pages()]
1164
1495
  out = {"pages": pages, "handles": sorted(_handles)}
1165
1496
  page = _graph_of(op, graph)
1497
+ if page is None and str(graph or "").strip():
1498
+ # A handle that no longer exists must not degrade into "here are the
1499
+ # other pages", which reads as a successful inspection of nothing.
1500
+ raise OriginError("graph_not_found", "图页句柄无效或已关闭:%s" % graph,
1501
+ ["origin_inspect 不带 graph 先看页面清单", "或重新 origin_plot 拿句柄"])
1166
1502
  if page is not None:
1167
1503
  layers = []
1168
1504
  i = 0
@@ -1190,7 +1526,7 @@ def exit_origin(save_to=""):
1190
1526
  _handles.clear()
1191
1527
  _names.clear()
1192
1528
  _project_file = ""
1193
- _op = None
1529
+ _reset_connection()
1194
1530
  return sanitize({"exited": True, "saved": saved})
1195
1531
 
1196
1532
  return call(run)
@@ -1435,10 +1771,13 @@ def _catalog():
1435
1771
 
1436
1772
  def chart_presets(category=""):
1437
1773
  cat = _catalog()
1438
- items = cat.list_templates(category or None)
1439
- return sanitize({"n_templates": len(items),
1440
- "categories": sorted({t["category"] for t in cat.TEMPLATES}),
1441
- "templates": items})
1774
+ known = sorted({t["category"] for t in cat.TEMPLATES})
1775
+ want = str(category or "").strip().lower()
1776
+ if want and want not in {c.lower() for c in known}:
1777
+ raise OriginError("bad_category", "没有这个图表预设分类:%r" % category,
1778
+ ["可用分类:%s" % ", ".join(known)])
1779
+ items = cat.list_templates(want or None)
1780
+ return sanitize({"n_templates": len(items), "categories": known, "templates": items})
1442
1781
 
1443
1782
 
1444
1783
  def chart_preset(name):
@@ -1457,7 +1796,26 @@ def chart_check(name, data=None, columns=None):
1457
1796
  if tpl is None:
1458
1797
  raise OriginError("preset_not_found", "没有这个图表预设:%r" % name)
1459
1798
  if data and not columns:
1460
- columns = [c["name"] for c in read_file(data)["columns"]]
1799
+ # `data` is documented as a file path, but models hand us dicts and lists
1800
+ # here constantly; accept those as column sources instead of dying with a
1801
+ # raw TypeError, and say so when the value is neither.
1802
+ if isinstance(data, dict):
1803
+ columns = list(data)
1804
+ elif isinstance(data, (list, tuple)):
1805
+ columns = [str(c) for c in data]
1806
+ elif isinstance(data, str):
1807
+ try:
1808
+ parsed = read_file(data)
1809
+ except OriginError:
1810
+ raise
1811
+ except Exception as exc:
1812
+ raise OriginError("bad_arguments",
1813
+ "data 要的是数据文件路径,或者列名列表/字典:%s" % exc,
1814
+ ["origin_chart_check 传 columns=[...] 更直接"]) from exc
1815
+ columns = [c["name"] for c in parsed["columns"]]
1816
+ else:
1817
+ raise OriginError("bad_arguments", "data 只能是文件路径字符串、列名字典或列表",
1818
+ ["origin_chart_check 传 columns=[...] 更直接"])
1461
1819
  detected = list(columns or [])
1462
1820
  issues = []
1463
1821
  for req in tpl["required_columns"] or ():
@@ -1484,6 +1842,15 @@ def chart_render(name, source="", columns=None, x=1, y=2, output_dir="",
1484
1842
  throw away whatever project the user has open in Origin.
1485
1843
  """
1486
1844
  checked = chart_check(name, data=source) if not columns else chart_check(name, columns=columns)
1845
+ if checked["issues"]:
1846
+ # Drawing a preset over data that does not carry its columns produced a
1847
+ # chart with the wrong axis labels and no hint anything was off.
1848
+ raise OriginError("preset_columns_mismatch",
1849
+ "%s 预设的列对不上:%s(识别到的列:%s)"
1850
+ % (name, ";".join(checked["issues"]), checked["columns_detected"] or "无"),
1851
+ ["把数据列名改成预设要求的名字(见 required_columns)",
1852
+ "换一个匹配数据的预设:origin_chart_presets",
1853
+ "不套预设只是画图:用 origin_figure"])
1487
1854
  preset = chart_preset(name)
1488
1855
  kind = str(plot_type or preset.get("default_plot_kind") or "line").lower()
1489
1856
  if kind not in PLOT_CODES: