diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbd342d..744def6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,7 @@
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。
-- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。
+- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,旧任务中的固定角落图例也会自动避让数据;同时消除 SVG 和 PDF 文本对象的异常横线。
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。
diff --git a/PROGRESS.md b/PROGRESS.md
index ea90296..5c3826f 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -24,7 +24,7 @@
### 2026-08-17
-- **08-17 / Unreleased / Origin 多面板自动布局与导出基线修复**:adapter 提升至 0.9.2,多面板图改用 Origin `layarrange` 根据行列、页边距和间距统一排列图层,并读取 Origin 计算后的实际图层位置放置标题与图例;共享 X 轴时仅底行保留标题和刻度标签,图例新增 `auto` 位置以调用 Origin 智能避让。每次任务显式设置 `@U=1`,消除 Origin 默认打印基线在中文标题、轴标题和图例上形成的异常横线,且不修改用户机器的持久配置。
+- **08-17 / Unreleased / Origin 多面板自动布局与导出基线修复**:adapter 0.9.2 完成 `layarrange` 图层排列与共享 X 轴收敛,但真机复核发现旧请求仍固定右上图例,且 `@U=1` 不能消除 Origin 2024 导出的文本基线。adapter 0.9.3 因此在多面板中将 `auto` 及历史四角图例统一交给 Origin 智能避让,并从 SVG、PDF 中精确清理 Origin 额外生成的独立基线图元;2×2、四系列、误差棒、中文标题的真机回归已确认两种矢量格式横线消失、字形完整。PNG 的 Origin 原生导出与剪贴板渲染均仍会绘制该基线,暂不采用会破坏汉字笔画的像素擦除方案。
- **08-17 / Unreleased / Origin 对数坐标自动缩放修复**:adapter 提升至 0.9.1,单图、多面板及右 Y 轴统一在 Origin 自动计算范围前应用坐标轴尺度,避免线性范围中的零值切换为对数轴后被强制展开到 `1E-10`;显式范围仍在自动缩放后覆盖,保持请求权威。多面板缺少 `y_axis` 时改用面板标题或数据列名回退,并在契约中提示对数轴提供正数范围及纵轴标题。相关 88 项 unittest、Python 编译、契约 JSON 和 diff 检查通过,未连接或写入生产数据库。
diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json
index 40b8d78..acc2722 100644
--- a/software-contracts/origin.plot.v2.json
+++ b/software-contracts/origin.plot.v2.json
@@ -199,7 +199,7 @@
"enabled": {"type": "boolean"},
"position": {
"enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"],
- "description": "图例位置;auto 由 Origin 根据当前数据和图层空间智能定位。"
+ "description": "图例位置;单图支持固定四角,多面板由 Origin 自动避开数据(包括兼容旧请求中的四角值)。"
},
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
@@ -525,7 +525,7 @@
"enabled": {"type": "boolean"},
"position": {
"enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"],
- "description": "图例位置;auto 由 Origin 根据当前面板数据智能定位。"
+ "description": "图例位置;单图支持固定四角,多面板由 Origin 自动避开数据(包括兼容旧请求中的四角值)。"
},
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py
index 4b22854..c7dbebc 100644
--- a/tests/test_origin_worker.py
+++ b/tests/test_origin_worker.py
@@ -4,6 +4,7 @@ import importlib.util
import json
import tempfile
import unittest
+import zlib
from pathlib import Path
from unittest.mock import patch
@@ -921,10 +922,11 @@ class OriginWorkerUnitTests(unittest.TestCase):
"Strength",
{"font_size": 12},
)
- self.assertEqual(layer.legend.values["attach"], 1)
+ self.assertEqual(layer.legend.values["attach"], 0)
self.assertEqual(layer.legend.values["background"], 0)
- self.assertEqual(layer.legend.values["left"], 655)
- self.assertEqual(layer.legend.values["top"], 307)
+ self.assertEqual(layer.legend.values["smartpos"], 1)
+ self.assertNotIn("left", layer.legend.values)
+ self.assertNotIn("top", layer.legend.values)
self.assertEqual(layer.title.values["attach"], 1)
self.assertEqual(layer.title.values["background"], 0)
self.assertEqual(layer.title.values["fsize"], 12)
@@ -943,6 +945,42 @@ class OriginWorkerUnitTests(unittest.TestCase):
worker._configure_origin_session(origin)
self.assertEqual(origin.variables, [("@U", 1)])
+ def test_svg_normalizer_removes_only_text_baseline_path(self) -> None:
+ source = (
+ b''
+ )
+
+ normalized, baselines = worker._strip_origin_svg_text_baselines(source)
+
+ self.assertEqual(len(baselines), 1)
+ self.assertNotIn(b'M 10,14.4 L 70,14.4', normalized)
+ self.assertIn(b'M 0,90 L 100,90', normalized)
+
+ def test_pdf_normalizer_preserves_stream_size_and_removes_baseline(self) -> None:
+ decoded = b"4 w\n0 J\n10 20 m\n90 20 l\nS\nQ\n"
+ compressed = zlib.compress(decoded)
+ original = b"%PDF-1.2\nstream\n" + compressed + b"\nendstream\n%%EOF"
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / "figure.pdf"
+ path.write_bytes(original)
+
+ removed = worker._strip_origin_pdf_text_baselines(path)
+ content = path.read_bytes()
+ start = content.index(b"stream\n") + len(b"stream\n")
+ end = content.index(b"\nendstream", start)
+ normalized = zlib.decompress(content[start:end])
+
+ self.assertEqual(removed, 1)
+ self.assertNotIn(b"10 20 m", normalized)
+ self.assertEqual(len(content), len(original))
+
def test_origin_arranges_panel_layers_and_reports_resulting_geometry(self) -> None:
class Layer:
def __init__(self, geometry):
diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json
index bc0221b..3c48f10 100644
--- a/windows-node/adapters/origin.plot@v2/adapter.json
+++ b/windows-node/adapters/origin.plot@v2/adapter.json
@@ -1,6 +1,6 @@
{
"capability": "origin.plot@v2",
- "adapter_version": "0.9.2",
+ "adapter_version": "0.9.3",
"runtime": "python",
"runtime_id": "origin",
"entrypoint": "worker.py",
diff --git a/windows-node/adapters/origin.plot@v2/worker.py b/windows-node/adapters/origin.plot@v2/worker.py
index e22b73f..2d723dc 100644
--- a/windows-node/adapters/origin.plot@v2/worker.py
+++ b/windows-node/adapters/origin.plot@v2/worker.py
@@ -14,6 +14,7 @@ import math
import os
import re
import sys
+import zlib
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from itertools import pairwise
@@ -74,7 +75,7 @@ PANEL_LAYOUT = {
(2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10},
(2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10},
}
-ADAPTER_VERSION = "0.9.2"
+ADAPTER_VERSION = "0.9.3"
def _server_executable(command: str) -> Path:
@@ -422,11 +423,117 @@ def _apply_canvas(graph: Any, canvas: Any) -> None:
def _configure_origin_session(op: Any) -> None:
"""Normalize export behavior that otherwise depends on machine defaults."""
- # Origin's default @U=0 prints a baseline under axis/data labels. It is
- # especially conspicuous with CJK fonts and can look like an underline.
+ # Keep labels flush with their frames. Origin 2024 still emits a separate
+ # baseline drawing primitive; exported files are normalized below.
op.set_lt_var("@U", 1)
+_SVG_TEXT_BASELINE_RE = re.compile(
+ rb'(?P[^>]*)>.*?\s*)'
+ rb'(?P-?[\d.]+),(?P-?[\d.]+)\s+'
+ rb'L\s*(?P-?[\d.]+),(?P-?[\d.]+)"\s+'
+ rb'style="stroke:\s*black;stroke-width:\s*(?P[\d.]+);[^\"]*"'
+ rb'\s+fill="none"[^>]*/>)',
+ re.DOTALL,
+)
+
+
+def _strip_origin_svg_text_baselines(
+ content: bytes,
+) -> tuple[bytes, list[tuple[float, float, float, float, float]]]:
+ """Remove Origin 2024's separate printing-baseline paths after text nodes."""
+ baselines: list[tuple[float, float, float, float, float]] = []
+
+ def replace(match: re.Match[bytes]) -> bytes:
+ attrs = match.group("attrs")
+ x1, y1, x2, y2, width = (
+ float(match.group(name)) for name in ("x1", "y1", "x2", "y2", "width")
+ )
+ font_match = re.search(rb'font-size="([\d.]+)"', attrs)
+ if font_match is None:
+ return match.group(0)
+ font_size = float(font_match.group(1))
+ transform = re.search(
+ rb'transform="matrix\([^\"]+\s(-?[\d.]+)\s(-?[\d.]+)\)"', attrs
+ )
+ text_x = re.search(rb'\bx="(-?[\d.]+)"', attrs)
+ text_y = re.search(rb'\by="(-?[\d.]+)"', attrs)
+ horizontal = abs(y1 - y2) < 0.01
+ vertical = abs(x1 - x2) < 0.01
+ matches_text_frame = False
+ if horizontal and text_x and text_y:
+ expected_x = float(text_x.group(1))
+ expected_y = float(text_y.group(1)) - 0.78 * font_size
+ matches_text_frame = (
+ abs(x1 - expected_x) <= max(2, font_size * 0.08)
+ and abs(y1 - expected_y) <= max(3, font_size * 0.12)
+ )
+ elif vertical and transform:
+ translate_x, translate_y = map(float, transform.groups())
+ expected_x = translate_x - 0.78 * font_size
+ matches_text_frame = (
+ abs(x1 - expected_x) <= max(3, font_size * 0.12)
+ and abs(y1 - translate_y) <= max(3, font_size * 0.08)
+ )
+ if not matches_text_frame or max(abs(x2 - x1), abs(y2 - y1)) < 1.5 * font_size:
+ return match.group(0)
+ if not 0.04 <= width / font_size <= 0.14:
+ return match.group(0)
+ baselines.append((x1, y1, x2, y2, width))
+ return match.group("text")
+
+ return _SVG_TEXT_BASELINE_RE.sub(replace, content), baselines
+
+
+_PDF_BASELINE_RE = re.compile(
+ rb'(?m)(?P[\d.]+) w\s*\n0 J\s*\n'
+ rb'(?P-?[\d.]+) (?P-?[\d.]+) m\s*\n'
+ rb'(?P-?[\d.]+) (?P-?[\d.]+) l\s*\nS\s*\nQ'
+)
+
+
+def _strip_origin_pdf_text_baselines(path: Path) -> int:
+ """Remove isolated long black text-baseline strokes without rewriting xrefs."""
+ content = bytearray(path.read_bytes())
+ removed = 0
+ for stream_match in reversed(list(re.finditer(rb'stream\r?\n', content))):
+ start = stream_match.end()
+ end = content.find(b"endstream", start)
+ if end < 0:
+ continue
+ compressed = bytes(content[start:end]).rstrip(b"\r\n")
+ try:
+ decoded = zlib.decompress(compressed)
+ except zlib.error:
+ continue
+
+ def replace(match: re.Match[bytes]) -> bytes:
+ nonlocal removed
+ width, x1, y1, x2, y2 = (
+ float(match.group(name))
+ for name in ("width", "x1", "y1", "x2", "y2")
+ )
+ length = max(abs(x2 - x1), abs(y2 - y1))
+ if min(abs(x2 - x1), abs(y2 - y1)) > 0.02 or length < 10 * width:
+ return match.group(0)
+ removed += 1
+ replacement = b"Q"
+ return replacement + b" " * (len(match.group(0)) - len(replacement))
+
+ normalized = _PDF_BASELINE_RE.sub(replace, decoded)
+ if normalized == decoded:
+ continue
+ recompressed = zlib.compress(normalized, 9)
+ if len(recompressed) > len(compressed):
+ raise RuntimeError("PDF_BASELINE_NORMALIZATION_FAILED")
+ content[start : start + len(compressed)] = recompressed + b"\n" * (
+ len(compressed) - len(recompressed)
+ )
+ if removed:
+ path.write_bytes(content)
+ return removed
+
+
def _arrange_panel_layers(
layers: list[Any], grid: tuple[int, int]
) -> list[tuple[float, float, float, float]]:
@@ -549,18 +656,11 @@ def _apply_legend(
label.set_int("smartpos", 1)
elif "position" in legend:
if attach_to_layer:
- x_fraction, y_fraction = {
- "top_left": (0.03, 0.04),
- "top_right": (0.66, 0.04),
- "bottom_left": (0.03, 0.68),
- "bottom_right": (0.66, 0.68),
- }[legend["position"]]
- if vertical_offset:
- y_fraction += 0.22 if legend["position"].startswith("top") else -0.22
- left, top = _panel_page_pixel(graph, geometry, x_fraction, y_fraction)
- label.set_int("attach", 1)
- label.set_int("left", round(left))
- label.set_int("top", round(top))
+ # Fixed page coordinates disable Origin's collision avoidance.
+ # Delegate multi-panel placement even for historical requests
+ # that specified a corner before automatic layout existed.
+ label.set_int("attach", 0)
+ label.set_int("smartpos", 1)
else:
left, top = LEGEND_POSITIONS[legend["position"]]
if vertical_offset and legend["position"].startswith("bottom"):
@@ -1198,20 +1298,29 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
canvas_width_mm = (plot_spec.get("canvas") or {}).get("width_mm", 160)
pixel_width = round(dpi * canvas_width_mm / 25.4)
for extension in ("png", "svg", "pdf"):
- if extension in formats:
- target = output / f"figure.{extension}"
- exported = Path(
- graph.save_fig(
- str(target),
- type=extension,
- width=pixel_width if extension == "png" else 0,
- ratio=100 if extension in {"svg", "pdf"} else 0,
- )
- ).resolve()
- if exported != target.resolve() or not target.is_file():
- raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED")
- _validate_artifact(target, extension)
- artifacts.append(_manifest(target, media[extension]))
+ if extension not in formats:
+ continue
+ target = output / f"figure.{extension}"
+ _configure_origin_session(op)
+ exported = Path(
+ graph.save_fig(
+ str(target),
+ type=extension,
+ width=pixel_width if extension == "png" else 0,
+ ratio=0 if extension == "png" else 100,
+ )
+ ).resolve()
+ if exported != target.resolve() or not target.is_file():
+ raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED")
+ if extension == "svg":
+ normalized_svg, _ = _strip_origin_svg_text_baselines(
+ target.read_bytes()
+ )
+ target.write_bytes(normalized_svg)
+ elif extension == "pdf":
+ _strip_origin_pdf_text_baselines(target)
+ _validate_artifact(target, extension)
+ artifacts.append(_manifest(target, media[extension]))
try:
originpro_version = version("originpro")
except PackageNotFoundError: