dsh-origin-bridge 0.4.5 → 0.4.7

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
@@ -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
 
@@ -540,7 +669,16 @@ def read_file(path, comment=None):
540
669
  path = os.path.abspath(os.path.expanduser(path))
541
670
  if not os.path.isfile(path):
542
671
  raise OriginError("file_not_found", "文件不存在:%s" % path)
543
- if os.path.splitext(path)[1].lower() == ".bin":
672
+ ext = os.path.splitext(path)[1].lower()
673
+ if ext in (".xls", ".xlsx", ".wks"):
674
+ # Decoding a zip/OLE container as text "succeeds" into mojibake and then reports
675
+ # "只有表头?", blaming the user's file for our mistake (measured 2026-10-19).
676
+ raise OriginError(
677
+ "unsupported_file_type",
678
+ "%s 是二进制表格,origin_read_file 只读文本类数据" % ext,
679
+ ["用 origin_import 直接导入(走 Origin 原生导入)",
680
+ "或先在 Excel 里另存为 .csv,再用 origin_read_file 预览结构"])
681
+ if ext == ".bin":
544
682
  try:
545
683
  decoded = binio.read_bin(path)
546
684
  except binio.BinFormatError as exc:
@@ -560,8 +698,14 @@ def read_file(path, comment=None):
560
698
  except ValueError as exc:
561
699
  raise OriginError("parse_failed", str(exc),
562
700
  ["确认文件里有数值行", "给 comment 指定实际的注释前缀"])
563
- parsed["columns"] = [{k: v for k, v in c.items() if k != "values"}
564
- for c in parsed["columns"]]
701
+ # Three raw values per column: enough for the model to spot a shifted column or a
702
+ # column that parsed to nothing, without putting the whole dataset in context.
703
+ parsed["columns"] = [
704
+ dict({k: v for k, v in c.items() if k != "values"},
705
+ sample=(c.get("values") or [])[:3])
706
+ for c in parsed["columns"]]
707
+ parsed["sample_note"] = ("sample 是每列前 3 个原始值(缺值为 null),只用来抽查解析是否正确;"
708
+ "完整数据用 origin_import 进 Origin")
565
709
  parsed["proof_level"] = "verified"
566
710
  return sanitize(parsed)
567
711
 
@@ -1441,18 +1585,24 @@ def export(graph, path="", fmt="png", width=1600):
1441
1585
  # No path is not an invitation to write into the process CWD: under dsh
1442
1586
  # that is the plugin's node_modules folder. Origin's User Files wins.
1443
1587
  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)
1588
+ raw_path = str(path or "").strip()
1589
+ target_path = _norm_target(raw_path or os.path.join(folder, "%s.%s" % (name, fmt)), fmt)
1590
+ _prepare_write(target_path)
1448
1591
  _safe(lambda: page.save_fig(target_path))
1449
- if not os.path.isfile(target_path):
1592
+ written, note = _written_or_mangled(target_path)
1593
+ if not written:
1450
1594
  raise OriginError("export_failed", "Origin 说导出了但文件不存在:%s" % target_path,
1451
- ["路径必须可写且最好是 ASCII", "先 origin_inspect 确认图页非空"])
1452
- info = _verify_file(target_path)
1595
+ ["路径必须可写;UNC/未映射盘先排除",
1596
+ "先 origin_inspect 确认图页非空"])
1597
+ info = _verify_file(written)
1453
1598
  if not info["ok"]:
1454
1599
  raise OriginError("export_suspect", "导出文件过小或文件头不对:%s" % info,
1455
1600
  ["图页可能是空的:origin_inspect 看 plots 数"])
1601
+ if note:
1602
+ info["warnings"] = [note]
1603
+ rel = _rel_note(path)
1604
+ if rel:
1605
+ info["warnings"] = (info.get("warnings") or []) + [rel]
1456
1606
  return sanitize(dict(info, channel="originpro.save_fig", graph=name))
1457
1607
 
1458
1608
  return call(run)
