fix(origin): 修正图例避让与矢量导出基线

This commit is contained in:
caoqianming 2026-08-17 13:43:00 +08:00
parent 548d7a2912
commit 1cca765796
6 changed files with 184 additions and 37 deletions

View File

@ -12,7 +12,7 @@
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。 - 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。 - 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,旧任务中的固定角落图例也会自动避让数据;同时消除 SVG 和 PDF 文本对象的异常横线。
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y` - 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`

View File

@ -24,7 +24,7 @@
### 2026-08-17 ### 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 检查通过,未连接或写入生产数据库。 - **08-17 / Unreleased / Origin 对数坐标自动缩放修复**adapter 提升至 0.9.1,单图、多面板及右 Y 轴统一在 Origin 自动计算范围前应用坐标轴尺度,避免线性范围中的零值切换为对数轴后被强制展开到 `1E-10`;显式范围仍在自动缩放后覆盖,保持请求权威。多面板缺少 `y_axis` 时改用面板标题或数据列名回退,并在契约中提示对数轴提供正数范围及纵轴标题。相关 88 项 unittest、Python 编译、契约 JSON 和 diff 检查通过,未连接或写入生产数据库。

View File

@ -199,7 +199,7 @@
"enabled": {"type": "boolean"}, "enabled": {"type": "boolean"},
"position": { "position": {
"enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"], "enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"],
"description": "图例位置;auto 由 Origin 根据当前数据和图层空间智能定位。" "description": "图例位置;单图支持固定四角,多面板由 Origin 自动避开数据(包括兼容旧请求中的四角值)。"
}, },
"font_size": {"type": "number", "minimum": 6, "maximum": 72} "font_size": {"type": "number", "minimum": 6, "maximum": 72}
} }
@ -525,7 +525,7 @@
"enabled": {"type": "boolean"}, "enabled": {"type": "boolean"},
"position": { "position": {
"enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"], "enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"],
"description": "图例位置;auto 由 Origin 根据当前面板数据智能定位。" "description": "图例位置;单图支持固定四角,多面板由 Origin 自动避开数据(包括兼容旧请求中的四角值)。"
}, },
"font_size": {"type": "number", "minimum": 6, "maximum": 72} "font_size": {"type": "number", "minimum": 6, "maximum": 72}
} }

View File

@ -4,6 +4,7 @@ import importlib.util
import json import json
import tempfile import tempfile
import unittest import unittest
import zlib
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@ -921,10 +922,11 @@ class OriginWorkerUnitTests(unittest.TestCase):
"Strength", "Strength",
{"font_size": 12}, {"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["background"], 0)
self.assertEqual(layer.legend.values["left"], 655) self.assertEqual(layer.legend.values["smartpos"], 1)
self.assertEqual(layer.legend.values["top"], 307) self.assertNotIn("left", layer.legend.values)
self.assertNotIn("top", layer.legend.values)
self.assertEqual(layer.title.values["attach"], 1) self.assertEqual(layer.title.values["attach"], 1)
self.assertEqual(layer.title.values["background"], 0) self.assertEqual(layer.title.values["background"], 0)
self.assertEqual(layer.title.values["fsize"], 12) self.assertEqual(layer.title.values["fsize"], 12)
@ -943,6 +945,42 @@ class OriginWorkerUnitTests(unittest.TestCase):
worker._configure_origin_session(origin) worker._configure_origin_session(origin)
self.assertEqual(origin.variables, [("@U", 1)]) self.assertEqual(origin.variables, [("@U", 1)])
def test_svg_normalizer_removes_only_text_baseline_path(self) -> None:
source = (
b'<svg viewBox="0,0 100,100" width="100" height="100">'
b'<text x="10" y="30" font-size="20">Title</text>\n'
b'<path d="M 10,14.4 L 70,14.4" '
b'style="stroke: black;stroke-width: 2;stroke-linecap:butt" '
b'fill="none"/>'
b'<path d="M 0,90 L 100,90" '
b'style="stroke: black;stroke-width: 2" fill="none"/>'
b'</svg>'
)
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: def test_origin_arranges_panel_layers_and_reports_resulting_geometry(self) -> None:
class Layer: class Layer:
def __init__(self, geometry): def __init__(self, geometry):

View File

@ -1,6 +1,6 @@
{ {
"capability": "origin.plot@v2", "capability": "origin.plot@v2",
"adapter_version": "0.9.2", "adapter_version": "0.9.3",
"runtime": "python", "runtime": "python",
"runtime_id": "origin", "runtime_id": "origin",
"entrypoint": "worker.py", "entrypoint": "worker.py",

View File

@ -14,6 +14,7 @@ import math
import os import os
import re import re
import sys import sys
import zlib
from datetime import datetime, timezone from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from itertools import pairwise 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, 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}, (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: 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: def _configure_origin_session(op: Any) -> None:
"""Normalize export behavior that otherwise depends on machine defaults.""" """Normalize export behavior that otherwise depends on machine defaults."""
# Origin's default @U=0 prints a baseline under axis/data labels. It is # Keep labels flush with their frames. Origin 2024 still emits a separate
# especially conspicuous with CJK fonts and can look like an underline. # baseline drawing primitive; exported files are normalized below.
op.set_lt_var("@U", 1) op.set_lt_var("@U", 1)
_SVG_TEXT_BASELINE_RE = re.compile(
rb'(?P<text><text\b(?P<attrs>[^>]*)>.*?</text>\s*)'
rb'(?P<path><path\s+d="M\s*(?P<x1>-?[\d.]+),(?P<y1>-?[\d.]+)\s+'
rb'L\s*(?P<x2>-?[\d.]+),(?P<y2>-?[\d.]+)"\s+'
rb'style="stroke:\s*black;stroke-width:\s*(?P<width>[\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<width>[\d.]+) w\s*\n0 J\s*\n'
rb'(?P<x1>-?[\d.]+) (?P<y1>-?[\d.]+) m\s*\n'
rb'(?P<x2>-?[\d.]+) (?P<y2>-?[\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( def _arrange_panel_layers(
layers: list[Any], grid: tuple[int, int] layers: list[Any], grid: tuple[int, int]
) -> list[tuple[float, float, float, float]]: ) -> list[tuple[float, float, float, float]]:
@ -549,18 +656,11 @@ def _apply_legend(
label.set_int("smartpos", 1) label.set_int("smartpos", 1)
elif "position" in legend: elif "position" in legend:
if attach_to_layer: if attach_to_layer:
x_fraction, y_fraction = { # Fixed page coordinates disable Origin's collision avoidance.
"top_left": (0.03, 0.04), # Delegate multi-panel placement even for historical requests
"top_right": (0.66, 0.04), # that specified a corner before automatic layout existed.
"bottom_left": (0.03, 0.68), label.set_int("attach", 0)
"bottom_right": (0.66, 0.68), label.set_int("smartpos", 1)
}[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))
else: else:
left, top = LEGEND_POSITIONS[legend["position"]] left, top = LEGEND_POSITIONS[legend["position"]]
if vertical_offset and legend["position"].startswith("bottom"): 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) canvas_width_mm = (plot_spec.get("canvas") or {}).get("width_mm", 160)
pixel_width = round(dpi * canvas_width_mm / 25.4) pixel_width = round(dpi * canvas_width_mm / 25.4)
for extension in ("png", "svg", "pdf"): for extension in ("png", "svg", "pdf"):
if extension in formats: if extension not in formats:
target = output / f"figure.{extension}" continue
exported = Path( target = output / f"figure.{extension}"
graph.save_fig( _configure_origin_session(op)
str(target), exported = Path(
type=extension, graph.save_fig(
width=pixel_width if extension == "png" else 0, str(target),
ratio=100 if extension in {"svg", "pdf"} else 0, type=extension,
) width=pixel_width if extension == "png" else 0,
).resolve() ratio=0 if extension == "png" else 100,
if exported != target.resolve() or not target.is_file(): )
raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED") ).resolve()
_validate_artifact(target, extension) if exported != target.resolve() or not target.is_file():
artifacts.append(_manifest(target, media[extension])) 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: try:
originpro_version = version("originpro") originpro_version = version("originpro")
except PackageNotFoundError: except PackageNotFoundError: