From 1cca765796d35da6dc2a72db65cae1479e454ca0 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 17 Aug 2026 13:43:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(origin):=20=E4=BF=AE=E6=AD=A3=E5=9B=BE?= =?UTF-8?q?=E4=BE=8B=E9=81=BF=E8=AE=A9=E4=B8=8E=E7=9F=A2=E9=87=8F=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- PROGRESS.md | 2 +- software-contracts/origin.plot.v2.json | 4 +- tests/test_origin_worker.py | 44 ++++- .../adapters/origin.plot@v2/adapter.json | 2 +- .../adapters/origin.plot@v2/worker.py | 167 +++++++++++++++--- 6 files changed, 184 insertions(+), 37 deletions(-) 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'' + b'Title\n' + b'' + b'' + 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: