fix(software): 修复 Origin 导出与重复完成报告

This commit is contained in:
caoqianming 2026-08-17 15:37:21 +08:00
parent 1cca765796
commit 4ec4496c3d
9 changed files with 363 additions and 36 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 专业软件任务提交后由系统统一发送完成通知和产物Agent 不再自行等待并重复报告Origin PNG 改由清理异常文字基线后的矢量页渲染,三维图在指定毫米画布时也会同步缩放模板图层,避免横线及坐标标题、色标和文字裁切。
- 专业软件任务完成后可按用户意图仅报告产物或自动读取图表和数据并给出分析Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。 - 专业软件任务完成后可按用户意图仅报告产物或自动读取图表和数据并给出分析Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。 - 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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。 - **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 ### 2026-08-17

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util import importlib.util
import json import json
import sys
import tempfile import tempfile
import unittest import unittest
import zlib import zlib
@ -109,6 +110,114 @@ class OriginWorkerUnitTests(unittest.TestCase):
) )
self.assertEqual(worker._axis_title(None, "Time"), "Time") 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: def test_layout_helpers_apply_validated_values(self) -> None:
class FakeAxis: class FakeAxis:
title = "" title = ""
@ -981,6 +1090,66 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.assertNotIn(b"10 20 m", normalized) self.assertNotIn(b"10 20 m", normalized)
self.assertEqual(len(content), len(original)) 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: 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

