fix(software): 修复 Origin 导出与重复完成报告
This commit is contained in:
parent
1cca765796
commit
4ec4496c3d
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 专业软件任务提交后由系统统一发送完成通知和产物,Agent 不再自行等待并重复报告;Origin PNG 改由清理异常文字基线后的矢量页渲染,三维图在指定毫米画布时也会同步缩放模板图层,避免横线及坐标标题、色标和文字裁切。
|
||||
|
||||
- 专业软件任务完成后可按用户意图仅报告产物,或自动读取图表和数据并给出分析;Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。
|
||||
|
||||
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-17(专业软件 Job 完成自动报告/分析与结果中心优化完成,未发版)
|
||||
最后更新:2026-08-17(专业软件 Job 单一完成通知与 Origin 3D 画布修复,未发版)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,6 +20,8 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **08-17 / Unreleased / Software Job 单一完成通知与 Origin PNG/3D 修复**:提交/修订工具把成功排队定义为当前 Agent run 的终点,并在返回值中声明后续由系统自动发布,避免 Agent `sleep`、查询、发布后再收到固定完成消息;Origin adapter 0.9.4 不再使用会绘制文字基线的原生 PNG 路径,改为清理 PDF 基线后按请求 DPI 栅格化,同时在添加图形对象前应用最终画布并把模板图层换算为页相对尺寸,修复 3D 曲面图缩小画布后的越界裁切。专项测试、Python 编译与 diff 检查通过,生产数据库仅做只读任务核查。
|
||||
|
||||
- **08-17 / Unreleased / Software Job 完成闭环与结果中心优化**:`software_job_submit` 新增默认 `report`、可选 `analyze` 的完成策略,0034 在原 Job 账本加入可恢复 follow-up 状态;成功任务可直接向原对话报告产物,或在 task 空闲后复用单活锁和 SSE 自动续跑分析,内部完成事件不冒充用户消息,手动深入/重新分析走同一幂等入口。Job 中心改为结果优先布局,显示短 Job ID、输入输出、完成方式和耗时,折叠展示完整 ID/输出目录/执行版本,并支持复制、查看结果及状态化分析按钮。相关专项 unittest、Python/JavaScript 语法、Ruff 致命规则和 diff 检查通过;未连接生产 DB、未执行 migration。
|
||||
|
||||
### 2026-08-17
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zlib
|
||||
|
|
@ -109,6 +110,114 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(worker._axis_title(None, "Time"), "Time")
|
||||
|
||||
def test_new_graph_converts_template_layers_before_applying_canvas(self) -> None:
|
||||
events = []
|
||||
|
||||
class Layer:
|
||||
def set_int(self, name, value):
|
||||
events.append(("layer", name, value))
|
||||
|
||||
class Graph:
|
||||
class Obj:
|
||||
@staticmethod
|
||||
def LT_execute(command):
|
||||
events.append(("page", "lt", command))
|
||||
|
||||
obj = Obj()
|
||||
|
||||
def __iter__(self):
|
||||
return iter([Layer()])
|
||||
|
||||
@staticmethod
|
||||
def activate():
|
||||
events.append(("page", "activate"))
|
||||
|
||||
class Origin:
|
||||
@staticmethod
|
||||
def new_graph(*, template):
|
||||
events.append(("new", template))
|
||||
return Graph()
|
||||
|
||||
graph = worker._new_graph(
|
||||
Origin(), "3D", {"width_mm": 140, "height_mm": 100}
|
||||
)
|
||||
|
||||
self.assertIsInstance(graph, Graph)
|
||||
self.assertEqual(events[0], ("new", "3D"))
|
||||
self.assertEqual(events[1], ("layer", "unit", 1))
|
||||
self.assertEqual(events[2], ("page", "activate"))
|
||||
self.assertEqual(events[3][0:2], ("page", "lt"))
|
||||
self.assertIn(f"page.width={140 / 25.4 * 600}", events[3][2])
|
||||
self.assertIn(f"page.height={100 / 25.4 * 600}", events[3][2])
|
||||
|
||||
def test_surface_3d_presentation_hides_color_scale_with_legend(self) -> None:
|
||||
created = []
|
||||
|
||||
class Label:
|
||||
def __init__(self, text=""):
|
||||
self.text = text
|
||||
self.name = ""
|
||||
self.show = True
|
||||
self.values = {}
|
||||
|
||||
def set_int(self, name, value):
|
||||
self.values[name] = value
|
||||
|
||||
class Obj:
|
||||
commands = []
|
||||
|
||||
@classmethod
|
||||
def LT_execute(cls, command):
|
||||
cls.commands.append(command)
|
||||
|
||||
class Layer:
|
||||
obj = Obj()
|
||||
values = {}
|
||||
spectrum = Label()
|
||||
|
||||
@classmethod
|
||||
def set_int(cls, name, value):
|
||||
cls.values[name] = value
|
||||
|
||||
@classmethod
|
||||
def set_float(cls, name, value):
|
||||
cls.values[name] = value
|
||||
|
||||
@classmethod
|
||||
def label(cls, name):
|
||||
return cls.spectrum if name == "Spectrum1" else None
|
||||
|
||||
@staticmethod
|
||||
def add_label(text):
|
||||
label = Label(text)
|
||||
created.append(label)
|
||||
return label
|
||||
|
||||
worker._apply_surface_3d_presentation(
|
||||
Layer(),
|
||||
{"enabled": False},
|
||||
x_title="Temperature (°C)",
|
||||
y_title="Time (min)",
|
||||
z_title="Strength (MPa)",
|
||||
)
|
||||
|
||||
self.assertEqual(Layer.values["unit"], 1)
|
||||
self.assertEqual(Layer.values["left"], 22)
|
||||
self.assertEqual(Layer.values["width"], 58)
|
||||
self.assertEqual(Layer.values["z.label.pt"], 7)
|
||||
self.assertFalse(Layer.spectrum.show)
|
||||
self.assertEqual(
|
||||
[(label.name, label.text) for label in created],
|
||||
[
|
||||
("ZCBOT_X_TITLE", "Temperature (°C)"),
|
||||
("ZCBOT_Y_TITLE", "Time (min)"),
|
||||
("ZCBOT_Z_TITLE", "Strength (MPa)"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(created[0].values["attach"], 0)
|
||||
self.assertEqual(created[2].values["rotate"], 90)
|
||||
self.assertIn("label -r zf", Obj.commands[0])
|
||||
|
||||
def test_layout_helpers_apply_validated_values(self) -> None:
|
||||
class FakeAxis:
|
||||
title = ""
|
||||
|
|
@ -981,6 +1090,66 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
|||
self.assertNotIn(b"10 20 m", normalized)
|
||||
self.assertEqual(len(content), len(original))
|
||||
|
||||
def test_png_is_rendered_from_normalized_pdf_at_requested_width(self) -> None:
|
||||
events = []
|
||||
|
||||
class Pixmap:
|
||||
width = 1654
|
||||
height = 1181
|
||||
|
||||
@staticmethod
|
||||
def save(path):
|
||||
events.append(("save", Path(path).name))
|
||||
|
||||
class Page:
|
||||
class Rect:
|
||||
width = 371
|
||||
|
||||
rect = Rect()
|
||||
|
||||
@staticmethod
|
||||
def get_pixmap(*, matrix, alpha):
|
||||
events.append(("render", matrix, alpha))
|
||||
return Pixmap()
|
||||
|
||||
class Document:
|
||||
page_count = 1
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __getitem__(_index):
|
||||
return Page()
|
||||
|
||||
class PyMuPDF:
|
||||
@staticmethod
|
||||
def Matrix(x, y):
|
||||
return (x, y)
|
||||
|
||||
@staticmethod
|
||||
def open(path):
|
||||
events.append(("open", Path(path).name))
|
||||
return Document()
|
||||
|
||||
with patch.dict(sys.modules, {"pymupdf": PyMuPDF()}):
|
||||
size = worker._render_pdf_to_png(
|
||||
Path("normalized.pdf"), Path("figure.png"), 1654
|
||||
)
|
||||
|
||||
self.assertEqual(size, (1654, 1181))
|
||||
self.assertEqual(
|
||||
events,
|
||||
[
|
||||
("open", "normalized.pdf"),
|
||||
("render", (1654 / 371, 1654 / 371), False),
|
||||
("save", "figure.png"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_origin_arranges_panel_layers_and_reports_resulting_geometry(self) -> None:
|
||||
class Layer:
|
||||
def __init__(self, geometry):
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ class SoftwareJobToolTests(unittest.TestCase):
|
|||
],
|
||||
))
|
||||
self.assertTrue(result["created"])
|
||||
self.assertEqual(result["completion_delivery"], "automatic")
|
||||
self.assertEqual(
|
||||
result["next_action"], "end_turn_after_reporting_queued_job_id"
|
||||
)
|
||||
self.assertEqual(create.call_args.args[:2], (self.user_id, self.task_id))
|
||||
self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2")
|
||||
self.assertEqual(create.call_args.kwargs["completion_action"], "report")
|
||||
|
|
@ -101,6 +105,8 @@ class SoftwareJobToolTests(unittest.TestCase):
|
|||
SoftwareJobSubmitTool.parameters["properties"]["completion_action"]["enum"],
|
||||
["report", "analyze"],
|
||||
)
|
||||
self.assertIn("terminal action for the current run", SoftwareJobSubmitTool.description)
|
||||
self.assertIn("automatically", SoftwareJobSubmitTool.description)
|
||||
|
||||
def test_submit_can_request_automatic_analysis(self):
|
||||
artifact_id = uuid4()
|
||||
|
|
@ -169,6 +175,10 @@ class SoftwareJobToolTests(unittest.TestCase):
|
|||
idempotency_key="revision-1",
|
||||
))
|
||||
self.assertTrue(result["created"])
|
||||
self.assertEqual(result["completion_delivery"], "automatic")
|
||||
self.assertEqual(
|
||||
result["next_action"], "end_turn_after_reporting_queued_job_id"
|
||||
)
|
||||
revise.assert_called_once_with(
|
||||
self.user_id,
|
||||
self.task_id,
|
||||
|
|
|
|||
|
|
@ -66,8 +66,10 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
|
|||
name = "software_job_submit"
|
||||
description = (
|
||||
"Submit a managed professional-software job using registered artifacts and a "
|
||||
"capability contract. Call register_artifact first for workspace files. Return "
|
||||
"immediately with job_id; do not poll continuously or wait for completion."
|
||||
"capability contract. Call register_artifact first for workspace files. A successful "
|
||||
"submission is the terminal action for the current run: report the queued job_id once "
|
||||
"and end the turn. Completion delivery and artifact publication happen automatically "
|
||||
"in a later system-managed message."
|
||||
)
|
||||
@staticmethod
|
||||
def _parameters() -> dict:
|
||||
|
|
@ -147,7 +149,12 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
|
|||
completion_action=completion_action,
|
||||
request=normalized_request,
|
||||
)
|
||||
return json.dumps({**job, "created": created}, ensure_ascii=False)
|
||||
return json.dumps({
|
||||
**job,
|
||||
"created": created,
|
||||
"completion_delivery": "automatic",
|
||||
"next_action": "end_turn_after_reporting_queued_job_id",
|
||||
}, ensure_ascii=False)
|
||||
except (SoftwareJobError, ValueError) as exc:
|
||||
return f"[Error] {exc}"
|
||||
|
||||
|
|
@ -189,7 +196,9 @@ class SoftwareJobReviseTool(_SoftwareJobTool):
|
|||
"Create a new professional-software job from a prior job in the current task. "
|
||||
"Reuse the prior registered inputs, provide a complete replacement operation and "
|
||||
"outputs, and leave the prior job and artifacts unchanged. Call software_job_status "
|
||||
"first when the prior editable_request is not already in context."
|
||||
"first when the prior editable_request is not already in context. A successful revision "
|
||||
"is the terminal action for the current run; completion and artifacts are delivered "
|
||||
"automatically in a later system-managed message."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -237,7 +246,12 @@ class SoftwareJobReviseTool(_SoftwareJobTool):
|
|||
operation=operation,
|
||||
outputs=outputs,
|
||||
)
|
||||
return json.dumps({**job, "created": created}, ensure_ascii=False)
|
||||
return json.dumps({
|
||||
**job,
|
||||
"created": created,
|
||||
"completion_delivery": "automatic",
|
||||
"next_action": "end_turn_after_reporting_queued_job_id",
|
||||
}, ensure_ascii=False)
|
||||
except (SoftwareJobError, ValueError) as exc:
|
||||
return f"[Error] {exc}"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"capability": "origin.plot@v2",
|
||||
"adapter_version": "0.9.3",
|
||||
"adapter_version": "0.9.4",
|
||||
"runtime": "python",
|
||||
"runtime_id": "origin",
|
||||
"entrypoint": "worker.py",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
originpro==1.1.15
|
||||
openpyxl==3.1.5
|
||||
numpy==2.2.6
|
||||
PyMuPDF==1.28.2
|
||||
|
|
|
|||
|
|
@ -75,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.3"
|
||||
ADAPTER_VERSION = "0.9.4"
|
||||
|
||||
|
||||
def _server_executable(command: str) -> Path:
|
||||
|
|
@ -417,8 +417,30 @@ def _apply_canvas(graph: Any, canvas: Any) -> None:
|
|||
return
|
||||
width = float(canvas["width_mm"])
|
||||
height = float(canvas["height_mm"])
|
||||
graph.set_float("width", width / 25.4 * graph.get_float("resx"))
|
||||
graph.set_float("height", height / 25.4 * graph.get_float("resy"))
|
||||
# Origin may reload a sub-600-DPI template against the active printer and
|
||||
# silently change resx/resy without scaling width/height. Pin both the
|
||||
# physical page and its resolution before any export or OPJU save.
|
||||
graph.activate()
|
||||
graph.obj.LT_execute(
|
||||
"page.updatetoprinter=0; "
|
||||
"page.resx=600; page.resy=600; "
|
||||
f"page.width={width / 25.4 * 600}; "
|
||||
f"page.height={height / 25.4 * 600};"
|
||||
)
|
||||
|
||||
|
||||
def _new_graph(op: Any, template: str, canvas: Any) -> Any:
|
||||
"""Create against the final page size so template objects stay inside exports."""
|
||||
graph = op.new_graph(template=template)
|
||||
if not isinstance(canvas, dict):
|
||||
return graph
|
||||
# Origin templates, especially 3D surfaces, may store layer geometry in
|
||||
# physical units. Convert it to % of page before changing page dimensions;
|
||||
# otherwise a smaller requested canvas crops axes and the color scale.
|
||||
for layer in graph:
|
||||
layer.set_int("unit", 1)
|
||||
_apply_canvas(graph, canvas)
|
||||
return graph
|
||||
|
||||
|
||||
def _configure_origin_session(op: Any) -> None:
|
||||
|
|
@ -534,6 +556,22 @@ def _strip_origin_pdf_text_baselines(path: Path) -> int:
|
|||
return removed
|
||||
|
||||
|
||||
def _render_pdf_to_png(
|
||||
pdf_path: Path, png_path: Path, pixel_width: int
|
||||
) -> tuple[int, int]:
|
||||
"""Rasterize the normalized vector export instead of Origin's broken PNG."""
|
||||
import pymupdf
|
||||
|
||||
with pymupdf.open(pdf_path) as document:
|
||||
if document.page_count != 1:
|
||||
raise RuntimeError("PNG_SOURCE_PDF_PAGE_COUNT_INVALID")
|
||||
page = document[0]
|
||||
scale = pixel_width / page.rect.width
|
||||
pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False)
|
||||
pixmap.save(png_path)
|
||||
return pixmap.width, pixmap.height
|
||||
|
||||
|
||||
def _arrange_panel_layers(
|
||||
layers: list[Any], grid: tuple[int, int]
|
||||
) -> list[tuple[float, float, float, float]]:
|
||||
|
|
@ -693,6 +731,56 @@ def _legend_label(layer: Any) -> Any:
|
|||
return label
|
||||
|
||||
|
||||
def _apply_surface_3d_presentation(
|
||||
layer: Any,
|
||||
legend: Any,
|
||||
*,
|
||||
x_title: str | None = None,
|
||||
y_title: str | None = None,
|
||||
z_title: str | None = None,
|
||||
) -> None:
|
||||
"""Keep the projected frame and its optional color scale inside the page."""
|
||||
layer.set_int("unit", 1)
|
||||
for name, value in (
|
||||
("left", 22),
|
||||
("top", 15),
|
||||
("width", 58),
|
||||
("height", 62),
|
||||
):
|
||||
layer.set_float(name, value)
|
||||
for axis_name in ("x", "y", "z"):
|
||||
layer.set_float(f"{axis_name}.label.pt", 7)
|
||||
# The glCMAP template keeps front/back title objects for every dimension,
|
||||
# while originpro's generic title setter only updates one face. Delete all
|
||||
# six template titles, then use ordinary layer-attached labels that Origin
|
||||
# will not regenerate or move to an unused 3D face.
|
||||
layer.obj.LT_execute(
|
||||
"label -r xb; label -r xt; label -r yl; "
|
||||
"label -r yr; label -r zb; label -r zf;"
|
||||
)
|
||||
for name, text, left, top, rotate in (
|
||||
("ZCBOT_X_TITLE", x_title, 1000, 10100, 0),
|
||||
("ZCBOT_Y_TITLE", y_title, 7600, 9700, 0),
|
||||
("ZCBOT_Z_TITLE", z_title, -1300, 3500, 90),
|
||||
):
|
||||
if text is None:
|
||||
continue
|
||||
title = layer.add_label(text)
|
||||
title.name = name
|
||||
title.set_int("attach", 0)
|
||||
title.set_int("background", 0)
|
||||
title.set_int("fsize", 9)
|
||||
title.set_int("left", left)
|
||||
title.set_int("top", top)
|
||||
title.set_int("rotate", rotate)
|
||||
|
||||
# A 3D colormap surface uses Spectrum1 as its legend, not the ordinary
|
||||
# Legend label. Respect the same public legend.enabled contract.
|
||||
color_scale = layer.label("Spectrum1")
|
||||
if color_scale is not None and isinstance(legend, dict):
|
||||
color_scale.show = legend.get("enabled", True)
|
||||
|
||||
|
||||
def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None:
|
||||
if not value:
|
||||
return
|
||||
|
|
@ -950,6 +1038,7 @@ def _build_stacked_graph(
|
|||
resolved_series: list[dict[str, Any]],
|
||||
template: str,
|
||||
plot_type: int,
|
||||
canvas: Any = None,
|
||||
) -> tuple[Any, Any, list[Any]]:
|
||||
first = resolved_series[0]
|
||||
_, first_rows = input_data[first["input"]]
|
||||
|
|
@ -973,7 +1062,7 @@ def _build_stacked_graph(
|
|||
lname=str(series_spec.get("label") or series_spec["y"]),
|
||||
)
|
||||
|
||||
graph = op.new_graph(template=template)
|
||||
graph = _new_graph(op, template, canvas)
|
||||
layer = graph[0]
|
||||
data_range = f"{staging.lt_range(False)}!(1,2:{len(series_specs) + 1})"
|
||||
layer.add_plot(data_range, type=plot_type)
|
||||
|
|
@ -1000,10 +1089,9 @@ def _build_recipe_graph(
|
|||
panels = plot_spec["panels"]
|
||||
layout = plot_spec["layout"]
|
||||
grid = (layout["rows"], layout["columns"])
|
||||
graph = op.new_graph(template="line")
|
||||
graph = _new_graph(op, "line", plot_spec.get("canvas"))
|
||||
while len(graph) < len(panels):
|
||||
graph.add_layer(0)
|
||||
_apply_canvas(graph, plot_spec.get("canvas"))
|
||||
primary_layers = list(graph)[: len(panels)]
|
||||
geometries = _arrange_panel_layers(primary_layers, grid)
|
||||
|
||||
|
|
@ -1108,8 +1196,9 @@ def _build_band_graph(
|
|||
worksheets: dict[str, Any],
|
||||
series_specs: list[dict[str, Any]],
|
||||
resolved_series: list[dict[str, Any]],
|
||||
canvas: Any = None,
|
||||
) -> tuple[Any, Any, list[Any], str]:
|
||||
graph = op.new_graph(template="line")
|
||||
graph = _new_graph(op, "line", canvas)
|
||||
layer = graph[0]
|
||||
center_plots = []
|
||||
legend_entries = []
|
||||
|
|
@ -1189,7 +1278,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
|||
origin_plots = []
|
||||
elif plot_type == "band":
|
||||
graph, layer, origin_plots, band_legend = _build_band_graph(
|
||||
op, worksheets, series_specs, resolved_series
|
||||
op, worksheets, series_specs, resolved_series, plot_spec.get("canvas")
|
||||
)
|
||||
elif plot_type == "heatmap":
|
||||
import numpy as np
|
||||
|
|
@ -1203,7 +1292,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
|||
matrix_sheet.set_label(
|
||||
0, _axis_title(plot_spec.get("z_axis"), str(series_specs[0]["z"]))
|
||||
)
|
||||
graph = op.new_graph(template="heatmap")
|
||||
graph = _new_graph(op, "heatmap", plot_spec.get("canvas"))
|
||||
layer = graph[0]
|
||||
origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)]
|
||||
elif plot_type in STACKED_PLOT_TYPES:
|
||||
|
|
@ -1215,10 +1304,11 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
|||
resolved_series,
|
||||
template,
|
||||
origin_plot_type,
|
||||
plot_spec.get("canvas"),
|
||||
)
|
||||
else:
|
||||
template, origin_plot_type = PLOT_CONFIG[plot_type]
|
||||
graph = op.new_graph(template=template)
|
||||
graph = _new_graph(op, template, plot_spec.get("canvas"))
|
||||
layer = graph[0]
|
||||
origin_plots = []
|
||||
for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
|
||||
|
|
@ -1238,7 +1328,6 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
|||
origin_plots.append(origin_plot)
|
||||
if plot_type == "grouped_column":
|
||||
layer.group()
|
||||
_apply_canvas(graph, plot_spec.get("canvas"))
|
||||
if plot_type not in COMPOSITION_PLOT_TYPES:
|
||||
if plot_type not in {"polar", "pie"}:
|
||||
axes = [
|
||||
|
|
@ -1276,6 +1365,24 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
|||
_apply_series_style(origin_plot, style)
|
||||
if plot_type in STACKED_PLOT_TYPES:
|
||||
_replace_series_legend(layer, series_specs)
|
||||
if plot_type == "surface_3d":
|
||||
_apply_surface_3d_presentation(
|
||||
layer,
|
||||
plot_spec.get("legend"),
|
||||
x_title=_axis_title(
|
||||
plot_spec.get("x_axis"),
|
||||
str(series_specs[0].get("x") or "X"),
|
||||
),
|
||||
y_title=_axis_title(
|
||||
plot_spec.get("y_axis"),
|
||||
str(series_specs[0].get("y") or "Y"),
|
||||
),
|
||||
z_title=_axis_title(
|
||||
plot_spec.get("z_axis"),
|
||||
str(series_specs[0].get("z") or "Z"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
_apply_legend(layer, plot_spec.get("legend"))
|
||||
if band_legend is not None:
|
||||
layer.label("Legend").text = band_legend
|
||||
|
|
@ -1297,30 +1404,52 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
|||
raise ValueError("OUTPUT_DPI_INVALID")
|
||||
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 not in formats:
|
||||
continue
|
||||
target = output / f"figure.{extension}"
|
||||
if "svg" in formats:
|
||||
target = output / "figure.svg"
|
||||
_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,
|
||||
type="svg",
|
||||
width=0,
|
||||
ratio=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()
|
||||
)
|
||||
raise RuntimeError("SVG_EXPORT_FAILED")
|
||||
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]))
|
||||
_validate_artifact(target, "svg")
|
||||
artifacts.append(_manifest(target, media["svg"]))
|
||||
|
||||
# Origin's native PNG rasterizer draws a separate printing-baseline
|
||||
# primitive under every text object. Export one PDF, remove those vector
|
||||
# primitives, then rasterize the normalized page at the requested DPI.
|
||||
if "pdf" in formats or "png" in formats:
|
||||
pdf_target = output / (
|
||||
"figure.pdf" if "pdf" in formats else ".figure-png-source.pdf"
|
||||
)
|
||||
_configure_origin_session(op)
|
||||
exported = Path(
|
||||
graph.save_fig(str(pdf_target), type="pdf", width=0, ratio=100)
|
||||
).resolve()
|
||||
if exported != pdf_target.resolve() or not pdf_target.is_file():
|
||||
raise RuntimeError("PDF_EXPORT_FAILED")
|
||||
_strip_origin_pdf_text_baselines(pdf_target)
|
||||
_validate_artifact(pdf_target, "pdf")
|
||||
if "png" in formats:
|
||||
png_target = output / "figure.png"
|
||||
rendered_width, _ = _render_pdf_to_png(
|
||||
pdf_target, png_target, pixel_width
|
||||
)
|
||||
if abs(rendered_width - pixel_width) > 1:
|
||||
raise RuntimeError("PNG_RENDER_WIDTH_INVALID")
|
||||
_validate_artifact(png_target, "png")
|
||||
artifacts.append(_manifest(png_target, media["png"]))
|
||||
if "pdf" in formats:
|
||||
artifacts.append(_manifest(pdf_target, media["pdf"]))
|
||||
else:
|
||||
pdf_target.unlink()
|
||||
try:
|
||||
originpro_version = version("originpro")
|
||||
except PackageNotFoundError:
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ if errorlevel 1 (
|
|||
echo [ERR] Failed to install the Origin worker dependencies.
|
||||
goto :failed
|
||||
)
|
||||
"%RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy; print('[OK] Origin worker Python packages are available.')"
|
||||
"%RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy, pymupdf; print('[OK] Origin worker Python packages are available.')"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Origin runtime import verification failed.
|
||||
goto :failed
|
||||
|
|
|
|||
Loading…
Reference in New Issue