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