@@ -1463,8 +1613,8 @@ def _save_project(path=""):
1463
1613
  def run():
1464
1614
  global _project_file
1465
1615
  op = origin()
1466
- if path:
1467
- target = os.path.abspath(path)
1616
+ if str(path).strip():
1617
+ target = _norm_target(path)
1468
1618
  elif _project_file:
1469
1619
  target = _project_file
1470
1620
  else:
@@ -1474,7 +1624,7 @@ def _save_project(path=""):
1474
1624
  time.strftime("origin_bridge_%Y%m%d_%H%M%S.opju"))
1475
1625
  if not target.lower().endswith((".opju", ".opj")):
1476
1626
  target += ".opju"
1477
- os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
1627
+ _prepare_write(target)
1478
1628
 
1479
1629
  # op.save(file) is Origin's Save-As: once the project is bound to that
1480
1630
  # file, saving the same name again returns False without meaning failure.
@@ -1484,10 +1634,12 @@ def _save_project(path=""):
1484
1634
  mtime_before = os.path.getmtime(target) if os.path.isfile(target) else 0
1485
1635
  ok = bool(op.save()) if same_file else bool(op.save(target))
1486
1636
 
1487
- if not os.path.isfile(target):
1637
+ written, _note = _written_or_mangled(target)
1638
+ if not written:
1488
1639
  raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
1489
- ["确认目录可写、路径为 ASCII",
1640
+ ["确认目录可写;中文、空格、超长路径实测都能存,UNC/未映射盘不行",
1490
1641
  "换一个不冲突的文件名,或省略 path 用自动时间戳命名"])
1642
+ target = written
1491
1643
  fresh = os.path.getmtime(target) > mtime_before
1492
1644
  if not ok and not (same_file and not fresh):
1493
1645
  raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
@@ -1525,12 +1677,26 @@ def open_project(path):
1525
1677
  def run():
1526
1678
  global _project_file
1527
1679
  op = origin()
1528
- if not op.open(path):
1680
+ # Opening a project replaces every page, so every handle handed out earlier is
1681
+ # dead — and a *refused* open can still have closed the current project. Both
1682
+ # ways the table has to go: leaving it behind makes later calls act on dead
1683
+ # pages, where originpro's wrapper "succeeds" and Origin quietly writes nothing
1684
+ # (that is how three exports failed in the 1124-case run).
1685
+ try:
1686
+ ok = bool(op.open(path))
1687
+ finally:
1688
+ _handles.clear()
1689
+ _names.clear()
1690
+ if not ok:
1691
+ _project_file = ""
1529
1692
  raise OriginError("open_failed", "Origin 拒绝打开这个工程:%s" % path,
1530
1693
  ["确认文件是 .opju/.opj 且没被另一个 Origin 实例锁住",
1531
- "Origin 版本低于该工程保存版本时也打不开"])
1694
+ "Origin 版本低于该工程保存版本时也打不开",
1695
+ "打开失败后旧句柄已作废,请重新 origin_inspect 看当前页面"])
1532
1696
  _project_file = path
1533
- return sanitize({"opened": path, "open_pages": [p.name for p in op.pages()]})
1697
+ return sanitize({"opened": path, "open_pages": [p.name for p in op.pages()],
1698
+ "handles_reset": True,
1699
+ "note": "之前拿到的 ws-/gr- 句柄全部作废(工程换了页面),请用这里的页面名或重新导入"})
1534
1700
 
1535
1701
  return call(run)
1536
1702
 
@@ -1647,7 +1813,7 @@ def figure(source="", columns=None, x=1, y=1, plot_type="line", title="",
1647
1813
  out["proof_level"] = "verified" if deliver.get("image") else "readback_only"
1648
1814
  out["steps"] = steps
1649
1815
  out["duration_ms"] = int((time.time() - t0) * 1000)
1650
- return out
1816
+ return _lift_warnings(out)
1651
1817
 
1652
1818
 
1653
1819
  # ------------------------------------------------------- matrix / 3D / heat maps
@@ -1711,22 +1877,27 @@ def matrix_plot(rows, template="heat_map", book_name="", graph_name="",
1711
1877
  out["export"] = _export_on_com_thread(gr, export_path)
1712
1878
  if project_path:
1713
1879
  out["project"] = _save_project(project_path)
1714
- return sanitize(out)
1880
+ return sanitize(_lift_warnings(out))
1715
1881
 
1716
1882
  return call(run)
1717
1883
 
1718
1884
 
1719
1885
  def _export_on_com_thread(page, path):
1720
1886
  """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)
1887
+ target = _norm_target(path)
1888
+ if not os.path.splitext(target)[1]:
1889
+ target += ".png"
1890
+ ext = os.path.splitext(target)[1].lower()
1891
+ _prepare_write(target)
1724
1892
  _safe(lambda: page.save_fig(target))
1725
- if not os.path.isfile(target):
1893
+ written, note = _written_or_mangled(target)
1894
+ if not written:
1726
1895
  raise OriginError("export_failed", "矩阵图导出没落盘:%s" % target)
1727
- info = _verify_file(target, must_be_image=ext in IMAGE_MAGIC)
1896
+ info = _verify_file(written, must_be_image=ext in IMAGE_MAGIC)
1728
1897
  if not info["ok"]:
1729
1898
  raise OriginError("export_suspect", "导出文件异常:%s" % info)
1899
+ if note:
1900
+ info["warnings"] = [note]
1730
1901
  return info
1731
1902
 
1732
1903
 
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.7",
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,9 @@
53
50
  "fixtures/*.txt",
54
51
  "smoke_test.py",
55
52
  "edge_checks.py",
53
+ "test_paths.py",
54
+ "test_json_safety.py",
55
+ "test_readfile.py",
56
56
  "test_asciiio.py",
57
57
  "test_binio.py",
58
58
  "test_lifecycle.py",
@@ -78,5 +78,13 @@
78
78
  "opju",
79
79
  "scientific-plotting",
80
80
  "electrochemistry"
81
- ]
81
+ ],
82
+ "peerDependencies": {
83
+ "@deepseek-ai/dsh-mcp-client": "0.2.0-rc.2 || >=0.1.0-rc.1 <1"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@deepseek-ai/dsh-mcp-client": {
87
+ "optional": true
88
+ }
89
+ }
82
90
  }
package/requirements.txt CHANGED
@@ -1,2 +1,4 @@
1
+ # 实测环境:originpro 1.1.15 + OriginPro 2024 SR1 (10.100178) + Python 3.12
2
+ # originpro 要求 Origin 2021+;2.x 没有验证过,暂不设上限。
1
3
  originpro>=1.1.0
2
4
  numpy>=1.24
package/server.py CHANGED
@@ -20,7 +20,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
20
  import originlab # noqa: E402
21
21
 
22
22
  SERVER_NAME = "origin-bridge"
23
- SERVER_VERSION = "0.4.5"
23
+ SERVER_VERSION = "0.4.7"
24
24
  PROTOCOL = "2024-11-05"
25
25
 
26
26
 
@@ -106,7 +106,7 @@ TOOLS = [
106
106
  },
107
107
  {
108
108
  "name": "origin_read_file",
109
- "description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
109
+ "description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。编码覆盖 UTF-8 / GBK / UTF-16(有无 BOM)/ cp1252;<0.001、2.5±0.1、25 mV、1,234.5、(500)、2.5* 这类被装饰的数值会解出来并在 warnings 里说明代入了什么。每列还带 sample(前 3 个原始值,缺值为 null),用来抽查解析对不对——完整数据仍然只进 Origin。二进制表格 .xls/.xlsx/.wks 不在它的范围内,会明确返回 unsupported_file_type,请用 origin_import。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
110
110
  "inputSchema": {
111
111
  "type": "object",
112
112
  "properties": {
@@ -192,13 +192,14 @@ TOOLS = [
192
192
  },
193
193
  {
194
194
  "name": "origin_export",
195
- "description": "把图页导出为图片文件,并校验文件确实存在、够大、文件头正确(Origin 有静默失败的历史)。fmt: png/tif/jpg/svg/pdf/emf(tiff→tif、jpeg→jpg 会自动改写扩展名)。",
195
+ "description": "把图页导出为图片文件,并校验文件确实存在、够大、文件头正确(Origin 有静默失败的历史)。fmt: png/tif/jpg/svg/pdf/emf(tiff→tif、jpeg→jpg 会自动改写扩展名)。path 给绝对路径:中文、空格、长路径实测可用,未映射盘/UNC/把目录当文件名返回 bad_path,已存在但只读或被占用返回 target_locked(不覆盖)。文件名里的 # 会被 Origin 换成编号,桥认出后改名回你要的名字并在 warnings 里说明——那是成功,不是失败。",
196
196
  "inputSchema": {
197
197
  "type": "object",
198
198
  "properties": {
199
199
  "graph": {"type": "string"},
200
200
  "path": {"type": "string", "description": "输出文件绝对路径;省略则用图页名放到当前目录"},
201
- "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
201
+ "fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
202
+ "default": "png"},
202
203
  "width": {"type": "integer", "default": 1600},
203
204
  },
204
205
  },
@@ -207,7 +208,7 @@ TOOLS = [
207
208
  },
208
209
  {
209
210
  "name": "origin_save_project",
210
- "description": "保存为可在 Origin 里继续编辑的 .opju 工程文件(不是图片快照)。省略 path 会自动命名。",
211
+ "description": "保存为可在 Origin 里继续编辑的 .opju 工程文件(不是图片快照)。省略 path 会自动命名。path 给绝对路径,中文/空格/长路径实测可用;未映射盘、UNC、把目录当文件名一律 bad_path,不会假装存好了。",
211
212
  "inputSchema": {
212
213
  "type": "object",
213
214
  "properties": {"path": {"type": "string"}},
@@ -216,7 +217,7 @@ TOOLS = [
216
217
  },
217
218
  {
218
219
  "name": "origin_open",
219
- "description": "打开已有的 .opju/.opj 工程,返回页面清单。",
220
+ "description": "打开已有的 .opju/.opj 工程,返回页面清单。打开会替换工程里的所有页面,所以之前拿到的 ws-/gr- 句柄全部作废(返回 handles_reset: true)——要用就用返回的页面名,或重新 origin_import/origin_plot 拿新句柄;打开失败时旧句柄同样作废。",
220
221
  "inputSchema": {
221
222
  "type": "object",
222
223
  "properties": {"path": {"type": "string"}},
@@ -235,7 +236,7 @@ TOOLS = [
235
236
  },
236
237
  {
237
238
  "name": "origin_figure",
238
- "description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings,说明图虽然画成了但可能不可读(例如线性横轴跨了几个数量级,点全挤在最左边):把 next_actions 里的方案讲给用户让他选,不要自己擅自改轴。",
239
+ "description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings,可能是两类:图不可读(例如线性横轴跨了几个数量级,点全挤在最左边)——把 next_actions 里的方案讲给用户让他选,不要自己擅自改轴;或是路径被代劳了(文件名里的 # 被 Origin 换成编号后改回、相对路径落到进程工作目录)——转达给用户即可,交付本身是成功的。",
239
240
  "inputSchema": {
240
241
  "type": "object",
241
242
  "properties": {
@@ -247,7 +248,8 @@ TOOLS = [
247
248
  "title": {"type": "string"},
248
249
  "x_title": {"type": "string"},
249
250
  "y_title": {"type": "string"},
250
- "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
251
+ "fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
252
+ "default": "png"},
251
253
  "width": {"type": "integer", "default": 1600},
252
254
  "output_dir": {"type": "string", "description": "交付目录:图片 + opju 都写到这。不给则只建图、不落盘"},
253
255
  "export_path": {"type": "string"},
@@ -417,7 +419,8 @@ TOOLS = [
417
419
  "title": {"type": "string"},
418
420
  "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"]},
419
421
  "with_legend": {"type": "boolean", "default": True},
420
- "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
422
+ "fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
423
+ "default": "png"},
421
424
  "width": {"type": "integer", "default": 1600},
422
425
  },
423
426
  "required": ["name"],
@@ -604,7 +607,20 @@ def main():
604
607
  "error": {"code": -32603, "message": "internal error: %s" % exc}}
605
608
  if reply is None:
606
609
  continue
607
- stdout.write((json.dumps(reply, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
610
+ # allow_nan=False is the last line of defence. A bare Infinity/NaN in a
611
+ # reply is not valid JSON, so a strict client (dsh) fails to parse the
612
+ # *entire* result rather than one field. If anything still slips through,
613
+ # answer with a clean JSON-RPC error instead of either killing the session
614
+ # or writing bytes no client can read.
615
+ try:
616
+ line = json.dumps(reply, ensure_ascii=False, default=str, allow_nan=False)
617
+ except ValueError as exc:
618
+ _log("reply was not strict JSON (%r) for id=%r" % (exc, reply.get("id")))
619
+ reply = {"jsonrpc": "2.0", "id": reply.get("id"),
620
+ "error": {"code": -32603,
621
+ "message": "internal error: 返回值含非 JSON 数值(%s)" % exc}}
622
+ line = json.dumps(reply, ensure_ascii=False, default=str)
623
+ stdout.write((line + "\n").encode("utf-8"))
608
624
  stdout.flush()
609
625
 
610
626
 
package/smoke_test.py CHANGED
@@ -27,6 +27,21 @@ PY = sys.executable
27
27
  STARTED_AT = time.strftime("%Y-%m-%dT%H:%M:%S")
28
28
 
29
29
 
30
+ class NonStrictJSON(ValueError):
31
+ """The server wrote bare Infinity/NaN/-Infinity.
32
+
33
+ Python's json accepts those, RFC 8259 does not: dsh's client would fail on the
34
+ whole response and the tool would look dead rather than slightly wrong. A test
35
+ harness that tolerates it hides a real protocol defect, so we reject it here.
36
+ """
37
+
38
+
39
+ def strict_loads(text):
40
+ def _reject(const):
41
+ raise NonStrictJSON("非标准 JSON 常量: %s" % const)
42
+ return json.loads(text, parse_constant=_reject)
43
+
44
+
30
45
  class Rpc:
31
46
  def __init__(self, python, script):
32
47
  self.p = subprocess.Popen([python, "-u", script], cwd=HERE,
@@ -64,7 +79,11 @@ class Rpc:
64
79
  with self.lock:
65
80
  for raw in self.lines:
66
81
  try:
67
- msg = json.loads(raw)
82
+ msg = strict_loads(raw)
83
+ except NonStrictJSON:
84
+ # A reply carrying bare Infinity/NaN parses in Python but not
85
+ # in a strict client, so dsh would report the tool as dead.
86
+ raise SystemExit("服务器写出了非法 JSON:%s" % raw[:160])
68
87
  except ValueError:
69
88
  continue
70
89
  if msg.get("id") == mine:
@@ -82,7 +101,10 @@ class Rpc:
82
101
  blocks = res.get("content", [])
83
102
  text = "".join(c.get("text", "") for c in blocks if c.get("type") == "text")
84
103
  try:
85
- payload = json.loads(text)
104
+ payload = strict_loads(text)
105
+ except NonStrictJSON as exc:
106
+ payload = {"ok": False, "code": "bad_payload",
107
+ "message": "工具返回体不是合法 JSON(%s):%s" % (exc, text[:300])}
86
108
  except ValueError:
87
109
  payload = {"ok": False, "code": "bad_payload", "message": text[:400]}
88
110
  payload["_isError"] = res.get("isError", False)