@ -59,6 +59,10 @@ class SoftwareJobToolTests(unittest.TestCase):
], ],
)) ))
self.assertTrue(result["created"]) 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.args[:2], (self.user_id, self.task_id))
self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2") self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2")
self.assertEqual(create.call_args.kwargs["completion_action"], "report") self.assertEqual(create.call_args.kwargs["completion_action"], "report")
@ -101,6 +105,8 @@ class SoftwareJobToolTests(unittest.TestCase):
SoftwareJobSubmitTool.parameters["properties"]["completion_action"]["enum"], SoftwareJobSubmitTool.parameters["properties"]["completion_action"]["enum"],
["report", "analyze"], ["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): def test_submit_can_request_automatic_analysis(self):
artifact_id = uuid4() artifact_id = uuid4()
@ -169,6 +175,10 @@ class SoftwareJobToolTests(unittest.TestCase):
idempotency_key="revision-1", idempotency_key="revision-1",
)) ))
self.assertTrue(result["created"]) 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( revise.assert_called_once_with(
self.user_id, self.user_id,
self.task_id, self.task_id,

View File

@ -66,8 +66,10 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
name = "software_job_submit" name = "software_job_submit"
description = ( description = (
"Submit a managed professional-software job using registered artifacts and a " "Submit a managed professional-software job using registered artifacts and a "
"capability contract. Call register_artifact first for workspace files. Return " "capability contract. Call register_artifact first for workspace files. A successful "
"immediately with job_id; do not poll continuously or wait for completion." "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 @staticmethod
def _parameters() -> dict: def _parameters() -> dict:
@ -147,7 +149,12 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
completion_action=completion_action, completion_action=completion_action,
request=normalized_request, 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: except (SoftwareJobError, ValueError) as exc:
return f"[Error] {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. " "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 " "Reuse the prior registered inputs, provide a complete replacement operation and "
"outputs, and leave the prior job and artifacts unchanged. Call software_job_status " "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 @staticmethod
@ -237,7 +246,12 @@ class SoftwareJobReviseTool(_SoftwareJobTool):
operation=operation, operation=operation,
outputs=outputs, 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: except (SoftwareJobError, ValueError) as exc:
return f"[Error] {exc}" return f"[Error] {exc}"

View File

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

View File

@ -1,3 +1,4 @@
originpro==1.1.15 originpro==1.1.15
openpyxl==3.1.5 openpyxl==3.1.5
numpy==2.2.6 numpy==2.2.6
PyMuPDF==1.28.2

View File

@ -75,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.3" ADAPTER_VERSION = "0.9.4"
def _server_executable(command: str) -> Path: def _server_executable(command: str) -> Path:
@ -417,8 +417,30 @@ def _apply_canvas(graph: Any, canvas: Any) -> None:
return return
width = float(canvas["width_mm"]) width = float(canvas["width_mm"])
height = float(canvas["height_mm"]) height = float(canvas["height_mm"])
graph.set_float("width", width / 25.4 * graph.get_float("resx")) # Origin may reload a sub-600-DPI template against the active printer and
graph.set_float("height", height / 25.4 * graph.get_float("resy")) # 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: def _configure_origin_session(op: Any) -> None:
@ -534,6 +556,22 @@ def _strip_origin_pdf_text_baselines(path: Path) -> int:
return removed 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( 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]]:
@ -693,6 +731,56 @@ def _legend_label(layer: Any) -> Any:
return label 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: def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None:
if not value: if not value:
return return
@ -950,6 +1038,7 @@ def _build_stacked_graph(
resolved_series: list[dict[str, Any]], resolved_series: list[dict[str, Any]],
template: str, template: str,
plot_type: int, plot_type: int,
canvas: Any = None,
) -> tuple[Any, Any, list[Any]]: ) -> tuple[Any, Any, list[Any]]:
first = resolved_series[0] first = resolved_series[0]
_, first_rows = input_data[first["input"]] _, first_rows = input_data[first["input"]]
@ -973,7 +1062,7 @@ def _build_stacked_graph(
lname=str(series_spec.get("label") or series_spec["y"]), 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] layer = graph[0]
data_range = f"{staging.lt_range(False)}!(1,2:{len(series_specs) + 1})" data_range = f"{staging.lt_range(False)}!(1,2:{len(series_specs) + 1})"
layer.add_plot(data_range, type=plot_type) layer.add_plot(data_range, type=plot_type)
@ -1000,10 +1089,9 @@ def _build_recipe_graph(
panels = plot_spec["panels"] panels = plot_spec["panels"]
layout = plot_spec["layout"] layout = plot_spec["layout"]
grid = (layout["rows"], layout["columns"]) 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): while len(graph) < len(panels):
graph.add_layer(0) graph.add_layer(0)
_apply_canvas(graph, plot_spec.get("canvas"))
primary_layers = list(graph)[: len(panels)] primary_layers = list(graph)[: len(panels)]
geometries = _arrange_panel_layers(primary_layers, grid) geometries = _arrange_panel_layers(primary_layers, grid)
@ -1108,8 +1196,9 @@ def _build_band_graph(
worksheets: dict[str, Any], worksheets: dict[str, Any],
series_specs: list[dict[str, Any]], series_specs: list[dict[str, Any]],
resolved_series: list[dict[str, Any]], resolved_series: list[dict[str, Any]],
canvas: Any = None,
) -> tuple[Any, Any, list[Any], str]: ) -> tuple[Any, Any, list[Any], str]:
graph = op.new_graph(template="line") graph = _new_graph(op, "line", canvas)
layer = graph[0] layer = graph[0]
center_plots = [] center_plots = []
legend_entries = [] legend_entries = []
@ -1189,7 +1278,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
origin_plots = [] origin_plots = []
elif plot_type == "band": elif plot_type == "band":
graph, layer, origin_plots, band_legend = _build_band_graph( 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": elif plot_type == "heatmap":
import numpy as np import numpy as np
@ -1203,7 +1292,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
matrix_sheet.set_label( matrix_sheet.set_label(
0, _axis_title(plot_spec.get("z_axis"), str(series_specs[0]["z"])) 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] layer = graph[0]
origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)] origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)]
elif plot_type in STACKED_PLOT_TYPES: elif plot_type in STACKED_PLOT_TYPES:
@ -1215,10 +1304,11 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
resolved_series, resolved_series,
template, template,
origin_plot_type, origin_plot_type,
plot_spec.get("canvas"),
) )
else: else:
template, origin_plot_type = PLOT_CONFIG[plot_type] 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] layer = graph[0]
origin_plots = [] origin_plots = []
for series_spec, resolved in zip(series_specs, resolved_series, strict=True): 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) origin_plots.append(origin_plot)
if plot_type == "grouped_column": if plot_type == "grouped_column":
layer.group() layer.group()
_apply_canvas(graph, plot_spec.get("canvas"))
if plot_type not in COMPOSITION_PLOT_TYPES: if plot_type not in COMPOSITION_PLOT_TYPES:
if plot_type not in {"polar", "pie"}: if plot_type not in {"polar", "pie"}:
axes = [ axes = [
@ -1276,7 +1365,25 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
_apply_series_style(origin_plot, style) _apply_series_style(origin_plot, style)
if plot_type in STACKED_PLOT_TYPES: if plot_type in STACKED_PLOT_TYPES:
_replace_series_legend(layer, series_specs) _replace_series_legend(layer, series_specs)
_apply_legend(layer, plot_spec.get("legend")) 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: if band_legend is not None:
layer.label("Legend").text = band_legend layer.label("Legend").text = band_legend
_apply_title(layer, plot_spec.get("title"), plot_spec.get("title_style")) _apply_title(layer, plot_spec.get("title"), plot_spec.get("title_style"))
@ -1297,30 +1404,52 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
raise ValueError("OUTPUT_DPI_INVALID") raise ValueError("OUTPUT_DPI_INVALID")
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"): if "svg" in formats:
if extension not in formats: target = output / "figure.svg"
continue
target = output / f"figure.{extension}"
_configure_origin_session(op) _configure_origin_session(op)
exported = Path( exported = Path(
graph.save_fig( graph.save_fig(
str(target), str(target),
type=extension, type="svg",
width=pixel_width if extension == "png" else 0, width=0,
ratio=0 if extension == "png" else 100, ratio=100,
) )
).resolve() ).resolve()
if exported != target.resolve() or not target.is_file(): if exported != target.resolve() or not target.is_file():
raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED") raise RuntimeError("SVG_EXPORT_FAILED")
if extension == "svg": normalized_svg, _ = _strip_origin_svg_text_baselines(target.read_bytes())
normalized_svg, _ = _strip_origin_svg_text_baselines( target.write_bytes(normalized_svg)
target.read_bytes() _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
) )
target.write_bytes(normalized_svg) if abs(rendered_width - pixel_width) > 1:
elif extension == "pdf": raise RuntimeError("PNG_RENDER_WIDTH_INVALID")
_strip_origin_pdf_text_baselines(target) _validate_artifact(png_target, "png")
_validate_artifact(target, extension) artifacts.append(_manifest(png_target, media["png"]))
artifacts.append(_manifest(target, media[extension])) if "pdf" in formats:
artifacts.append(_manifest(pdf_target, media["pdf"]))
else:
pdf_target.unlink()
try: try:
originpro_version = version("originpro") originpro_version = version("originpro")
except PackageNotFoundError: except PackageNotFoundError:

View File

@ -56,7 +56,7 @@ if errorlevel 1 (
echo [ERR] Failed to install the Origin worker dependencies. echo [ERR] Failed to install the Origin worker dependencies.
goto :failed 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 ( if errorlevel 1 (
echo [ERR] Origin runtime import verification failed. echo [ERR] Origin runtime import verification failed.
goto :failed goto :failed