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`

View File

@ -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 检查通过,未连接或写入生产数据库。

View File

@ -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}
}

View File

@ -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'<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:
class Layer:
def __init__(self, geometry):

View File

@ -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",

View File

@ -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<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(
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,18 +1298,27 @@ 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:
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=100 if extension in {"svg", "pdf"} 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: