majoplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1006 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import WindowsPath, Path
4
+ from typing import Optional, Any, Union
5
+ _ColorLike = Union[str, tuple[int, int, int], list[int]]
6
+
7
+ import numpy as np
8
+ from win32com.client import Dispatch
9
+
10
+ from ...domain.base import *
11
+ from ...domain.muti_axes_spec import *
12
+ from .origin_utils.originlab_type_library import constants
13
+
14
+
15
+ # --- Unit/scale mapping ---
16
+ # Origin uses its own point-like units for font size, line width, and symbol size in LabTalk.
17
+ # FONT_PT_SCALE is a rough conversion factor used throughout style setters.
18
+ # INSET_SCALE / MAIN_SCALE are intended to scale styles between main vs inset layers,
19
+ # but note that curve_scale is computed later and is NOT used inside _apply_curve_style() in this file.
20
+ FONT_PT_SCALE = 2.3
21
+ INSET_SCALE = 2.5 # inset linewidth/marker/title a bit smaller
22
+ MAIN_SCALE = 1.5 # inset linewidth/marker/title a bit smaller
23
+ # -------------------------
24
+ # COM Resourse
25
+ # -------------------------
26
+ ## How to get help:
27
+ ## Run:
28
+ ## python -m win32com.client.makepy
29
+ ## choose "OriginLab Type Library"
30
+ ## Find APIs in generated py file. (maybe under C:\Users\user_name\AppData\Local\Temp\gen_py/)
31
+
32
+ ## tested with OriginPro 2021
33
+
34
+
35
+ # -------------------------
36
+ # Origin COM bootstrap (COM = Component Object Model)
37
+ # -------------------------
38
+ # OriginCOM is a context manager that creates/controls an Origin instance.
39
+ # Typical usage:
40
+ # with OriginCOM(visible=True) as og:
41
+ # plot(project, name, og, proj_dir=..., overwrite=...)
42
+ # Behavior notes:
43
+ # - __enter__ calls BeginSession() and returns the Application COM object.
44
+ # - __exit__ tries to Exit() Origin (closing the application).
45
+ # - If you need Origin to remain open for interactive debugging, modify *caller* behavior,
46
+ # not this class; this annotated file must keep logic unchanged.
47
+ class OriginCOM:
48
+ """
49
+ OriginCOM
50
+ create a ApplicationCMOSI instance of Origin. See https://www.originlab.com/doc/en/COM/Classes/ApplicationCOMSI.
51
+ """
52
+ def __init__(self,visible:bool):
53
+ self.visible = visible
54
+ app = None
55
+
56
+ def __enter__(self):
57
+ # Acquire COM automation entry point: Origin.ApplicationCOMSI
58
+ # Most actions below happen through LabTalk commands (og.Execute).
59
+ # Dispatch() attaches to Origin via COM.
60
+ self.app = Dispatch("Origin.ApplicationCOMSI")
61
+ if self.visible:
62
+ self.app.Visible = constants.MAINWND_SHOW
63
+ else:
64
+ self.app.Visible = constants.MAINWND_HIDE
65
+ # Reduce UI noise / command window chatter
66
+ # doc -mc 1: suppress some UI/command-window chatter during batch automation.
67
+ self.app.Execute("doc -mc 1;")
68
+ # BeginSession(): recommended by Origin for COM automation batches.
69
+ self.app.BeginSession()
70
+ return self.app
71
+
72
+ def __exit__(self, exc_type, exc, tb) -> None:
73
+ try:
74
+ self.app.Execute("doc -mc 0;")
75
+ except Exception:
76
+ pass
77
+ try:
78
+ if self.app is not None:
79
+ self.app.Exit()
80
+ except Exception:
81
+ pass
82
+ self.app = None
83
+
84
+ # =========================
85
+ # LabTalk helpers
86
+ # =========================
87
+
88
+
89
+ # -------------------------
90
+ # LabTalk command execution helper
91
+ # -------------------------
92
+ # This wrapper intentionally swallows failures after printing.
93
+ # Consequence: style commands can fail silently, leaving defaults in place,
94
+ # which may look like "random" styles. Consider temporarily making this stricter
95
+ # during debugging (but NOT in this annotated file).
96
+ def _try_exec(og: Any, lt: str) -> None:
97
+ """
98
+ execute Labtalk commands
99
+ """
100
+ try:
101
+ og.Execute(lt)
102
+ except Exception as e:
103
+ print(f"LabTalk command '{lt}' failed: {e}")
104
+ pass
105
+
106
+ # ==========================
107
+ # OPJU Manager
108
+ # ==========================
109
+
110
+ # -------------------------
111
+ # Project open/create (.opju)
112
+ # -------------------------
113
+ # This function enforces suffix and ensures the directory exists.
114
+ # It does NOT create folders/pages inside the project; it only manages the project file itself.
115
+ def open_or_create_project(
116
+ og,
117
+ project_path: Union[str, Path],
118
+ *,
119
+ readonly: bool = False,
120
+ ) -> None:
121
+ """
122
+ Open an existing Origin project, or create a new one and save to path.
123
+
124
+ Parameters
125
+ ----------
126
+ og : COM object
127
+ Origin.ApplicationCOMSI instance
128
+ project_path : str | Path
129
+ Full path to .opju or .opj
130
+ readonly : bool
131
+ Open project in read-only mode if exists
132
+ """
133
+ path = Path(project_path).expanduser().resolve()
134
+
135
+ # ---- sanity checks ----
136
+ if path.suffix.lower() not in {".opju", ".opj"}:
137
+ raise ValueError("Origin project must end with .opju or .opj")
138
+
139
+ # Ensure parent directory exists (for create case)
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+
142
+ # If project already exists: load it.
143
+ if path.exists():
144
+ # ===== open existing project =====
145
+ # Origin COM exposes Open() directly
146
+ og.Load(str(path), int(bool(readonly)))
147
+ else:
148
+ # ===== create new project =====
149
+ try:
150
+ og.NewProject()
151
+ except Exception:
152
+ # fallback (older builds / edge cases)
153
+ og.Execute("doc -n;")
154
+
155
+ # Save immediately to bind project path
156
+ og.Save(str(path))
157
+
158
+
159
+
160
+
161
+ # -------------------------
162
+ # LabTalk string escaping
163
+ # -------------------------
164
+ # Origin LabTalk uses "..." string literals. Backslashes and quotes need escaping.
165
+ def _lt_quote(s: str) -> str:
166
+ """Escape string for LabTalk "..." literal."""
167
+ return s.replace("\\", "\\\\").replace('"', '\\"')
168
+
169
+ def _lt_quote_keep_backslash(s: str) -> str:
170
+ # Only escape double quotes for LabTalk "...".
171
+ return s.replace('"', r'\"')
172
+
173
+
174
+
175
+ # Color helpers
176
+ # - _lt_color_expr outputs color(name) or color(r,g,b) for #RRGGBB
177
+ # - _lt_color_rgb always forces RGB form to avoid name-resolution surprises
178
+ def _lt_color_expr(color: str) -> str:
179
+ """Return LabTalk color() expression supporting '#RRGGBB' and names."""
180
+ c = (color or "").strip()
181
+ if c.startswith("#") and len(c) in (7, 9):
182
+ r = int(c[1:3], 16)
183
+ g = int(c[3:5], 16)
184
+ b = int(c[5:7], 16)
185
+ return f"color({r},{g},{b})"
186
+ if not c:
187
+ c = "black"
188
+ return f"color({c})"
189
+
190
+ def _lt_color_rgb(color: _ColorLike, *, fallback=(250, 179, 209)) -> str:
191
+ """
192
+ Convert a color literal to LabTalk-safe RGB expression: color(R,G,B).
193
+
194
+ Intended for Axis / Grid / Tick / Border colors in Origin.
195
+ Avoids silent fallback caused by color(name).
196
+
197
+ Supported inputs:
198
+ - color names: "grey", "gray", "black", "red", ...
199
+ - hex strings: "#RRGGBB", "#RGB"
200
+ - RGB tuple/list: (r, g, b)
201
+ - None -> fallback
202
+
203
+ Returns:
204
+ str: 'color(R,G,B)'
205
+ """
206
+ if color is None:
207
+ r, g, b = fallback
208
+ return f"color({r},{g},{b})"
209
+
210
+ # --- RGB tuple/list ---
211
+ if isinstance(color, (tuple, list)) and len(color) == 3:
212
+ r, g, b = (int(c) for c in color)
213
+ return f"color({r},{g},{b})"
214
+
215
+ if not isinstance(color, str):
216
+ r, g, b = fallback
217
+ return f"color({r},{g},{b})"
218
+
219
+ c = color.strip().lower()
220
+
221
+ # --- Hex formats ---
222
+ if c.startswith("#"):
223
+ h = c[1:]
224
+ if len(h) == 6:
225
+ r = int(h[0:2], 16)
226
+ g = int(h[2:4], 16)
227
+ b = int(h[4:6], 16)
228
+ return f"color({r},{g},{b})"
229
+ if len(h) == 3:
230
+ r = int(h[0] * 2, 16)
231
+ g = int(h[1] * 2, 16)
232
+ b = int(h[2] * 2, 16)
233
+ return f"color({r},{g},{b})"
234
+
235
+ # --- Named colors (minimal but sufficient for grids) ---
236
+ NAMED_RGB = {
237
+ "black": (0, 0, 0),
238
+ "white": (255, 255, 255),
239
+ "red": (255, 0, 0),
240
+ "green": (0, 128, 0),
241
+ "blue": (0, 0, 255),
242
+ "grey": (128, 128, 128),
243
+ "gray": (128, 128, 128),
244
+ "lightgrey": (200, 200, 200),
245
+ "lightgray": (200, 200, 200),
246
+ "darkgrey": (96, 96, 96),
247
+ "darkgray": (96, 96, 96),
248
+ "pinkie_pie": (250,179,209)
249
+ }
250
+
251
+ if c in NAMED_RGB:
252
+ r, g, b = NAMED_RGB[c]
253
+ return f"color({r},{g},{b})"
254
+
255
+ # --- Fallback ---
256
+ r, g, b = fallback
257
+ return f"color({r},{g},{b})"
258
+
259
+
260
+ # Origin plot style code maps
261
+ # These integers are consumed by `set %C -d <code>` or similar LabTalk.
262
+ _LINESTYLE_CODE = {
263
+ "-": 0, # solid
264
+ "--": 2, # dash
265
+ ":": 3, # dot
266
+ "-.": 4, # dash-dot
267
+ }
268
+ _GRID_LINESTYLE_CODE = {
269
+ "-": 1, # solid
270
+ "--": 2, # dash
271
+ ":": 3, # dot
272
+ "-.": 4, # dash-dot
273
+ }
274
+
275
+ _MARKER_CODE = {
276
+ "s": 1, # square
277
+ "o": 2, # circle
278
+ "^": 3, # up triangle
279
+ "v": 4, # down triangle
280
+ "D": 5, # diamond
281
+ "d": 5,
282
+ "+": 6, # plus
283
+ "x": 7, # x
284
+ "*": 8, # star
285
+ }
286
+
287
+
288
+
289
+ # IMPORTANT: plotxy plot code selection
290
+ # In this file, _choose_plot_code ALWAYS returns 202 (line+symbol).
291
+ # That means even if a series should be "line only" or "scatter only",
292
+ # Origin still creates a line+symbol plot, and later style setters try to disable/alter pieces.
293
+ # This can contribute to confusing marker/line behavior.
294
+ def _choose_plot_code(linestyle: str, marker: str) -> int:
295
+ """plotxy plot codes: 200=line, 201=scatter, 202=line+symbol"""
296
+ # has_line = bool(linestyle) and linestyle != "None"
297
+ # has_marker = bool(marker) and marker != "None"
298
+ # if has_line and has_marker:
299
+ # return 202
300
+ # if has_marker and not has_line:
301
+ # return 201
302
+ # return 200
303
+ return 202
304
+
305
+
306
+ def _graph_sanitize_name(name: str, fallback: str = "MajoGraph") -> str:
307
+ """
308
+ Origin Graph Page Short Name Limit: <= 24 Characters
309
+ """
310
+ if not name:
311
+ return fallback
312
+ out = []
313
+ for ch in name:
314
+ if ch.isalnum() or ch == "_":
315
+ out.append(ch)
316
+ else:
317
+ out.append("_")
318
+ s = "".join(out).strip("_")
319
+ if not s:
320
+ return fallback
321
+ if not s[0].isalpha():
322
+ s = "G_" + s
323
+ if len(s) > 24:
324
+ s = f"{s[:10]}__{s[-10:]}"
325
+ return s
326
+
327
+ def _workbook_sanitize_name(name: str, fallback: str = "MajoGraph") -> str:
328
+ """
329
+ Origin WorkBook Short Name Limit: <= 13 Characters
330
+ """
331
+ if not name:
332
+ return fallback
333
+ out = []
334
+ for ch in name:
335
+ if ch.isalnum():
336
+ out.append(ch)
337
+ s = "".join(out)
338
+ if not s:
339
+ return fallback
340
+ if not s[0].isalpha():
341
+ s = "W" + s
342
+ if len(s) > 13:
343
+ s = f"{s[:6]}{s[-6:]}"
344
+ return s
345
+
346
+ # =========================
347
+ # Figure-level style cycle
348
+ # =========================
349
+
350
+
351
+ # -------------------------
352
+ # Figure-level style cycle (shared across ALL layers)
353
+ # -------------------------
354
+ # This generator advances a single index i each time next() is called.
355
+ # In plot(), a single iterator is created per Figure and is consumed for every curve
356
+ # across all layers. There is NO per-axes reset here, even for StackAxesSpec.
357
+ def iter_cycles(figspec: FigureSpec) -> Iterator[dict[str, Any]]:
358
+ """Yield per-line styles. Each new line advances ALL cycles once."""
359
+ i = 0
360
+ while True:
361
+ yield {
362
+ "color": figspec.linecolor_cycle[i % len(figspec.linecolor_cycle)],
363
+ "linestyle": figspec.linestyle_cycle[i % len(figspec.linestyle_cycle)],
364
+ "marker": figspec.linemarker_cycle[i % len(figspec.linemarker_cycle)],
365
+ "alpha": figspec.alpa_cycle[i % len(figspec.alpa_cycle)],
366
+ }
367
+ i += 1
368
+
369
+
370
+ # =========================
371
+ # Spec application helpers
372
+ # =========================
373
+
374
+ # Log-scale heuristic
375
+ # If limits are positive AND span ratio exceeds threshold, enable log10 axis in Origin.
376
+ # Note: the code later writes layer.x.from/to and layer.y.from/to using numeric values;
377
+ # their interpretation depends on layer.x.type / layer.y.type (linear vs log).
378
+ def _should_set_log_scale(axes_obj: Axes) -> tuple[bool,bool]:
379
+ """Enable log scale when limits are positive and span exceeds thresholds."""
380
+ xl = axes_obj.xlim[0]
381
+ xr = axes_obj.xlim[1]
382
+ yl = axes_obj.ylim[0]
383
+ yr = axes_obj.ylim[1]
384
+ x_span_min = axes_obj.spec.x_log_scale_min_span
385
+ y_span_min = axes_obj.spec.y_log_scale_min_span
386
+
387
+ should_x, should_y = False, False
388
+ if xl > 0 and xr > 0 and (xr / xl) >= x_span_min:
389
+ should_x = True
390
+
391
+ if yl > 0 and yr > 0 and (yr / yl) >= y_span_min:
392
+ should_y = True
393
+ return should_x, should_y
394
+
395
+
396
+ def _apply_full_frame_axes(og: Any) -> None:
397
+ """Force full-frame axes (top/right show line & ticks) at UI/OPJU level."""
398
+ # Caller should have selected layer: layer -s N;
399
+ _try_exec(og, "layer.border=0;")
400
+
401
+ # Axis display: 0=None, 1=First, 2=Second, 3=Both
402
+ # For X: First=Bottom, Second=Top; For Y: First=Left, Second=Right
403
+ _try_exec(og, "axis -ps X A 3;") # show bottom+top axis line & ticks
404
+ _try_exec(og, "axis -ps Y A 3;") # show left+right axis line & ticks
405
+
406
+ # Tick label display (optional):
407
+ # Keep labels only on Bottom/Left (typical matplotlib look).
408
+ _try_exec(og, "axis -ps X L 1;")
409
+ _try_exec(og, "axis -ps Y L 1;")
410
+
411
+ # Keep secondary axes linked to primary if your version supports it
412
+ _try_exec(og, "layer.x2.link=1;")
413
+ _try_exec(og, "layer.y2.link=1;")
414
+
415
+
416
+
417
+
418
+ def _ticks_mask(direction: str, include_minor: bool) -> int:
419
+ # ticks bitmask: major in=1, major out=2, minor in=4, minor out=8
420
+ d = (direction or "in").lower()
421
+ if d == "in":
422
+ return (1 | (4 if include_minor else 0))
423
+ return (2 | (8 if include_minor else 0))
424
+
425
+
426
+
427
+ # Tick application (best-effort)
428
+ # Many tick/grid properties in Origin depend on version; failed commands fall back silently via _try_exec.
429
+ def _apply_tick_spec(og: Any, major: Optional[TickSpec], minor: Optional[TickSpec], global_fontsize: float, scale: float) -> None:
430
+ """Apply major/minor ticks (best-effort) to x/y axes."""
431
+ include_minor = minor is not None
432
+
433
+ # tick label fontsize (Origin uses layer.x.label.fsize etc)
434
+ _try_exec(og, f"layer.x.label.fsize={global_fontsize * FONT_PT_SCALE * scale};")
435
+ _try_exec(og, f"layer.y.label.fsize={global_fontsize * FONT_PT_SCALE * scale};")
436
+ _try_exec(og, f"layer.x2.label.fsize={global_fontsize * FONT_PT_SCALE *scale};")
437
+ _try_exec(og, f"layer.y2.label.fsize={global_fontsize * FONT_PT_SCALE * scale};")
438
+
439
+ # Direction
440
+ direction = major.direction if major is not None else ("in" if minor is None else minor.direction)
441
+ mask = _ticks_mask(direction, include_minor)
442
+ _try_exec(og, f"layer.x.ticks={mask}; layer.y.ticks={mask};")
443
+ _try_exec(og, f"layer.x2.ticks={mask}; layer.y2.ticks={mask};")
444
+
445
+ # Length/width
446
+ if major is not None:
447
+ _try_exec(og, f"layer.x.ticklength={major.length * scale}; layer.y.ticklength={major.length * scale};")
448
+ _try_exec(og, f"layer.x2.ticklength={major.length * scale}; layer.y2.ticklength={major.length * scale};")
449
+ _try_exec(og, f"layer.x.tickthickness={major.width}; layer.y.tickthickness={major.width};")
450
+ _try_exec(og, f"layer.x2.tickthickness={major.width}; layer.y2.tickthickness={major.width};")
451
+
452
+ # Which sides show ticks (best-effort)
453
+ if not major.top:
454
+ _try_exec(og, "layer.x2.show=0;")
455
+ if not major.left:
456
+ _try_exec(og, "layer.y.show=0;") # left Y axis; risky, but matches intent
457
+
458
+ if minor is not None:
459
+ _try_exec(og, f"layer.x.mticklength={minor.length * scale}; layer.y.mticklength={minor.length * scale};")
460
+ _try_exec(og, f"layer.x2.mticklength={minor.length * scale}; layer.y2.mticklength={minor.length * scale};")
461
+ _try_exec(og, f"layer.x.mtickthickness={minor.width}; layer.y.mtickthickness={minor.width};")
462
+ _try_exec(og, f"layer.x2.mtickthickness={minor.width}; layer.y2.mtickthickness={minor.width};")
463
+
464
+
465
+
466
+ # Grid application (best-effort)
467
+ # If these properties mismatch your Origin version, commands may fail and grids remain default/unchanged.
468
+ def _apply_grid_spec(og: Any, major: Optional[GridSpec], minor: Optional[GridSpec]) -> None:
469
+ """Apply major/minor grids (best-effort)."""
470
+ major_on = major is not None
471
+ minor_on = minor is not None
472
+
473
+ # Show flags: 0 none, 1 major, 2 minor, 3 both
474
+ show = (1 if major_on else 0) | (2 if minor_on else 0)
475
+
476
+ # Turn on/off grid display (documented)
477
+ _try_exec(og, f"layer.x.grid.show={show}; layer.y.grid.show={show};")
478
+
479
+ # Some scripts / versions also use showGrids; setting both is harmless and increases robustness
480
+ _try_exec(og, f"layer.x.showGrids={show}; layer.y.showGrids={show};")
481
+
482
+ # Must use RGB here.
483
+ if major is not None:
484
+ lt = _GRID_LINESTYLE_CODE.get(major.linestyle, 1)
485
+ # Use documented properties (width in points)
486
+ _try_exec(og, f"layer.x.grid.majorType={lt}; layer.y.grid.majorType={lt};")
487
+ _try_exec(og, f"layer.x.grid.majorWidth={float(major.linewidth)}; layer.y.grid.majorWidth={float(major.linewidth)};")
488
+ _try_exec(og, f"layer.x.grid.majorColor={_lt_color_rgb(major.color)}; layer.y.grid.majorColor={_lt_color_rgb(major.color)};")
489
+
490
+ if minor is not None:
491
+ lt = _GRID_LINESTYLE_CODE.get(minor.linestyle, 1)
492
+ _try_exec(og, f"layer.x.grid.minorType={lt}; layer.y.grid.minorType={lt};")
493
+ _try_exec(og, f"layer.x.grid.minorWidth={float(minor.linewidth)}; layer.y.grid.minorWidth={float(minor.linewidth)};")
494
+ _try_exec(og, f"layer.x.grid.minorColor={_lt_color_rgb(minor.color)}; layer.y.grid.minorColor={_lt_color_rgb(minor.color)};")
495
+
496
+ def _apply_annotations(og: Any, ann: Optional[list[AnnotationSpec]], scale: float) -> None:
497
+ if not ann:
498
+ return
499
+ for a in ann:
500
+ text = _lt_quote(a.text)
501
+ x, y = a.xy
502
+ _try_exec(og, f'label -a {float(x)} {float(y)} "{text}";')
503
+ _try_exec(og, f"label.fsize={a.fontsize * FONT_PT_SCALE * scale};")
504
+
505
+
506
+
507
+ # -------------------------
508
+ # AxesSpec -> Origin layer properties
509
+ # -------------------------
510
+ # Workflow: select layer -> enforce full-frame -> set titles -> set scale types -> set limits -> ticks/grids -> annotations
511
+ # Important detail: this is applied AFTER curves are plotted (in plot()).
512
+ def _apply_axes_spec(
513
+ og: Any,
514
+ layer_idx: int,
515
+ axes_obj: Axes,
516
+ *,
517
+ ignore_grid: bool,
518
+ figspec: FigureSpec,
519
+ x_is_log: bool,
520
+ y_is_log: bool,
521
+ scale: float,
522
+ force_show_titles: bool = False
523
+ ) -> None:
524
+ """Apply AxesSpec to selected layer."""
525
+ spec = axes_obj.spec
526
+ _try_exec(og, f"layer -s {layer_idx};")
527
+
528
+ _apply_full_frame_axes(og)
529
+
530
+ # Titles
531
+ if force_show_titles:
532
+ # Titles: use label -n to ensure special objects (XB/YL) are created for every layer
533
+ _try_exec(og, f'label -n XB "{_lt_quote(spec.x_axis_title)}";')
534
+ _try_exec(og, f'label -n YL "{_lt_quote(spec.y_axis_title)}";')
535
+
536
+ # After creation, setting properties via XB/YL works reliably
537
+ _try_exec(og, f"XB.fsize={spec.axis_title_font_size * FONT_PT_SCALE * 2 * scale};")
538
+ _try_exec(og, f"YL.fsize={spec.axis_title_font_size * FONT_PT_SCALE * 2 * scale};")
539
+ _try_exec(og, "YL.rotate=90;") # rotate Y axis title to 90 degrees
540
+
541
+ # Scale type: 0=linear, 2=log10
542
+ _try_exec(og, f"layer.x.type={2 if x_is_log else 0};")
543
+ _try_exec(og, f"layer.y.type={2 if y_is_log else 0};")
544
+
545
+ # Limits
546
+ # Disable auto scaling before setting from/to.
547
+ _try_exec(og,f"layer.x.auto=0")
548
+ _try_exec(og,f"layer.x.rescale_margin=5")
549
+ _try_exec(og,f"layer.y.auto=0")
550
+ _try_exec(og,f"layer.y.rescale_margin=5")
551
+ xl, xr = axes_obj.xlim
552
+ yl, yr = axes_obj.ylim
553
+ # Add 5% padding based on data-driven xlim/ylim if explicit limits are missing.
554
+ xl_margin = xl - (xr - xl) * 0.05
555
+ xr_margin = xr + (xr - xl) * 0.05
556
+ yl_margin = yl - (yr - yl) * 0.05
557
+ yr_margin = yr + (yr - yl) * 0.05
558
+ if spec.x_left_lim is not None:
559
+ _try_exec(og, f"layer.x.from={float(spec.x_left_lim)};")
560
+ else:
561
+ _try_exec(og, f"layer.x.from={float(xl_margin)};")
562
+ if spec.x_right_lim is not None:
563
+ _try_exec(og, f"layer.x.to={float(spec.x_right_lim)};")
564
+ else:
565
+ _try_exec(og, f"layer.x.to={float(xr_margin)};")
566
+
567
+ if spec.y_left_lim is not None:
568
+ _try_exec(og, f"layer.y.from={float(spec.y_left_lim)};")
569
+ else:
570
+ _try_exec(og, f"layer.y.from={float(yl_margin)};")
571
+ if spec.y_right_lim is not None:
572
+ _try_exec(og, f"layer.y.to={float(spec.y_right_lim)};")
573
+ else:
574
+ _try_exec(og, f"layer.y.to={float(yr_margin)};")
575
+
576
+ # Ticks & grids
577
+ _apply_tick_spec(og, spec.major_tick, spec.minor_tick, figspec.global_fontsize, scale)
578
+ if not ignore_grid:
579
+ _apply_grid_spec(og, spec.major_grid, spec.minor_grid)
580
+
581
+ # Annotations
582
+ _apply_annotations(og, spec.annotation, scale)
583
+
584
+
585
+ def _legend_anchor_xy(leg: LegendSpec) -> tuple[float, float]:
586
+ """Use explicit anchor_x/y if user set them; else map loc to a sane default."""
587
+ # In your LegendSpec, anchor defaults to (1,1). We'll interpret as "relative top-right".
588
+ if leg.anchor_x != 1 or leg.anchor_y != 1:
589
+ return float(leg.anchor_x) * 100.0, float(leg.anchor_y) * 100.0
590
+
591
+ loc = (leg.loc or "upper right").lower().replace("_", " ")
592
+ table = {
593
+ "upper right": (90.0, 90.0),
594
+ "upper left": (10.0, 90.0),
595
+ "lower left": (10.0, 10.0),
596
+ "lower right": (90.0, 10.0),
597
+ "center": (50.0, 50.0),
598
+ }
599
+ return table.get(loc, (90.0, 90.0))
600
+
601
+
602
+ def _apply_legend_text(og: Any, layer_idx: int, legend_spec: LegendSpec, legend_text: str, scale: float) -> None:
603
+ _try_exec(og, f"layer -s {layer_idx};")
604
+
605
+ # Ensure the legend object exists (template may not have one)
606
+ _try_exec(og, "legend;")
607
+ _try_exec(og, "legend -r;")
608
+
609
+ # Set legend text
610
+ _try_exec(og, f'string __py_lg$="{_lt_quote_keep_backslash(legend_text)}";')
611
+ _try_exec(og, "legend.text$=__py_lg$;")
612
+
613
+ # Font and frame
614
+ _try_exec(og, f"legend.fsize={legend_spec.fontsize * FONT_PT_SCALE * scale};")
615
+ _try_exec(og, f"legend.frame={1 if legend_spec.frameon else 0};")
616
+
617
+ # Place legend: interpret your (x,y) as percent of axis range -> convert to axis units
618
+ px, py = _legend_anchor_xy(legend_spec) # 0..100
619
+ fx = float(px) / 100.0
620
+ fy = float(py) / 100.0
621
+ _try_exec(
622
+ og,
623
+ f"double __x=layer.x.from+(layer.x.to-layer.x.from)*{fx:.8f};"
624
+ f"double __y=layer.y.from+(layer.y.to-layer.y.from)*{fy:.8f};"
625
+ "legend.x=__x; legend.y=__y;"
626
+ )
627
+
628
+ _try_exec(og, "legend.update=1;")
629
+
630
+
631
+
632
+ def _set_layer_position_percent(og: Any, layer_idx: int, width: float, height: float, xoff: float, yoff: float) -> None:
633
+ _try_exec(og, f"layer -s {layer_idx};")
634
+ _try_exec(og, f"layer {width:.4f} {height:.4f} {xoff:.4f} {yoff:.4f};")
635
+
636
+
637
+ def _apply_multi_axes_layout(og: Any, multi_spec: MutiAxesSpec, n_layers: int) -> None:
638
+ if isinstance(multi_spec, InsertAxesSpec) and n_layers >= 2:
639
+ main_w, main_h, main_x, main_y = 78.0, 72.0, 15.0, 15.0
640
+ _set_layer_position_percent(og, 1, main_w, main_h, main_x, main_y)
641
+
642
+ insert_w = main_w * float(multi_spec.width)
643
+ insert_h = main_h * float(multi_spec.height)
644
+ insert_x = main_x + main_w * float(multi_spec.x)
645
+ insert_y = main_y + main_h * float(multi_spec.y)
646
+ insert_y = 100 - (insert_y + insert_h)
647
+ _set_layer_position_percent(og, 2, insert_w, insert_h, insert_x, insert_y)
648
+
649
+ _try_exec(og, "layer -s 2; layer.link=0;")
650
+
651
+ if isinstance(multi_spec, StackAxesSpec):
652
+ # Grid layout: nrows x ncols
653
+ nrows = int(multi_spec.nrows)
654
+ ncols = int(multi_spec.ncols)
655
+
656
+ # Safety: if mismatched, do best-effort with min(n_layers, nrows*ncols)
657
+ total = nrows * ncols
658
+ use_layers = min(n_layers, total)
659
+
660
+ # Layout parameters in percent (tweak if you want tighter spacing)
661
+ left = 15.0
662
+ right = 10.0
663
+ bottom = 12.0
664
+ top = 10.0
665
+ hgap = 6.0 # horizontal gap between cells
666
+ vgap = 6.0 # vertical gap between cells
667
+
668
+ avail_w = 100.0 - left - right - (ncols - 1) * hgap
669
+ avail_h = 100.0 - bottom - top - (nrows - 1) * vgap
670
+ cell_w = avail_w / ncols
671
+ cell_h = avail_h / nrows
672
+
673
+ for idx in range(1, use_layers + 1):
674
+ k = idx - 1
675
+ r = k // ncols
676
+ c = k % ncols
677
+
678
+ # Origin layer positioning is easier to reason in "bottom-left" coordinates.
679
+ # We want row 0 at the top, so map it to highest yoff.
680
+ xoff = left + c * (cell_w + hgap)
681
+ yoff = bottom + r * (cell_h + vgap)
682
+
683
+
684
+ _set_layer_position_percent(og, idx, cell_w, cell_h, xoff, yoff)
685
+
686
+ # Optional: unlink axes among layers
687
+ for idx in range(1, use_layers + 1):
688
+ _try_exec(og, f"layer -s {idx}; layer.link=0;")
689
+
690
+ return
691
+
692
+
693
+
694
+ # -------------------------
695
+ # Curve styling via LabTalk `set`
696
+ # -------------------------
697
+ # Styles are applied to plot_ref (default "%C" = current plot).
698
+ # If Origin does not update %C to the newly-created plot after plotxy,
699
+ # style commands may apply to the wrong curve ("off-by-one" or cross-layer).
700
+ # That can produce complex, nontrivial mismatches (not just a simple swap).
701
+ def _apply_curve_style(og, axes_spec, cyc, *, plot_ref="%C") -> None:
702
+ color = str(cyc.get("color", "black"))
703
+ linestyle = str(cyc.get("linestyle", "-"))
704
+ marker = str(cyc.get("marker", "o"))
705
+ alpha = float(cyc.get("alpha", 1.0))
706
+
707
+ lw = float(axes_spec.linewidth) * FONT_PT_SCALE
708
+ ms = float(axes_spec.marker_size) * FONT_PT_SCALE
709
+
710
+ # Always apply base color/alpha first
711
+ _try_exec(og, f"set {plot_ref} -c {_lt_color_expr(color)};")
712
+ _try_exec(og, f"set {plot_ref} -t {int(alpha * 100)};")
713
+
714
+ if linestyle == "|":
715
+ # --- Special case: "stick" style implemented via Drop Lines ---
716
+ # 1) No connecting line (Connect: No Line)
717
+ _try_exec(og, f"set {plot_ref} -l 0;") # 0 = scatter/no line :contentReference[oaicite:4]{index=4}
718
+
719
+ # 2) Hide symbols (we only want droplines)
720
+ _try_exec(og, f"set {plot_ref} -z 0;") # symbol size -> 0 (your code uses -z for size)
721
+
722
+ # 3) Enable vertical drop lines
723
+ _try_exec(og, f"set {plot_ref} -lv 1;") # show vertical drop lines :contentReference[oaicite:5]{index=5}
724
+
725
+ # 4) Vertical drop line style: dashed
726
+ _try_exec(og, f"set {plot_ref} -lvs 1;") # 1 = dash :contentReference[oaicite:6]{index=6}
727
+
728
+ # 5) Vertical drop line width
729
+ _try_exec(og, f"set {plot_ref} -lvw {int(lw)};")
730
+
731
+ # 6) Optional: try to set dropline color by palette index (only works for simple named colors)
732
+ # Origin docs say -lvc follows palette index (1 black, 2 red, 3 green, 4 blue, ...). :contentReference[oaicite:8]{index=8}
733
+ _COLOR_INDEX = {"black": 1, "red": 2, "green": 3, "blue": 4, "white": 0}
734
+ ci = _COLOR_INDEX.get(color.strip().lower()) if isinstance(color, str) else None
735
+ if ci is not None:
736
+ _try_exec(og, f"set {plot_ref} -lvc {ci};")
737
+
738
+ return # IMPORTANT: do not fall through to normal line/marker logic
739
+
740
+ # --- Normal path (your existing logic) ---
741
+ _try_exec(og, f"set {plot_ref} -wp {lw};")
742
+ _try_exec(og, f"set {plot_ref} -z {ms};")
743
+
744
+ ls_code = _LINESTYLE_CODE.get(linestyle)
745
+ if ls_code is not None:
746
+ _try_exec(og, f"set {plot_ref} -d {int(ls_code)};")
747
+
748
+ mk_code = _MARKER_CODE.get(marker)
749
+ if mk_code is not None:
750
+ _try_exec(og, f"set {plot_ref} -k {int(mk_code)};")
751
+ else:
752
+ _try_exec(og, f"set {plot_ref} -z 0;")
753
+
754
+
755
+
756
+
757
+
758
+ def _save_project(og: Any, proj_name: str, opju_dir: str) -> None:
759
+ out_dir = WindowsPath(opju_dir)
760
+ out_dir.mkdir(parents=True, exist_ok=True)
761
+ opju_path = out_dir / f"{proj_name}.opju"
762
+ og.Save(str(opju_path.absolute()))
763
+
764
+
765
+
766
+ # -------------------------
767
+ # Legend text templates
768
+ # -------------------------
769
+ # Origin legend uses escape sequences like: \l(1) meaning "sample of plot #1".
770
+ # If plot order is changed (e.g., `layer -r`), the mapping from index -> curve changes.
771
+ # This is a high-priority suspect when legend labels and line samples appear mismatched.
772
+ def _legend_text_single_layer(labels: list[str]) -> str:
773
+ return "\n".join([f"\\l({i}) {lab}" for i, lab in enumerate(labels, start=1)])
774
+
775
+
776
+ def _legend_text_all_layers(layer_labels: list[list[str]]) -> str:
777
+ lines: list[str] = []
778
+ for L, labels in enumerate(layer_labels, start=1):
779
+ for P, lab in enumerate(labels, start=1):
780
+ lines.append(f"\\l({L}.{P}) {lab}")
781
+ return "\n".join(lines)
782
+
783
+ def _apply_layer_background_from_figure(
784
+ og: Any,
785
+ facecolor,
786
+ *,
787
+ layer_idx: int | None = None,
788
+ ) -> None:
789
+ """
790
+ Apply FigureSpec.facecolor to layer background and FORCE it opaque.
791
+ This works for both main layer and inset layers.
792
+ """
793
+ cexpr = _lt_color_rgb(facecolor, fallback=(255, 255, 255))
794
+
795
+ if layer_idx is None:
796
+ # Assume current layer is selected
797
+ _try_exec(og, "layer.fill=1;") # REQUIRED for inset
798
+ _try_exec(og, f"layer.color={cexpr};") # background color
799
+ _try_exec(og, "layer.transparency=0;") # 0 = opaque
800
+ else:
801
+ _try_exec(og, f"layer -s {layer_idx};")
802
+ _try_exec(og, f"layer{layer_idx}.fill=1;")
803
+ _try_exec(og, f"layer{layer_idx}.color={cexpr};")
804
+ _try_exec(og, f"layer{layer_idx}.transparency=0;")
805
+
806
+
807
+ # -------------------------
808
+ # Core plotting
809
+ # -------------------------
810
+
811
+ # =====================================================================
812
+ # Main pipeline: domain Project -> Origin workbook(s) + graph page(s)
813
+ # =====================================================================
814
+ # High-level runtime structure per Figure:
815
+ # 1) Ensure project opened/created
816
+ # 2) Create a workbook named after figure_name
817
+ # - One worksheet per Axes (layer)
818
+ # - Each Data contributes two columns (X then Y)
819
+ # 3) Create a graph page (template "Origin"), fallback via plotxy if CreatePage fails
820
+ # 4) Ensure layer count == number of Axes
821
+ # 5) Plot curves for each layer, consuming a SINGLE figure-level style cycle
822
+ # - After each layer, run `layer -r` (reverse plot order within the layer)
823
+ # 6) Apply AxesSpec formatting (titles/limits/log/ticks/grids/annotations)
824
+ # 7) Build legend text and write legend.text$
825
+ # 8) Save project if requested
826
+ #
827
+ # Debugging suspects for style chaos (non-exhaustive):
828
+ # - _choose_plot_code always returns 202 (line+symbol)
829
+ # - %C reference may not point to the intended curve when styling
830
+ # - `layer -r` changes plot indices used by legend \l(...)
831
+ # - CreatePage fallback may create an extra initial plot before the main loop plots again
832
+ # - curve_scale computed but not used inside _apply_curve_style
833
+ def plot(proj: Project, proj_name: str, og: Any, /, proj_dir: Optional[str] = None, overwrite=False) -> None:
834
+ """Plot FigureSpec into Origin and optionally save OPJU.
835
+ """
836
+ if overwrite:
837
+ # Start fresh
838
+ try:
839
+ og.NewProject()
840
+ except Exception:
841
+ _try_exec(og, "doc -n;")
842
+ else:
843
+ open_or_create_project(og, Path(proj_dir) / f"{proj_name}.opju")
844
+
845
+ for folder_name, folder in proj.items():
846
+ for figure_name, figure in folder.items():
847
+ _try_exec(og, "pe_cd /")
848
+ _try_exec(og, f"pe_mkdir {folder_name} chk:=1 cd:=1")
849
+
850
+ axes_pool = figure.axes_pool
851
+ figspec: FigureSpec = figure.spec
852
+ multi_spec: MutiAxesSpec = figure.muti_axes_spec
853
+
854
+ if not axes_pool:
855
+ break
856
+
857
+ # ===== Workbook + data =====
858
+ # A New workbook
859
+ wb = og.WorksheetPages.Add("Origin")
860
+ wb.Name = _workbook_sanitize_name(figure_name)
861
+ og.Execute(f'page.longname$ = "{figure_name}";')
862
+ # create worksheets
863
+ wks_layer_names = [wb.Layers(0).Name]
864
+ for _ in range(len(axes_pool)-1):
865
+ wks_layer_names.append(wb.Layers.Add().Name)
866
+ wks_pool = [f"[{wb.Name}]{name}" for name in wks_layer_names]
867
+ # Put datas into worksheets, set axis titles(longnames) and legends(comments).
868
+ for wks_name,axes in zip(wks_pool, axes_pool):
869
+ datas = axes.data_pool
870
+ wks = og.FindWorksheet(wks_name)
871
+ for i,data in enumerate(datas):
872
+ og.PutWorksheet(wks_name,data.points_for_plot[:,0:2].tolist(),0,-1) # 0:first_row -1:append_col
873
+ id1 = i * 2
874
+ id2 = id1 + 1
875
+ wks.Columns(id1).Type = constants.COLTYPE_X
876
+ wks.Columns(id2).Type = constants.COLTYPE_Y
877
+
878
+ # --- Robust column long names: data.headers may be missing/short ---
879
+ headers = list(getattr(data, "headers", []) or [])
880
+ # Prefer user-provided headers; otherwise fallback to deterministic defaults.
881
+ x_name = headers[0] if len(headers) >= 1 and headers[0] else f"X{i+1}"
882
+ y_name = headers[1] if len(headers) >= 2 and headers[1] else f"Y{i+1}"
883
+ wks.Columns(id1).LongName = x_name
884
+ wks.Columns(id2).LongName = y_name
885
+ wks.Columns(id2).Comments = data.labels.brief_summary
886
+
887
+
888
+
889
+ # ===== Create graph page =====
890
+ graph_name = _graph_sanitize_name(figure_name)
891
+ try:
892
+ gr_page = str(og.CreatePage(constants.OPT_GRAPH, graph_name, "Origin"))
893
+ og.Execute(f'page.longname$ = "{figure_name}";')
894
+ except Exception:
895
+ # FALLBACK PATH: create graph via plotxy <new ...>.
896
+ # IMPORTANT: this may create the first plot immediately.
897
+ # Later, the main plotting loop may plot the same series again,
898
+ # which can duplicate curves and scramble plot indices/styles.
899
+ # fallback: create via plotxy new
900
+ first_sheet = wks_pool[0]
901
+ cyc0 = next(iter_cycles(figspec))
902
+ code0 = _choose_plot_code(str(cyc0["linestyle"]), str(cyc0["marker"]))
903
+ _try_exec(og, f'plotxy iy:=({first_sheet}!col(1),col(2)) plot:={code0} ogl:=<new name:={graph_name}>;')
904
+ try:
905
+ gr_page = str(og.GetLTStr("%H")).strip()
906
+ except Exception:
907
+ gr_page = graph_name
908
+
909
+ _try_exec(og, f'win -a "{_lt_quote(gr_page)}";')
910
+
911
+ # Ensure layer count
912
+ for _ in range(2, len(axes_pool) + 1):
913
+ _try_exec(og, "layer -n;")
914
+
915
+ # Muti Layout if needed
916
+ _apply_multi_axes_layout(og, multi_spec, len(axes_pool))
917
+
918
+ # ===== Plot curves with figure-level cycles =====
919
+ global_cycle_iter = iter_cycles(figspec)
920
+
921
+ for layer_idx, (axes, sheet_name) in enumerate(zip(axes_pool, wks_pool), start=1):
922
+ _try_exec(og, f"layer -s {layer_idx};")
923
+ # for stack, each layer has its own cycle
924
+ if isinstance(multi_spec, StackAxesSpec):
925
+ cycle_iter = iter_cycles(figspec) # reset per layer
926
+ else:
927
+ cycle_iter = global_cycle_iter # share across figure
928
+
929
+ is_inset = isinstance(multi_spec, InsertAxesSpec) and layer_idx == 2
930
+ curve_scale = INSET_SCALE if is_inset else MAIN_SCALE
931
+
932
+ for j, data in enumerate(axes.data_pool):
933
+ cyc = next(cycle_iter)
934
+ code = _choose_plot_code(str(cyc["linestyle"]), str(cyc["marker"]))
935
+
936
+ xcol = j * 2 + 1
937
+ ycol = xcol + 1
938
+
939
+ _try_exec(
940
+ og,
941
+ f"plotxy iy:=({sheet_name}!col({xcol}),{sheet_name}!col({ycol})) "
942
+ f"plot:={code} ogl:={layer_idx} rescale:=0 legend:=0;"
943
+ )
944
+ _try_exec(og, f"layer -s {layer_idx}; layer -c;") # count plots in active layer -> count, %Z :contentReference[oaicite:2]{index=2}
945
+ _try_exec(og, "layer.plot = count;") # set active plot index to the last one :contentReference[oaicite:3]{index=3}
946
+ _apply_curve_style(og, axes.spec, cyc, plot_ref="%C")
947
+ _try_exec(og, f"layer -s {layer_idx};")
948
+
949
+ # ===== Axes formatting =====
950
+ for layer_idx, axes in enumerate(axes_pool, start=1):
951
+ is_inset = isinstance(multi_spec, InsertAxesSpec) and layer_idx == 2
952
+ scale = INSET_SCALE if is_inset else 1.0
953
+
954
+ x_is_log, y_is_log = _should_set_log_scale(axes)
955
+
956
+ if isinstance(figure.muti_axes_spec, StackAxesSpec):
957
+ force_show_titles = True
958
+ else:
959
+ force_show_titles = False
960
+
961
+ _apply_axes_spec(
962
+ og,
963
+ layer_idx,
964
+ axes,
965
+ figspec=figspec,
966
+ ignore_grid=is_inset,
967
+ x_is_log=x_is_log,
968
+ y_is_log=y_is_log,
969
+ scale=scale,
970
+ force_show_titles=force_show_titles
971
+ )
972
+
973
+ # If no explicit limits, rescale
974
+ # NOTE: This condition uses OR across the four limits.
975
+ # If ANY one limit is None, Rescale runs and may override from/to values set earlier.
976
+ if axes.spec.x_left_lim is None or axes.spec.x_right_lim is None or axes.spec.y_left_lim is None or axes.spec.y_right_lim is None:
977
+ _try_exec(og, f"layer -s {layer_idx}; Rescale;")
978
+
979
+ if figspec.facecolor is not None:
980
+ _try_exec(og, f"layer -s {layer_idx};")
981
+ _apply_layer_background_from_figure(og, figspec.facecolor)
982
+
983
+ # ===== Legends =====
984
+ # Build labels
985
+ per_layer_labels: list[list[str]] = []
986
+ for axes in axes_pool:
987
+ per_layer_labels.append([d.labels.brief_summary for d in axes.data_pool])
988
+
989
+ if isinstance(multi_spec, InsertAxesSpec) and getattr(multi_spec, "legend_holder", "") == "last axes":
990
+ holder_idx = len(axes_pool)
991
+ legend_spec = axes_pool[-1].spec.legend or figspec.legend
992
+ if legend_spec is not None:
993
+ txt = _legend_text_all_layers(per_layer_labels)
994
+ _apply_legend_text(og, holder_idx, legend_spec, txt, scale=INSET_SCALE if holder_idx == 2 else MAIN_SCALE)
995
+ for i in range(1, holder_idx):
996
+ _try_exec(og, f"layer -s {i}; legend.text$=\"\";")
997
+ else:
998
+ for layer_idx, axes in enumerate(axes_pool, start=1):
999
+ legend_spec = axes.spec.legend or figspec.legend
1000
+ if legend_spec is None:
1001
+ continue
1002
+ txt = _legend_text_single_layer(per_layer_labels[layer_idx - 1])
1003
+ _apply_legend_text(og, layer_idx, legend_spec, txt, scale=MAIN_SCALE)
1004
+
1005
+ if proj_dir and proj_name:
1006
+ _save_project(og, proj_name, proj_dir)