1400 lines
52 KiB
Python
1400 lines
52 KiB
Python
"""Fixed Origin adapter for origin.plot@v2.
|
||
|
||
This process accepts exactly one argument: a Node-created job directory. It never
|
||
installs packages, evaluates user code, downloads data, or resolves paths from the
|
||
request. terminal.json is its only terminal-state contract.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import hashlib
|
||
import json
|
||
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
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
PLOT_CONFIG = {
|
||
"line": ("line", "l"),
|
||
"scatter": ("scatter", "s"),
|
||
"line_scatter": ("linesymb", "y"),
|
||
"column": ("column", "c"),
|
||
"bar": ("bar", 215),
|
||
"grouped_column": ("column", "c"),
|
||
"y_error": ("ERRBAR", "y"),
|
||
"contour": ("TriContour", 243),
|
||
"surface_3d": ("glCMAP", 103),
|
||
"ternary": ("ternary", 245),
|
||
"box": ("box", 206),
|
||
"histogram": ("hist", 219),
|
||
"stacked_column": ("StackColumn", 213),
|
||
"bubble": ("scatter", "s"),
|
||
"band": ("line", "l"),
|
||
"area": ("area", 204),
|
||
"stacked_area": ("stackarea", 214),
|
||
"polar": ("polar", 192),
|
||
"pie": ("pie", 225),
|
||
"stacked_bar": ("bar", 216),
|
||
}
|
||
STACKED_PLOT_TYPES = {"stacked_column", "stacked_area", "stacked_bar"}
|
||
COMPOSITION_PLOT_TYPES = {"multi_panel", "recipe"}
|
||
_CUMULATIVE_STACK_COMMAND = "layer -b s 1"
|
||
XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"}
|
||
FORMATS = {"opju", "png", "svg", "pdf"}
|
||
LINE_STYLES = {
|
||
"solid": 1,
|
||
"dash": 2,
|
||
"dot": 3,
|
||
"dash_dot": 4,
|
||
"dash_dot_dot": 5,
|
||
}
|
||
SYMBOLS = {
|
||
"square": 1,
|
||
"circle": 2,
|
||
"triangle_up": 3,
|
||
"diamond": 5,
|
||
"cross": 7,
|
||
"plus": 6,
|
||
}
|
||
LEGEND_POSITIONS = {
|
||
"top_left": (700, 500),
|
||
"top_right": (6800, 500),
|
||
"bottom_left": (700, 7200),
|
||
"bottom_right": (6800, 7200),
|
||
}
|
||
PANEL_LAYOUT = {
|
||
(1, 1): {"left": 13, "right": 13, "top": 14, "bottom": 14, "xgap": 0, "ygap": 0},
|
||
(1, 2): {"left": 9, "right": 4, "top": 14, "bottom": 14, "xgap": 11, "ygap": 0},
|
||
(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"
|
||
|
||
|
||
def _server_executable(command: str) -> Path:
|
||
value = os.path.expandvars(command.strip())
|
||
match = re.match(r'^\s*"([^"]+\.exe)"|^\s*(.+?\.exe)(?:\s|$)', value, re.IGNORECASE)
|
||
if match is None:
|
||
raise ValueError("Origin COM server executable is invalid")
|
||
return Path(match.group(1) or match.group(2))
|
||
|
||
|
||
def _windows_file_version(path: Path) -> str:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
|
||
class FixedFileInfo(ctypes.Structure):
|
||
_fields_ = [
|
||
("signature", wintypes.DWORD),
|
||
("structure_version", wintypes.DWORD),
|
||
("file_version_ms", wintypes.DWORD),
|
||
("file_version_ls", wintypes.DWORD),
|
||
("product_version_ms", wintypes.DWORD),
|
||
("product_version_ls", wintypes.DWORD),
|
||
("file_flags_mask", wintypes.DWORD),
|
||
("file_flags", wintypes.DWORD),
|
||
("file_os", wintypes.DWORD),
|
||
("file_type", wintypes.DWORD),
|
||
("file_subtype", wintypes.DWORD),
|
||
("file_date_ms", wintypes.DWORD),
|
||
("file_date_ls", wintypes.DWORD),
|
||
]
|
||
|
||
class LanguageAndCodePage(ctypes.Structure):
|
||
_fields_ = [("language", wintypes.WORD), ("code_page", wintypes.WORD)]
|
||
|
||
version_api = ctypes.WinDLL("version", use_last_error=True)
|
||
version_api.GetFileVersionInfoSizeW.argtypes = [
|
||
wintypes.LPCWSTR,
|
||
ctypes.POINTER(wintypes.DWORD),
|
||
]
|
||
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
|
||
version_api.GetFileVersionInfoW.argtypes = [
|
||
wintypes.LPCWSTR,
|
||
wintypes.DWORD,
|
||
wintypes.DWORD,
|
||
ctypes.c_void_p,
|
||
]
|
||
version_api.GetFileVersionInfoW.restype = wintypes.BOOL
|
||
version_api.VerQueryValueW.argtypes = [
|
||
ctypes.c_void_p,
|
||
wintypes.LPCWSTR,
|
||
ctypes.POINTER(ctypes.c_void_p),
|
||
ctypes.POINTER(wintypes.UINT),
|
||
]
|
||
version_api.VerQueryValueW.restype = wintypes.BOOL
|
||
ignored_handle = wintypes.DWORD()
|
||
size = version_api.GetFileVersionInfoSizeW(str(path), ctypes.byref(ignored_handle))
|
||
if size == 0:
|
||
raise ctypes.WinError(ctypes.get_last_error())
|
||
buffer = ctypes.create_string_buffer(size)
|
||
if not version_api.GetFileVersionInfoW(str(path), 0, size, buffer):
|
||
raise ctypes.WinError(ctypes.get_last_error())
|
||
value = ctypes.c_void_p()
|
||
value_size = wintypes.UINT()
|
||
if not version_api.VerQueryValueW(buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)):
|
||
raise ctypes.WinError(ctypes.get_last_error())
|
||
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
|
||
translation = ctypes.c_void_p()
|
||
translation_size = wintypes.UINT()
|
||
if version_api.VerQueryValueW(
|
||
buffer,
|
||
r"\VarFileInfo\Translation",
|
||
ctypes.byref(translation),
|
||
ctypes.byref(translation_size),
|
||
):
|
||
count = translation_size.value // ctypes.sizeof(LanguageAndCodePage)
|
||
translations = ctypes.cast(
|
||
translation, ctypes.POINTER(LanguageAndCodePage * count)
|
||
).contents
|
||
for item in translations:
|
||
prefix = rf"\StringFileInfo\{item.language:04x}{item.code_page:04x}"
|
||
strings = {}
|
||
for name in ("FileDescription", "ProductVersion"):
|
||
string_value = ctypes.c_void_p()
|
||
string_size = wintypes.UINT()
|
||
if version_api.VerQueryValueW(
|
||
buffer,
|
||
f"{prefix}\\{name}",
|
||
ctypes.byref(string_value),
|
||
ctypes.byref(string_size),
|
||
):
|
||
strings[name] = ctypes.wstring_at(string_value).strip()
|
||
description = strings.get("FileDescription", "")
|
||
match = re.fullmatch(r"Origin(?:Pro)?\s+(.+)", description)
|
||
if match:
|
||
return match.group(1)
|
||
if strings.get("ProductVersion"):
|
||
return strings["ProductVersion"]
|
||
parts = (
|
||
info.file_version_ms >> 16,
|
||
info.file_version_ms & 0xFFFF,
|
||
info.file_version_ls >> 16,
|
||
info.file_version_ls & 0xFFFF,
|
||
)
|
||
if not any(parts):
|
||
raise ValueError("Origin executable has no file version")
|
||
return ".".join(str(part) for part in parts)
|
||
|
||
|
||
def _registered_origin_version(winreg: Any) -> str | None:
|
||
try:
|
||
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID") as clsid_key:
|
||
clsid = winreg.QueryValueEx(clsid_key, None)[0]
|
||
with winreg.OpenKey(
|
||
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
|
||
) as server_key:
|
||
command = winreg.QueryValueEx(server_key, None)[0]
|
||
return _windows_file_version(_server_executable(str(command)))
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def _probe() -> int:
|
||
health = "ready"
|
||
detail = "Origin COM 与托管 Python 运行时可用"
|
||
software_version = None
|
||
try:
|
||
if sys.platform != "win32":
|
||
raise RuntimeError("Origin adapter requires Windows")
|
||
import winreg
|
||
|
||
import originpro
|
||
|
||
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"):
|
||
pass
|
||
software_version = _registered_origin_version(winreg)
|
||
originpro_version = version("originpro")
|
||
detail = f"Origin COM 与托管 Python 运行时可用(originpro {originpro_version})"
|
||
del originpro
|
||
except (
|
||
FileNotFoundError,
|
||
ImportError,
|
||
OSError,
|
||
PackageNotFoundError,
|
||
RuntimeError,
|
||
) as exc:
|
||
health = "unavailable"
|
||
detail = str(exc)
|
||
print(
|
||
json.dumps(
|
||
{
|
||
"adapter_version": ADAPTER_VERSION,
|
||
"software": "OriginPro",
|
||
"software_version": software_version,
|
||
"health": health,
|
||
"detail": detail,
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
)
|
||
return 0
|
||
|
||
|
||
def _validate_semantics(request: dict[str, Any]) -> None:
|
||
input_keys = {item["key"] for item in request["inputs"]}
|
||
plot = request["operation"]["plot"]
|
||
plot_type = plot["type"]
|
||
if plot_type in COMPOSITION_PLOT_TYPES:
|
||
panels = plot["panels"]
|
||
layout = plot["layout"]
|
||
grid = (layout["rows"], layout["columns"])
|
||
valid_grids = {
|
||
1: {(1, 1)},
|
||
2: {(1, 2), (2, 1)},
|
||
3: {(2, 2)},
|
||
4: {(2, 2)},
|
||
}
|
||
if grid not in valid_grids[len(panels)]:
|
||
raise ValueError("PANEL_LAYOUT_TOO_SMALL")
|
||
panel_keys = [panel["key"] for panel in panels]
|
||
if len(set(panel_keys)) != len(panel_keys):
|
||
raise ValueError("PANEL_KEYS_MUST_BE_UNIQUE")
|
||
series = [item for panel in panels for item in panel["series"]]
|
||
if len(series) > 16:
|
||
raise ValueError("SERIES_COUNT_EXCEEDED")
|
||
for panel in panels:
|
||
if not any(item.get("y_axis", "left") == "left" for item in panel["series"]):
|
||
raise ValueError("PANEL_LEFT_AXIS_REQUIRES_SERIES")
|
||
for axis_name in ("x_axis", "y_axis", "right_y_axis"):
|
||
_validate_axis(panel.get(axis_name), axis_name)
|
||
for axis_name in ("x_axis", "y_axis"):
|
||
_validate_axis(plot.get(axis_name), axis_name)
|
||
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
|
||
if layout.get(share_name):
|
||
settings = [_panel_setting(plot, panel, axis_name) or {} for panel in panels]
|
||
if any(value != settings[0] for value in settings[1:]):
|
||
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
|
||
else:
|
||
series = plot["series"]
|
||
used_inputs = {item["input"] for item in series}
|
||
if used_inputs != input_keys:
|
||
raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY")
|
||
if plot_type in COMPOSITION_PLOT_TYPES:
|
||
return
|
||
if plot_type == "grouped_column" and len(series) < 2:
|
||
raise ValueError("GROUPED_COLUMN_REQUIRES_MULTIPLE_SERIES")
|
||
if plot_type in STACKED_PLOT_TYPES and len(series) < 2:
|
||
raise ValueError("STACKED_PLOT_REQUIRES_MULTIPLE_SERIES")
|
||
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
|
||
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
|
||
required_roles = (
|
||
("x", "y", "z")
|
||
if plot_type in XYZ_PLOT_TYPES
|
||
else ("x", "y", "y_error")
|
||
if plot_type == "y_error"
|
||
else ("y",)
|
||
if plot_type in {"box", "histogram"}
|
||
else ("x", "y", "size")
|
||
if plot_type == "bubble"
|
||
else ("x", "y", "lower", "upper")
|
||
if plot_type == "band"
|
||
else ("x", "y")
|
||
)
|
||
for item in series:
|
||
if any(role not in item for role in required_roles):
|
||
raise ValueError("SERIES_REQUIRED_ROLE_MISSING")
|
||
for axis_name in ("x_axis", "y_axis", "z_axis"):
|
||
_validate_axis(plot.get(axis_name), axis_name)
|
||
|
||
|
||
def _validate_axis(value: Any, name: str) -> None:
|
||
axis = value if isinstance(value, dict) else {}
|
||
minimum = axis.get("minimum")
|
||
maximum = axis.get("maximum")
|
||
if minimum is not None and maximum is not None and minimum >= maximum:
|
||
raise ValueError(f"{name.upper()}_LIMITS_INVALID")
|
||
if axis.get("scale") in {"log10", "ln", "log2"} and (
|
||
minimum is not None and minimum <= 0 or maximum is not None and maximum <= 0
|
||
):
|
||
raise ValueError(f"{name.upper()}_LOG_LIMIT_INVALID")
|
||
|
||
|
||
def _atomic_json(path: Path, value: Any) -> None:
|
||
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
|
||
try:
|
||
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
||
json.dump(value, handle, ensure_ascii=False, indent=2)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.replace(temporary, path)
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
|
||
|
||
def _read_rows(path: Path, sheet: str | None) -> tuple[list[str], list[list[Any]]]:
|
||
suffix = path.suffix.lower()
|
||
if suffix == ".csv":
|
||
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||
rows = list(csv.reader(handle))
|
||
if len(rows) < 2:
|
||
raise ValueError("CSV_INPUT_EMPTY")
|
||
return [str(item) for item in rows[0]], rows[1:]
|
||
if suffix == ".json":
|
||
value = json.loads(path.read_text(encoding="utf-8"))
|
||
if isinstance(value, list) and value and all(isinstance(item, dict) for item in value):
|
||
headers = list(value[0])
|
||
return headers, [[item.get(name) for name in headers] for item in value]
|
||
if (
|
||
isinstance(value, dict)
|
||
and value
|
||
and all(isinstance(item, list) for item in value.values())
|
||
):
|
||
headers = list(value)
|
||
length = max(len(value[name]) for name in headers)
|
||
return headers, [
|
||
[value[name][index] if index < len(value[name]) else None for name in headers]
|
||
for index in range(length)
|
||
]
|
||
raise ValueError("JSON_INPUT_SHAPE_UNSUPPORTED")
|
||
if suffix == ".xlsx":
|
||
from openpyxl import load_workbook
|
||
|
||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||
try:
|
||
worksheet = workbook[sheet] if sheet else workbook.active
|
||
rows = list(worksheet.iter_rows(values_only=True))
|
||
finally:
|
||
workbook.close()
|
||
if len(rows) < 2:
|
||
raise ValueError("XLSX_INPUT_EMPTY")
|
||
return [str(item or "") for item in rows[0]], [list(row) for row in rows[1:]]
|
||
raise ValueError("INPUT_TYPE_UNSUPPORTED")
|
||
|
||
|
||
def _column_index(headers: list[str], value: Any, field: str) -> int:
|
||
if not isinstance(value, str) or value not in headers:
|
||
raise ValueError(f"{field.upper()}_COLUMN_NOT_FOUND")
|
||
return headers.index(value)
|
||
|
||
|
||
def _manifest(path: Path, media_type: str) -> dict[str, Any]:
|
||
return {
|
||
"artifact_id": {
|
||
"project.opju": "project",
|
||
"figure.png": "figure_png",
|
||
"figure.svg": "figure_svg",
|
||
"figure.pdf": "figure_pdf",
|
||
"plot-spec.json": "plot_spec",
|
||
"provenance.json": "provenance",
|
||
}[path.name],
|
||
"filename": path.name,
|
||
"media_type": media_type,
|
||
"size_bytes": path.stat().st_size,
|
||
"sha256": _file_sha256(path),
|
||
}
|
||
|
||
|
||
def _file_sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def _axis_title(axis: Any, fallback: str) -> str:
|
||
if not isinstance(axis, dict):
|
||
return fallback
|
||
title = str(axis.get("title") or fallback)
|
||
unit = str(axis.get("unit") or "")
|
||
return f"{title} ({unit})" if unit else title
|
||
|
||
|
||
def _hex_color(value: str) -> tuple[int, int, int]:
|
||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||
|
||
|
||
def _apply_canvas(graph: Any, canvas: Any) -> None:
|
||
if not isinstance(canvas, dict):
|
||
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"))
|
||
|
||
|
||
def _configure_origin_session(op: Any) -> None:
|
||
"""Normalize export behavior that otherwise depends on machine defaults."""
|
||
# 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]]:
|
||
"""Let Origin arrange the grid, then return its resulting page geometry."""
|
||
settings = PANEL_LAYOUT[grid]
|
||
for layer in layers:
|
||
layer.set_int("unit", 1)
|
||
layers[0].activate()
|
||
arguments = " ".join(
|
||
f"{name}:={value}"
|
||
for name, value in (
|
||
("row", grid[0]),
|
||
("col", grid[1]),
|
||
*settings.items(),
|
||
)
|
||
)
|
||
layers[0].obj.LT_execute(f"layarrange {arguments};")
|
||
return [
|
||
tuple(layer.get_float(name) for name in ("left", "top", "width", "height"))
|
||
for layer in layers
|
||
]
|
||
|
||
|
||
def _apply_shared_x_presentation(
|
||
layer: Any, *, panel_index: int, rows: int, columns: int, share_x: bool
|
||
) -> None:
|
||
"""For shared X grids, keep labels and the title only on the bottom row."""
|
||
if not share_x or panel_index // columns == rows - 1:
|
||
return
|
||
title = layer.label("xb")
|
||
if title is not None:
|
||
title.show = False
|
||
layer.activate()
|
||
layer.obj.LT_execute("axis -ps X L 0;")
|
||
|
||
|
||
def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
|
||
settings = spec if isinstance(spec, dict) else {}
|
||
axis = layer.axis(name)
|
||
axis.title = _axis_title(settings, fallback)
|
||
if "scale" in settings:
|
||
axis.scale = settings["scale"]
|
||
if any(key in settings for key in ("minimum", "maximum", "major_step")):
|
||
axis.set_limits(
|
||
settings.get("minimum"), settings.get("maximum"), settings.get("major_step")
|
||
)
|
||
if "tick_label_angle" in settings:
|
||
layer.set_float(f"{name}.label.rotate", float(settings["tick_label_angle"]))
|
||
if "tick_label_font_size" in settings:
|
||
layer.set_float(f"{name}.label.pt", float(settings["tick_label_font_size"]))
|
||
if "title_font_size" in settings:
|
||
title_object = {"x": "xb", "y": "yl", "y2": "yr", "z": "zf"}[name]
|
||
layer.label(title_object).set_int("fsize", round(settings["title_font_size"]))
|
||
if "grid" in settings:
|
||
grid_value = {"none": 0, "major": 1, "major_minor": 3}[settings["grid"]]
|
||
layer.set_int(f"{name}.grid.show", grid_value)
|
||
|
||
|
||
def _rescale_with_axis_scales(layer: Any, axes: list[tuple[str, Any]]) -> None:
|
||
"""Apply axis transforms before Origin derives automatic limits.
|
||
|
||
Origin's linear rescale commonly includes zero. Switching that result to a
|
||
logarithmic axis afterwards coerces the zero bound to an unusable value such
|
||
as 1E-10. Explicit limits are intentionally left to ``_apply_axis`` after
|
||
rescaling so a caller-provided range remains authoritative.
|
||
"""
|
||
for name, spec in axes:
|
||
if isinstance(spec, dict) and "scale" in spec:
|
||
layer.axis(name).scale = spec["scale"]
|
||
layer.rescale()
|
||
|
||
|
||
def _apply_series_style(origin_plot: Any, style: Any) -> None:
|
||
if not isinstance(style, dict):
|
||
return
|
||
if "color" in style:
|
||
origin_plot.color = _hex_color(style["color"])
|
||
if "line_width" in style:
|
||
origin_plot.set_float("line.width", float(style["line_width"]))
|
||
if "line_style" in style:
|
||
origin_plot.set_int("line.type", LINE_STYLES[style["line_style"]])
|
||
if "symbol" in style:
|
||
origin_plot.symbol_kind = SYMBOLS[style["symbol"]]
|
||
if "symbol_size" in style:
|
||
origin_plot.symbol_size = float(style["symbol_size"])
|
||
if "transparency" in style:
|
||
origin_plot.transparency = int(style["transparency"])
|
||
|
||
|
||
def _apply_error_style(origin_plot: Any, style: Any) -> None:
|
||
if not isinstance(style, dict):
|
||
return
|
||
if "color" in style:
|
||
origin_plot.color = _hex_color(style["color"])
|
||
if "line_width" in style:
|
||
origin_plot.set_float("line.width", float(style["line_width"]))
|
||
if "transparency" in style:
|
||
origin_plot.transparency = int(style["transparency"])
|
||
|
||
|
||
def _apply_legend(
|
||
layer: Any,
|
||
legend: Any,
|
||
vertical_offset: int = 0,
|
||
*,
|
||
attach_to_layer: bool = False,
|
||
graph: Any = None,
|
||
geometry: tuple[int, int, int, int] | None = None,
|
||
) -> None:
|
||
if not isinstance(legend, dict):
|
||
return
|
||
label = _legend_label(layer)
|
||
label.set_int("background", 0)
|
||
if attach_to_layer:
|
||
label.set_int("attach", 0)
|
||
label.show = legend.get("enabled", True)
|
||
if "font_size" in legend:
|
||
label.set_int("fsize", round(legend["font_size"]))
|
||
if legend.get("position") == "auto":
|
||
label.set_int("smartpos", 1)
|
||
elif "position" in legend:
|
||
if attach_to_layer:
|
||
# 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"):
|
||
vertical_offset = -vertical_offset
|
||
label.set_int("left", left)
|
||
label.set_int("top", top + vertical_offset)
|
||
|
||
|
||
def _replace_series_legend(
|
||
layer: Any,
|
||
series_specs: list[dict[str, Any]],
|
||
origin_plots: list[Any] | None = None,
|
||
) -> None:
|
||
plot_numbers = (
|
||
[plot.index() + 1 for plot in origin_plots]
|
||
if origin_plots is not None
|
||
else list(range(1, len(series_specs) + 1))
|
||
)
|
||
_legend_label(layer).text = "\n".join(
|
||
f"\\l({plot_number}) {item.get('label') or item['y']}"
|
||
for plot_number, item in zip(plot_numbers, series_specs, strict=True)
|
||
)
|
||
|
||
|
||
def _legend_label(layer: Any) -> Any:
|
||
label = layer.label("Legend")
|
||
if label is None:
|
||
label = layer.add_label("")
|
||
label.name = "Legend"
|
||
return label
|
||
|
||
|
||
def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None:
|
||
if not value:
|
||
return
|
||
title = layer.add_label(str(value))
|
||
title.set_int("background", 0)
|
||
title.set_int("fsize", round((style or {}).get("font_size", 18)))
|
||
title.set_int("left", 2200)
|
||
title.set_int("top", top)
|
||
|
||
|
||
def _panel_page_pixel(
|
||
graph: Any,
|
||
geometry: tuple[int, int, int, int] | None,
|
||
x_fraction: float,
|
||
y_fraction: float,
|
||
) -> tuple[float, float]:
|
||
if geometry is None:
|
||
raise ValueError("PANEL_GEOMETRY_REQUIRED")
|
||
page_width = graph.get_float("width")
|
||
page_height = graph.get_float("height")
|
||
left, top, width, height = geometry
|
||
return (
|
||
page_width * (left + width * x_fraction) / 100,
|
||
page_height * (top + height * y_fraction) / 100,
|
||
)
|
||
|
||
|
||
def _apply_panel_title(
|
||
graph: Any,
|
||
layer: Any,
|
||
geometry: tuple[int, int, int, int],
|
||
value: Any,
|
||
style: Any,
|
||
) -> None:
|
||
if not value:
|
||
return
|
||
title = layer.add_label(str(value))
|
||
title.set_int("background", 0)
|
||
title.set_int("attach", 1)
|
||
title.set_int("fsize", round((style or {}).get("font_size", 12)))
|
||
left, top = _panel_page_pixel(graph, geometry, 0.28, -0.14)
|
||
title.set_int("left", round(left))
|
||
title.set_int("top", round(top))
|
||
|
||
|
||
def _apply_page_title(graph: Any, layer: Any, value: Any, style: Any) -> None:
|
||
if not value:
|
||
return
|
||
title = layer.add_label(str(value))
|
||
title.set_int("background", 0)
|
||
title.set_int("attach", 1)
|
||
title.set_int("fsize", round((style or {}).get("font_size", 18)))
|
||
title.set_int("left", round(graph.get_float("width") * 0.32))
|
||
title.set_int("top", round(graph.get_float("height") * 0.01))
|
||
|
||
|
||
def _panel_setting(plot_spec: dict[str, Any], panel: dict[str, Any], name: str) -> Any:
|
||
value = panel.get(name)
|
||
return value if value is not None else plot_spec.get(name)
|
||
|
||
|
||
def _panel_y_axis_fallback(panel: dict[str, Any], series_specs: list[dict[str, Any]]) -> str:
|
||
"""Prefer meaningful declarative metadata over Origin's generic ``Y``."""
|
||
return str(panel.get("title") or series_specs[0].get("y") or "Y")
|
||
|
||
|
||
def _panel_legend(plot_spec: dict[str, Any], panel: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"enabled": True,
|
||
"position": "top_right",
|
||
**(plot_spec.get("legend") or {}),
|
||
**(panel.get("legend") or {}),
|
||
}
|
||
|
||
|
||
def _input_file(job_dir: Path, key: str) -> Path:
|
||
directory = job_dir / "input" / key
|
||
files = [
|
||
path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")
|
||
]
|
||
if len(files) != 1:
|
||
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
|
||
return files[0]
|
||
|
||
|
||
def _resolve_series(
|
||
input_data: dict[str, tuple[list[str], list[list[Any]]]],
|
||
series_specs: list[dict[str, Any]],
|
||
) -> tuple[list[dict[str, Any]], dict[tuple[str, int], str]]:
|
||
resolved: list[dict[str, Any]] = []
|
||
labels: dict[tuple[str, int], str] = {}
|
||
for series in series_specs:
|
||
input_key = series["input"]
|
||
headers, _ = input_data[input_key]
|
||
role_indexes = {
|
||
role: _column_index(headers, series[role], role)
|
||
for role in ("x", "y", "z", "size", "lower", "upper", "x_error", "y_error")
|
||
if role in series
|
||
}
|
||
y_index = role_indexes["y"]
|
||
label = series.get("label")
|
||
label_key = (input_key, y_index)
|
||
effective_label = label or series["y"]
|
||
if label_key in labels and labels[label_key] != effective_label:
|
||
raise ValueError("SERIES_LABEL_CONFLICT")
|
||
labels[label_key] = effective_label
|
||
resolved.append({"input": input_key, "label": label, **role_indexes})
|
||
return resolved, labels
|
||
|
||
|
||
def _number(value: Any, role: str) -> float:
|
||
if isinstance(value, bool):
|
||
raise ValueError(f"{role.upper()}_VALUE_NOT_NUMERIC") # noqa: TRY004
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"{role.upper()}_VALUE_NOT_NUMERIC") from exc
|
||
if not math.isfinite(number):
|
||
raise ValueError(f"{role.upper()}_VALUE_NOT_FINITE")
|
||
return number
|
||
|
||
|
||
def _validate_bubble_values(
|
||
input_data: dict[str, tuple[list[str], list[list[Any]]]],
|
||
resolved_series: list[dict[str, Any]],
|
||
) -> None:
|
||
for resolved in resolved_series:
|
||
_, rows = input_data[resolved["input"]]
|
||
for row in rows:
|
||
try:
|
||
size = _number(row[resolved["size"]], "size")
|
||
except IndexError as exc:
|
||
raise ValueError("BUBBLE_ROW_INCOMPLETE") from exc
|
||
if size <= 0:
|
||
raise ValueError("SIZE_VALUE_NOT_POSITIVE")
|
||
|
||
|
||
def _validate_band_values(
|
||
input_data: dict[str, tuple[list[str], list[list[Any]]]],
|
||
resolved_series: list[dict[str, Any]],
|
||
) -> None:
|
||
for resolved in resolved_series:
|
||
_, rows = input_data[resolved["input"]]
|
||
for row in rows:
|
||
try:
|
||
lower = _number(row[resolved["lower"]], "lower")
|
||
upper = _number(row[resolved["upper"]], "upper")
|
||
except IndexError as exc:
|
||
raise ValueError("BAND_ROW_INCOMPLETE") from exc
|
||
if lower > upper:
|
||
raise ValueError("BAND_BOUNDS_INVERTED")
|
||
|
||
|
||
def _is_evenly_spaced(values: list[float]) -> bool:
|
||
if len(values) <= 2:
|
||
return True
|
||
step = values[1] - values[0]
|
||
tolerance = max(abs(step) * 1e-9, 1e-12)
|
||
return all(
|
||
math.isclose(current - previous, step, rel_tol=1e-9, abs_tol=tolerance)
|
||
for previous, current in pairwise(values[1:])
|
||
)
|
||
|
||
|
||
def _heatmap_matrix(
|
||
rows: list[list[Any]], resolved: dict[str, Any]
|
||
) -> tuple[list[list[float]], tuple[float, float, float, float]]:
|
||
points: dict[tuple[float, float], float] = {}
|
||
for row in rows:
|
||
try:
|
||
x = _number(row[resolved["x"]], "x")
|
||
y = _number(row[resolved["y"]], "y")
|
||
z = _number(row[resolved["z"]], "z")
|
||
except IndexError as exc:
|
||
raise ValueError("HEATMAP_ROW_INCOMPLETE") from exc
|
||
if (x, y) in points:
|
||
raise ValueError("HEATMAP_COORDINATES_DUPLICATED")
|
||
points[(x, y)] = z
|
||
x_values = sorted({item[0] for item in points})
|
||
y_values = sorted({item[1] for item in points})
|
||
if len(x_values) < 2 or len(y_values) < 2:
|
||
raise ValueError("HEATMAP_GRID_TOO_SMALL")
|
||
if len(points) != len(x_values) * len(y_values):
|
||
raise ValueError("HEATMAP_GRID_INCOMPLETE")
|
||
if not _is_evenly_spaced(x_values) or not _is_evenly_spaced(y_values):
|
||
raise ValueError("HEATMAP_GRID_NOT_REGULAR")
|
||
matrix = [[points[(x, y)] for x in x_values] for y in y_values]
|
||
return matrix, (x_values[0], x_values[-1], y_values[0], y_values[-1])
|
||
|
||
|
||
def _validate_artifact(path: Path, extension: str) -> None:
|
||
if not path.is_file() or path.stat().st_size == 0:
|
||
raise RuntimeError(f"{extension.upper()}_EXPORT_EMPTY")
|
||
head = path.read_bytes()[:1024]
|
||
if extension == "png" and not head.startswith(b"\x89PNG\r\n\x1a\n"):
|
||
raise RuntimeError("PNG_EXPORT_INVALID")
|
||
if extension == "pdf" and not head.startswith(b"%PDF-"):
|
||
raise RuntimeError("PDF_EXPORT_INVALID")
|
||
if extension == "svg" and b"<svg" not in head.lower():
|
||
raise RuntimeError("SVG_EXPORT_INVALID")
|
||
if extension == "opju" and len(head) < 64:
|
||
raise RuntimeError("OPJU_EXPORT_INVALID")
|
||
|
||
|
||
def _add_xy_plots(
|
||
layer: Any,
|
||
worksheets: dict[str, Any],
|
||
series_specs: list[dict[str, Any]],
|
||
resolved_series: list[dict[str, Any]],
|
||
) -> list[Any]:
|
||
plots = []
|
||
error_plots: list[list[Any]] = []
|
||
for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
|
||
kind = series_spec["kind"]
|
||
arguments = {
|
||
"coly": resolved["y"],
|
||
"colx": resolved["x"],
|
||
"type": PLOT_CONFIG[kind][1],
|
||
}
|
||
if "x_error" in resolved:
|
||
arguments["colxerr"] = resolved["x_error"]
|
||
if "y_error" in resolved:
|
||
arguments["colyerr"] = resolved["y_error"]
|
||
plot_count = len(layer.plot_list())
|
||
plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
|
||
plots.append(plot)
|
||
error_plots.append(list(layer.plot_list())[plot_count + 1 :])
|
||
if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs):
|
||
layer.group()
|
||
else:
|
||
# Explicit series styles require independent plots.
|
||
layer.group(False)
|
||
for plot, errors, series_spec in zip(plots, error_plots, series_specs, strict=True):
|
||
style = series_spec.get("style")
|
||
_apply_series_style(plot, style)
|
||
for error_plot in errors:
|
||
_apply_error_style(error_plot, style)
|
||
return plots
|
||
|
||
|
||
def _configure_bubble_plot(
|
||
op: Any,
|
||
origin_plot: Any,
|
||
series_spec: dict[str, Any],
|
||
resolved: dict[str, Any],
|
||
) -> None:
|
||
origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"])
|
||
origin_plot.symbol_sizefactor = float((series_spec.get("style") or {}).get("symbol_size", 10))
|
||
|
||
|
||
def _build_stacked_graph(
|
||
op: Any,
|
||
input_data: dict[str, tuple[list[str], list[list[Any]]]],
|
||
series_specs: list[dict[str, Any]],
|
||
resolved_series: list[dict[str, Any]],
|
||
template: str,
|
||
plot_type: int,
|
||
) -> tuple[Any, Any, list[Any]]:
|
||
first = resolved_series[0]
|
||
_, first_rows = input_data[first["input"]]
|
||
x_values = [row[first["x"]] for row in first_rows]
|
||
y_columns: list[list[Any]] = []
|
||
for resolved in resolved_series:
|
||
_, rows = input_data[resolved["input"]]
|
||
current_x = [row[resolved["x"]] for row in rows]
|
||
if current_x != x_values:
|
||
raise ValueError("STACKED_PLOT_X_VALUES_MISMATCH")
|
||
y_columns.append([row[resolved["y"]] for row in rows])
|
||
|
||
staging = op.new_sheet("w", lname="stacked_plot_data")
|
||
staging.from_list(0, x_values, lname=str(series_specs[0]["x"]))
|
||
for index, (series_spec, values) in enumerate(
|
||
zip(series_specs, y_columns, strict=True), start=1
|
||
):
|
||
staging.from_list(
|
||
index,
|
||
values,
|
||
lname=str(series_spec.get("label") or series_spec["y"]),
|
||
)
|
||
|
||
graph = op.new_graph(template=template)
|
||
layer = graph[0]
|
||
data_range = f"{staging.lt_range(False)}!(1,2:{len(series_specs) + 1})"
|
||
layer.add_plot(data_range, type=plot_type)
|
||
layer.group(True, 0, len(series_specs) - 1)
|
||
layer.activate()
|
||
layer.obj.LT_execute(_CUMULATIVE_STACK_COMMAND)
|
||
return graph, layer, list(layer.plot_list())
|
||
|
||
|
||
def _share_axis_limits(layers: list[Any], name: str) -> None:
|
||
limits = [getattr(layer, f"{name}lim") for layer in layers]
|
||
begin = min(item[0] for item in limits)
|
||
end = max(item[1] for item in limits)
|
||
for layer in layers:
|
||
getattr(layer, f"set_{name}lim")(begin, end)
|
||
|
||
|
||
def _build_recipe_graph(
|
||
op: Any,
|
||
plot_spec: dict[str, Any],
|
||
worksheets: dict[str, Any],
|
||
resolved_series: list[dict[str, Any]],
|
||
) -> Any:
|
||
panels = plot_spec["panels"]
|
||
layout = plot_spec["layout"]
|
||
grid = (layout["rows"], layout["columns"])
|
||
graph = op.new_graph(template="line")
|
||
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)
|
||
|
||
resolved_offset = 0
|
||
for panel_index, (panel, layer, geometry) in enumerate(
|
||
zip(panels, primary_layers, geometries, strict=True)
|
||
):
|
||
panel_series = panel["series"]
|
||
resolved_panel = resolved_series[resolved_offset : resolved_offset + len(panel_series)]
|
||
resolved_offset += len(panel_series)
|
||
left_pairs = [
|
||
(spec, resolved)
|
||
for spec, resolved in zip(panel_series, resolved_panel, strict=True)
|
||
if spec.get("y_axis", "left") == "left"
|
||
]
|
||
right_pairs = [
|
||
(spec, resolved)
|
||
for spec, resolved in zip(panel_series, resolved_panel, strict=True)
|
||
if spec.get("y_axis", "left") == "right"
|
||
]
|
||
left_specs = [item[0] for item in left_pairs]
|
||
left_resolved = [item[1] for item in left_pairs]
|
||
left_plots = _add_xy_plots(layer, worksheets, left_specs, left_resolved)
|
||
x_axis = _panel_setting(plot_spec, panel, "x_axis")
|
||
y_axis = _panel_setting(plot_spec, panel, "y_axis")
|
||
_rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)])
|
||
_apply_axis(
|
||
layer,
|
||
"x",
|
||
x_axis,
|
||
str(left_specs[0]["x"]),
|
||
)
|
||
_apply_axis(layer, "y", y_axis, _panel_y_axis_fallback(panel, left_specs))
|
||
_apply_shared_x_presentation(
|
||
layer,
|
||
panel_index=panel_index,
|
||
rows=grid[0],
|
||
columns=grid[1],
|
||
share_x=bool(layout.get("share_x")),
|
||
)
|
||
legend = _panel_legend(plot_spec, panel)
|
||
_replace_series_legend(layer, left_specs, left_plots)
|
||
_apply_legend(
|
||
layer,
|
||
legend,
|
||
attach_to_layer=True,
|
||
graph=graph,
|
||
geometry=geometry,
|
||
)
|
||
_apply_panel_title(
|
||
graph,
|
||
layer,
|
||
geometry,
|
||
panel.get("title"),
|
||
panel.get("title_style"),
|
||
)
|
||
if panel.get("panel_label"):
|
||
panel_label = layer.add_label(str(panel["panel_label"]))
|
||
panel_label.set_int("background", 0)
|
||
panel_label.set_int("attach", 1)
|
||
panel_label.set_int(
|
||
"fsize", round((panel.get("title_style") or {}).get("font_size", 12))
|
||
)
|
||
panel_left, panel_top = _panel_page_pixel(graph, geometry, 0.02, -0.14)
|
||
panel_label.set_float("left", round(panel_left))
|
||
panel_label.set_float("top", round(panel_top))
|
||
|
||
if right_pairs:
|
||
layer.activate()
|
||
right_layer = graph.add_layer(2)
|
||
right_specs = [item[0] for item in right_pairs]
|
||
right_resolved = [item[1] for item in right_pairs]
|
||
right_plots = _add_xy_plots(right_layer, worksheets, right_specs, right_resolved)
|
||
right_y_axis = panel.get("right_y_axis")
|
||
_rescale_with_axis_scales(right_layer, [("y2", right_y_axis)])
|
||
_apply_axis(right_layer, "y2", right_y_axis, "Right Y")
|
||
_replace_series_legend(right_layer, right_specs, right_plots)
|
||
_apply_legend(
|
||
right_layer,
|
||
legend,
|
||
vertical_offset=700,
|
||
attach_to_layer=True,
|
||
graph=graph,
|
||
geometry=geometry,
|
||
)
|
||
|
||
if layout.get("share_x") and len(primary_layers) > 1:
|
||
_share_axis_limits(primary_layers, "x")
|
||
if layout.get("share_y") and len(primary_layers) > 1:
|
||
_share_axis_limits(primary_layers, "y")
|
||
_apply_page_title(
|
||
graph,
|
||
primary_layers[0],
|
||
plot_spec.get("title"),
|
||
plot_spec.get("title_style"),
|
||
)
|
||
return graph
|
||
|
||
|
||
def _build_band_graph(
|
||
op: Any,
|
||
worksheets: dict[str, Any],
|
||
series_specs: list[dict[str, Any]],
|
||
resolved_series: list[dict[str, Any]],
|
||
) -> tuple[Any, Any, list[Any], str]:
|
||
graph = op.new_graph(template="line")
|
||
layer = graph[0]
|
||
center_plots = []
|
||
legend_entries = []
|
||
for index, (series_spec, resolved) in enumerate(
|
||
zip(series_specs, resolved_series, strict=True)
|
||
):
|
||
worksheet = worksheets[resolved["input"]]
|
||
upper_plot = layer.add_plot(worksheet, coly=resolved["upper"], colx=resolved["x"], type="l")
|
||
lower_plot = layer.add_plot(worksheet, coly=resolved["lower"], colx=resolved["x"], type="l")
|
||
upper_plot.set_fill_area(type=9)
|
||
_apply_series_style(upper_plot, series_spec.get("style"))
|
||
_apply_series_style(lower_plot, series_spec.get("style"))
|
||
center_plot = layer.add_plot(worksheet, coly=resolved["y"], colx=resolved["x"], type="l")
|
||
center_plots.append(center_plot)
|
||
label = series_spec.get("label") or series_spec["y"]
|
||
legend_entries.append(f"\\l({index * 3 + 3}) {label}")
|
||
return graph, layer, center_plots, "\n".join(legend_entries)
|
||
|
||
|
||
def run(job_dir: Path) -> list[dict[str, Any]]:
|
||
job_dir = job_dir.resolve(strict=True)
|
||
request_record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8"))
|
||
request = request_record["request"]
|
||
_validate_semantics(request)
|
||
input_specs = request["inputs"]
|
||
input_files = {item["key"]: _input_file(job_dir, item["key"]) for item in input_specs}
|
||
input_data = {
|
||
item["key"]: _read_rows(
|
||
input_files[item["key"]],
|
||
(item.get("selector") or {}).get("sheet"),
|
||
)
|
||
for item in input_specs
|
||
}
|
||
plot_spec = request["operation"]["plot"]
|
||
plot_type = plot_spec["type"]
|
||
if plot_type not in PLOT_CONFIG and plot_type not in {
|
||
"heatmap",
|
||
*COMPOSITION_PLOT_TYPES,
|
||
}:
|
||
raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
|
||
series_specs = (
|
||
[item for panel in plot_spec["panels"] for item in panel["series"]]
|
||
if plot_type in COMPOSITION_PLOT_TYPES
|
||
else plot_spec["series"]
|
||
)
|
||
resolved_series, labels = _resolve_series(input_data, series_specs)
|
||
if plot_type == "bubble":
|
||
_validate_bubble_values(input_data, resolved_series)
|
||
if plot_type == "band":
|
||
_validate_band_values(input_data, resolved_series)
|
||
|
||
import originpro as op
|
||
|
||
output = job_dir / "output"
|
||
output.mkdir(exist_ok=True)
|
||
op.set_show(False)
|
||
try:
|
||
op.new()
|
||
_configure_origin_session(op)
|
||
worksheets: dict[str, Any] = {}
|
||
for input_spec in input_specs:
|
||
input_key = input_spec["key"]
|
||
headers, rows = input_data[input_key]
|
||
worksheet = op.new_sheet("w", lname=input_key)
|
||
worksheets[input_key] = worksheet
|
||
for index, header in enumerate(headers):
|
||
column_label = labels.get((input_key, index), header)
|
||
worksheet.from_list(
|
||
index,
|
||
[row[index] if index < len(row) else None for row in rows],
|
||
lname=column_label,
|
||
)
|
||
band_legend = None
|
||
if plot_type in COMPOSITION_PLOT_TYPES:
|
||
graph = _build_recipe_graph(op, plot_spec, worksheets, resolved_series)
|
||
layer = graph[0]
|
||
origin_plots = []
|
||
elif plot_type == "band":
|
||
graph, layer, origin_plots, band_legend = _build_band_graph(
|
||
op, worksheets, series_specs, resolved_series
|
||
)
|
||
elif plot_type == "heatmap":
|
||
import numpy as np
|
||
|
||
resolved = resolved_series[0]
|
||
_, rows = input_data[resolved["input"]]
|
||
matrix, xy_map = _heatmap_matrix(rows, resolved)
|
||
matrix_sheet = op.new_sheet("m")
|
||
matrix_sheet.from_np(np.array(matrix, dtype=float))
|
||
matrix_sheet.xymap = xy_map
|
||
matrix_sheet.set_label(
|
||
0, _axis_title(plot_spec.get("z_axis"), str(series_specs[0]["z"]))
|
||
)
|
||
graph = op.new_graph(template="heatmap")
|
||
layer = graph[0]
|
||
origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)]
|
||
elif plot_type in STACKED_PLOT_TYPES:
|
||
template, origin_plot_type = PLOT_CONFIG[plot_type]
|
||
graph, layer, origin_plots = _build_stacked_graph(
|
||
op,
|
||
input_data,
|
||
series_specs,
|
||
resolved_series,
|
||
template,
|
||
origin_plot_type,
|
||
)
|
||
else:
|
||
template, origin_plot_type = PLOT_CONFIG[plot_type]
|
||
graph = op.new_graph(template=template)
|
||
layer = graph[0]
|
||
origin_plots = []
|
||
for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
|
||
arguments = {
|
||
"coly": resolved["y"],
|
||
"type": origin_plot_type,
|
||
}
|
||
if "x" in resolved:
|
||
arguments["colx"] = resolved["x"]
|
||
if plot_type in XYZ_PLOT_TYPES:
|
||
arguments["colz"] = resolved["z"]
|
||
if plot_type == "y_error":
|
||
arguments["colyerr"] = resolved["y_error"]
|
||
origin_plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
|
||
if plot_type == "bubble":
|
||
_configure_bubble_plot(op, origin_plot, series_spec, resolved)
|
||
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 = [
|
||
("x", plot_spec.get("x_axis")),
|
||
("y", plot_spec.get("y_axis")),
|
||
]
|
||
if plot_type == "surface_3d":
|
||
axes.append(("z", plot_spec.get("z_axis")))
|
||
_rescale_with_axis_scales(layer, axes)
|
||
_apply_axis(
|
||
layer,
|
||
"x",
|
||
plot_spec.get("x_axis"),
|
||
str(series_specs[0].get("x") or "X"),
|
||
)
|
||
_apply_axis(
|
||
layer,
|
||
"y",
|
||
plot_spec.get("y_axis"),
|
||
str(series_specs[0].get("y") or "Y"),
|
||
)
|
||
else:
|
||
layer.rescale()
|
||
if plot_type == "surface_3d":
|
||
_apply_axis(
|
||
layer,
|
||
"z",
|
||
plot_spec.get("z_axis"),
|
||
str(series_specs[0].get("z") or "Z"),
|
||
)
|
||
for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
|
||
style = series_spec.get("style")
|
||
if plot_type == "bubble" and style:
|
||
style = {key: value for key, value in style.items() if key != "symbol_size"}
|
||
_apply_series_style(origin_plot, style)
|
||
if plot_type in STACKED_PLOT_TYPES:
|
||
_replace_series_legend(layer, series_specs)
|
||
_apply_legend(layer, plot_spec.get("legend"))
|
||
if band_legend is not None:
|
||
layer.label("Legend").text = band_legend
|
||
_apply_title(layer, plot_spec.get("title"), plot_spec.get("title_style"))
|
||
requested_outputs = request["outputs"]
|
||
formats = [item["format"] for item in requested_outputs]
|
||
if any(item not in FORMATS for item in formats):
|
||
raise ValueError("OUTPUT_FORMAT_UNSUPPORTED")
|
||
artifacts: list[dict[str, Any]] = []
|
||
if "opju" in formats:
|
||
project = output / "project.opju"
|
||
op.save(str(project))
|
||
_validate_artifact(project, "opju")
|
||
artifacts.append(_manifest(project, "application/x-origin-project"))
|
||
media = {"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"}
|
||
png_output = next((item for item in requested_outputs if item["format"] == "png"), None)
|
||
dpi = (png_output.get("options") or {}).get("dpi", 300) if png_output else 300
|
||
if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200:
|
||
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}"
|
||
_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:
|
||
originpro_version = "embedded"
|
||
provenance = {
|
||
"adapter_version": ADAPTER_VERSION,
|
||
"originpro_version": originpro_version,
|
||
"request_digest": request_record["request_digest"],
|
||
"inputs": [
|
||
{
|
||
"key": item["key"],
|
||
"filename": input_files[item["key"]].name,
|
||
"sha256": _file_sha256(input_files[item["key"]]),
|
||
}
|
||
for item in input_specs
|
||
],
|
||
"outputs": requested_outputs,
|
||
"requested_dpi": dpi if png_output else None,
|
||
"png_pixel_width": pixel_width if png_output else None,
|
||
"canvas_mm": plot_spec.get("canvas"),
|
||
}
|
||
plot_spec_path = output / "plot-spec.json"
|
||
provenance_path = output / "provenance.json"
|
||
_atomic_json(plot_spec_path, request)
|
||
_atomic_json(provenance_path, provenance)
|
||
artifacts.append(_manifest(plot_spec_path, "application/json"))
|
||
artifacts.append(_manifest(provenance_path, "application/json"))
|
||
return artifacts
|
||
finally:
|
||
if op.oext:
|
||
op.exit()
|
||
|
||
|
||
def main() -> int:
|
||
if sys.argv[1:] == ["--probe"]:
|
||
return _probe()
|
||
if len(sys.argv) != 2:
|
||
print("[ERR] Usage: worker.py <job-directory>", file=sys.stderr)
|
||
return 2
|
||
job_dir = Path(sys.argv[1])
|
||
request_record: dict[str, Any] = {}
|
||
try:
|
||
request_record = json.loads(
|
||
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
|
||
)
|
||
artifacts = run(job_dir)
|
||
terminal = {
|
||
"job_id": request_record["job_id"],
|
||
"lease_id": request_record["lease_id"],
|
||
"request_digest": request_record["request_digest"],
|
||
"status": "succeeded",
|
||
"error": {},
|
||
"artifact_manifest": artifacts,
|
||
"terminal_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
_atomic_json(job_dir / "artifacts.json", artifacts)
|
||
_atomic_json(job_dir / "terminal.json", terminal)
|
||
print("[OK] Origin job completed.")
|
||
return 0
|
||
except Exception as exception:
|
||
terminal = {
|
||
"job_id": request_record.get("job_id", ""),
|
||
"lease_id": request_record.get("lease_id", ""),
|
||
"request_digest": request_record.get("request_digest", ""),
|
||
"status": "failed",
|
||
"error": {"code": type(exception).__name__, "detail": str(exception)[:500]},
|
||
"artifact_manifest": [],
|
||
"terminal_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
_atomic_json(job_dir / "terminal.json", terminal)
|
||
print(f"[ERR] {type(exception).__name__}: {exception}", file=sys.stderr)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|