scitex 2.4.2__py3-none-any.whl → 2.5.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.
Files changed (64) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/browser/__init__.py +53 -0
  3. scitex/browser/debugging/__init__.py +56 -0
  4. scitex/browser/debugging/_failure_capture.py +372 -0
  5. scitex/browser/debugging/_sync_session.py +259 -0
  6. scitex/browser/debugging/_test_monitor.py +284 -0
  7. scitex/browser/debugging/_visual_cursor.py +432 -0
  8. scitex/io/_load.py +5 -0
  9. scitex/io/_load_modules/_canvas.py +171 -0
  10. scitex/io/_save.py +8 -0
  11. scitex/io/_save_modules/_canvas.py +356 -0
  12. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +77 -22
  13. scitex/plt/docs/FIGURE_ARCHITECTURE.md +257 -0
  14. scitex/plt/utils/__init__.py +10 -0
  15. scitex/plt/utils/_collect_figure_metadata.py +14 -12
  16. scitex/plt/utils/_csv_column_naming.py +237 -0
  17. scitex/scholar/citation_graph/database.py +9 -2
  18. scitex/scholar/config/ScholarConfig.py +23 -3
  19. scitex/scholar/config/default.yaml +55 -0
  20. scitex/scholar/core/Paper.py +102 -0
  21. scitex/scholar/core/__init__.py +44 -0
  22. scitex/scholar/core/journal_normalizer.py +524 -0
  23. scitex/scholar/core/oa_cache.py +285 -0
  24. scitex/scholar/core/open_access.py +457 -0
  25. scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
  26. scitex/scholar/pdf_download/strategies/__init__.py +6 -0
  27. scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
  28. scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +18 -3
  29. scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +15 -2
  30. scitex/session/_decorator.py +13 -1
  31. scitex/vis/README.md +246 -615
  32. scitex/vis/__init__.py +138 -78
  33. scitex/vis/canvas.py +423 -0
  34. scitex/vis/docs/CANVAS_ARCHITECTURE.md +307 -0
  35. scitex/vis/editor/__init__.py +1 -1
  36. scitex/vis/editor/_dearpygui_editor.py +1830 -0
  37. scitex/vis/editor/_defaults.py +40 -1
  38. scitex/vis/editor/_edit.py +54 -18
  39. scitex/vis/editor/_flask_editor.py +37 -0
  40. scitex/vis/editor/_qt_editor.py +865 -0
  41. scitex/vis/editor/flask_editor/__init__.py +21 -0
  42. scitex/vis/editor/flask_editor/bbox.py +216 -0
  43. scitex/vis/editor/flask_editor/core.py +152 -0
  44. scitex/vis/editor/flask_editor/plotter.py +130 -0
  45. scitex/vis/editor/flask_editor/renderer.py +184 -0
  46. scitex/vis/editor/flask_editor/templates/__init__.py +33 -0
  47. scitex/vis/editor/flask_editor/templates/html.py +295 -0
  48. scitex/vis/editor/flask_editor/templates/scripts.py +614 -0
  49. scitex/vis/editor/flask_editor/templates/styles.py +549 -0
  50. scitex/vis/editor/flask_editor/utils.py +81 -0
  51. scitex/vis/io/__init__.py +84 -21
  52. scitex/vis/io/canvas.py +226 -0
  53. scitex/vis/io/data.py +204 -0
  54. scitex/vis/io/directory.py +202 -0
  55. scitex/vis/io/export.py +460 -0
  56. scitex/vis/io/panel.py +424 -0
  57. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/METADATA +9 -2
  58. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/RECORD +61 -32
  59. scitex/vis/DJANGO_INTEGRATION.md +0 -677
  60. scitex/vis/editor/_web_editor.py +0 -1440
  61. scitex/vis/tmp.txt +0 -239
  62. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/WHEEL +0 -0
  63. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/entry_points.txt +0 -0
  64. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/browser/debugging/_visual_cursor.py
5
+
6
+ """
7
+ Visual cursor and click effects for E2E test feedback.
8
+
9
+ Provides visual feedback during browser automation:
10
+ - Visual cursor indicator that follows mouse movements
11
+ - Click ripple effects
12
+ - Drag state visualization
13
+ - Step progress messages
14
+
15
+ Works with both async and sync Playwright APIs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Union
21
+
22
+ if TYPE_CHECKING:
23
+ from playwright.async_api import Page as AsyncPage
24
+ from playwright.sync_api import Page as SyncPage
25
+
26
+ # CSS styles for visual effects
27
+ VISUAL_EFFECTS_CSS = """
28
+ /* Visual cursor indicator */
29
+ #_scitex_cursor {
30
+ position: fixed;
31
+ width: 24px;
32
+ height: 24px;
33
+ border: 3px solid #FF4444;
34
+ border-radius: 50%;
35
+ pointer-events: none;
36
+ z-index: 2147483647;
37
+ transform: translate(-50%, -50%);
38
+ transition: all 0.15s ease-out;
39
+ box-shadow: 0 0 15px rgba(255, 68, 68, 0.6);
40
+ display: none;
41
+ }
42
+ #_scitex_cursor.clicking {
43
+ transform: translate(-50%, -50%) scale(0.6);
44
+ background: rgba(255, 68, 68, 0.4);
45
+ box-shadow: 0 0 25px rgba(255, 68, 68, 0.8);
46
+ }
47
+ #_scitex_cursor.dragging {
48
+ border-color: #28A745;
49
+ box-shadow: 0 0 15px rgba(40, 167, 69, 0.6);
50
+ width: 28px;
51
+ height: 28px;
52
+ }
53
+
54
+ /* Click ripple effect */
55
+ .scitex-click-ripple {
56
+ position: fixed;
57
+ border-radius: 50%;
58
+ border: 3px solid #FF4444;
59
+ pointer-events: none;
60
+ z-index: 2147483646;
61
+ animation: clickRipple 0.5s ease-out forwards;
62
+ }
63
+ @keyframes clickRipple {
64
+ 0% { width: 0; height: 0; opacity: 1; transform: translate(-50%, -50%); }
65
+ 100% { width: 80px; height: 80px; opacity: 0; transform: translate(-50%, -50%); }
66
+ }
67
+
68
+ /* Step message container */
69
+ #_scitex_step_messages {
70
+ position: fixed;
71
+ top: 10px;
72
+ left: 10px;
73
+ z-index: 2147483647;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 8px;
77
+ max-width: 600px;
78
+ pointer-events: none;
79
+ }
80
+ .scitex-step-msg {
81
+ background: rgba(0, 0, 0, 0.9);
82
+ color: white;
83
+ padding: 14px 24px;
84
+ border-radius: 8px;
85
+ font-size: 16px;
86
+ font-family: 'Courier New', monospace;
87
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
88
+ word-wrap: break-word;
89
+ animation: stepSlideIn 0.3s ease-out;
90
+ }
91
+ @keyframes stepSlideIn {
92
+ 0% { opacity: 0; transform: translateX(-20px); }
93
+ 100% { opacity: 1; transform: translateX(0); }
94
+ }
95
+
96
+ /* Test result banner */
97
+ #_scitex_result_banner {
98
+ position: fixed;
99
+ top: 50%;
100
+ left: 50%;
101
+ transform: translate(-50%, -50%);
102
+ padding: 40px 80px;
103
+ border-radius: 16px;
104
+ font-size: 48px;
105
+ font-weight: bold;
106
+ font-family: 'Arial', sans-serif;
107
+ z-index: 2147483647;
108
+ pointer-events: none;
109
+ animation: resultPulse 0.5s ease-out;
110
+ }
111
+ #_scitex_result_banner.success {
112
+ background: rgba(40, 167, 69, 0.95);
113
+ color: white;
114
+ box-shadow: 0 0 50px rgba(40, 167, 69, 0.8);
115
+ }
116
+ #_scitex_result_banner.failure {
117
+ background: rgba(220, 53, 69, 0.95);
118
+ color: white;
119
+ box-shadow: 0 0 50px rgba(220, 53, 69, 0.8);
120
+ }
121
+ @keyframes resultPulse {
122
+ 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0; }
123
+ 50% { transform: translate(-50%, -50%) scale(1.1); }
124
+ 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
125
+ }
126
+ """
127
+
128
+ # JavaScript to inject visual effects
129
+ INJECT_EFFECTS_JS = f"""
130
+ () => {{
131
+ if (document.getElementById('_scitex_visual_effects')) return;
132
+
133
+ const style = document.createElement('style');
134
+ style.id = '_scitex_visual_effects';
135
+ style.textContent = `{VISUAL_EFFECTS_CSS}`;
136
+ document.head.appendChild(style);
137
+
138
+ // Create cursor element
139
+ const cursor = document.createElement('div');
140
+ cursor.id = '_scitex_cursor';
141
+ document.body.appendChild(cursor);
142
+
143
+ // Create step messages container
144
+ const msgContainer = document.createElement('div');
145
+ msgContainer.id = '_scitex_step_messages';
146
+ document.body.appendChild(msgContainer);
147
+ }}
148
+ """
149
+
150
+
151
+ def inject_visual_effects(page: Union["AsyncPage", "SyncPage"]) -> None:
152
+ """Inject CSS and elements for visual effects (sync version)."""
153
+ page.evaluate(INJECT_EFFECTS_JS)
154
+
155
+
156
+ async def inject_visual_effects_async(page: "AsyncPage") -> None:
157
+ """Inject CSS and elements for visual effects (async version)."""
158
+ await page.evaluate(INJECT_EFFECTS_JS)
159
+
160
+
161
+ def show_cursor_at(
162
+ page: Union["AsyncPage", "SyncPage"],
163
+ x: float,
164
+ y: float,
165
+ state: str = "normal"
166
+ ) -> None:
167
+ """Move visual cursor to position (sync version).
168
+
169
+ Args:
170
+ page: Playwright page object
171
+ x: X coordinate
172
+ y: Y coordinate
173
+ state: Cursor state - "normal", "clicking", or "dragging"
174
+ """
175
+ page.evaluate("""
176
+ ([x, y, state]) => {
177
+ let cursor = document.getElementById('_scitex_cursor');
178
+ if (!cursor) {
179
+ cursor = document.createElement('div');
180
+ cursor.id = '_scitex_cursor';
181
+ document.body.appendChild(cursor);
182
+ }
183
+ cursor.style.display = 'block';
184
+ cursor.style.left = x + 'px';
185
+ cursor.style.top = y + 'px';
186
+ cursor.className = state === 'clicking' ? 'clicking' :
187
+ state === 'dragging' ? 'dragging' : '';
188
+ if (state === 'dragging') {
189
+ cursor.style.borderColor = '#28A745';
190
+ cursor.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.6)';
191
+ } else {
192
+ cursor.style.borderColor = '#FF4444';
193
+ cursor.style.boxShadow = '0 0 15px rgba(255, 68, 68, 0.6)';
194
+ }
195
+ }
196
+ """, [x, y, state])
197
+
198
+
199
+ async def show_cursor_at_async(
200
+ page: "AsyncPage",
201
+ x: float,
202
+ y: float,
203
+ state: str = "normal"
204
+ ) -> None:
205
+ """Move visual cursor to position (async version)."""
206
+ await page.evaluate("""
207
+ ([x, y, state]) => {
208
+ let cursor = document.getElementById('_scitex_cursor');
209
+ if (!cursor) {
210
+ cursor = document.createElement('div');
211
+ cursor.id = '_scitex_cursor';
212
+ document.body.appendChild(cursor);
213
+ }
214
+ cursor.style.display = 'block';
215
+ cursor.style.left = x + 'px';
216
+ cursor.style.top = y + 'px';
217
+ cursor.className = state === 'clicking' ? 'clicking' :
218
+ state === 'dragging' ? 'dragging' : '';
219
+ if (state === 'dragging') {
220
+ cursor.style.borderColor = '#28A745';
221
+ cursor.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.6)';
222
+ } else {
223
+ cursor.style.borderColor = '#FF4444';
224
+ cursor.style.boxShadow = '0 0 15px rgba(255, 68, 68, 0.6)';
225
+ }
226
+ }
227
+ """, [x, y, state])
228
+
229
+
230
+ def show_click_effect(page: Union["AsyncPage", "SyncPage"], x: float, y: float) -> None:
231
+ """Show click ripple effect at position (sync version)."""
232
+ page.evaluate("""
233
+ ([x, y]) => {
234
+ const ripple = document.createElement('div');
235
+ ripple.className = 'scitex-click-ripple';
236
+ ripple.style.left = x + 'px';
237
+ ripple.style.top = y + 'px';
238
+ document.body.appendChild(ripple);
239
+ setTimeout(() => ripple.remove(), 600);
240
+
241
+ const cursor = document.getElementById('_scitex_cursor');
242
+ if (cursor) {
243
+ cursor.classList.add('clicking');
244
+ setTimeout(() => cursor.classList.remove('clicking'), 150);
245
+ }
246
+ }
247
+ """, [x, y])
248
+
249
+
250
+ async def show_click_effect_async(page: "AsyncPage", x: float, y: float) -> None:
251
+ """Show click ripple effect at position (async version)."""
252
+ await page.evaluate("""
253
+ ([x, y]) => {
254
+ const ripple = document.createElement('div');
255
+ ripple.className = 'scitex-click-ripple';
256
+ ripple.style.left = x + 'px';
257
+ ripple.style.top = y + 'px';
258
+ document.body.appendChild(ripple);
259
+ setTimeout(() => ripple.remove(), 600);
260
+
261
+ const cursor = document.getElementById('_scitex_cursor');
262
+ if (cursor) {
263
+ cursor.classList.add('clicking');
264
+ setTimeout(() => cursor.classList.remove('clicking'), 150);
265
+ }
266
+ }
267
+ """, [x, y])
268
+
269
+
270
+ def show_step(
271
+ page: Union["AsyncPage", "SyncPage"],
272
+ step: int,
273
+ total: int,
274
+ message: str,
275
+ level: str = "info"
276
+ ) -> None:
277
+ """Show numbered step message in browser (sync version).
278
+
279
+ Args:
280
+ page: Playwright page object
281
+ step: Current step number
282
+ total: Total number of steps
283
+ message: Message to display
284
+ level: Message level - "info", "success", "warning", or "error"
285
+ """
286
+ color_map = {
287
+ "info": "#17A2B8",
288
+ "success": "#28A745",
289
+ "warning": "#FFC107",
290
+ "error": "#DC3545",
291
+ }
292
+ color = color_map.get(level, color_map["info"])
293
+
294
+ page.evaluate("""
295
+ ([step, total, message, color]) => {
296
+ let container = document.getElementById('_scitex_step_messages');
297
+ if (!container) {
298
+ container = document.createElement('div');
299
+ container.id = '_scitex_step_messages';
300
+ container.style.cssText = `
301
+ position: fixed; top: 10px; left: 10px; z-index: 2147483647;
302
+ display: flex; flex-direction: column; gap: 8px;
303
+ max-width: 600px; pointer-events: none;
304
+ `;
305
+ document.body.appendChild(container);
306
+ }
307
+ const popup = document.createElement('div');
308
+ popup.className = 'scitex-step-msg';
309
+ popup.innerHTML = `<strong>[${step}/${total}] ${message}</strong>`;
310
+ popup.style.cssText = `
311
+ background: rgba(0, 0, 0, 0.9); color: white;
312
+ padding: 14px 24px; border-radius: 8px; font-size: 16px;
313
+ font-family: 'Courier New', monospace;
314
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
315
+ border-left: 6px solid ${color}; word-wrap: break-word;
316
+ `;
317
+ container.appendChild(popup);
318
+ while (container.children.length > 5) container.removeChild(container.firstChild);
319
+ setTimeout(() => { if (popup.parentNode) popup.parentNode.removeChild(popup); }, 8000);
320
+ }
321
+ """, [step, total, message, color])
322
+ page.wait_for_timeout(200)
323
+
324
+
325
+ async def show_step_async(
326
+ page: "AsyncPage",
327
+ step: int,
328
+ total: int,
329
+ message: str,
330
+ level: str = "info"
331
+ ) -> None:
332
+ """Show numbered step message in browser (async version)."""
333
+ color_map = {
334
+ "info": "#17A2B8",
335
+ "success": "#28A745",
336
+ "warning": "#FFC107",
337
+ "error": "#DC3545",
338
+ }
339
+ color = color_map.get(level, color_map["info"])
340
+
341
+ await page.evaluate("""
342
+ ([step, total, message, color]) => {
343
+ let container = document.getElementById('_scitex_step_messages');
344
+ if (!container) {
345
+ container = document.createElement('div');
346
+ container.id = '_scitex_step_messages';
347
+ container.style.cssText = `
348
+ position: fixed; top: 10px; left: 10px; z-index: 2147483647;
349
+ display: flex; flex-direction: column; gap: 8px;
350
+ max-width: 600px; pointer-events: none;
351
+ `;
352
+ document.body.appendChild(container);
353
+ }
354
+ const popup = document.createElement('div');
355
+ popup.className = 'scitex-step-msg';
356
+ popup.innerHTML = `<strong>[${step}/${total}] ${message}</strong>`;
357
+ popup.style.cssText = `
358
+ background: rgba(0, 0, 0, 0.9); color: white;
359
+ padding: 14px 24px; border-radius: 8px; font-size: 16px;
360
+ font-family: 'Courier New', monospace;
361
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
362
+ border-left: 6px solid ${color}; word-wrap: break-word;
363
+ `;
364
+ container.appendChild(popup);
365
+ while (container.children.length > 5) container.removeChild(container.firstChild);
366
+ setTimeout(() => { if (popup.parentNode) popup.parentNode.removeChild(popup); }, 8000);
367
+ }
368
+ """, [step, total, message, color])
369
+ await page.wait_for_timeout(200)
370
+
371
+
372
+ def show_test_result(
373
+ page: Union["AsyncPage", "SyncPage"],
374
+ success: bool,
375
+ message: str = "",
376
+ delay_ms: int = 3000
377
+ ) -> None:
378
+ """Show test result banner (PASS/FAIL) and wait (sync version).
379
+
380
+ Args:
381
+ page: Playwright page object
382
+ success: True for PASS, False for FAIL
383
+ message: Optional message to display
384
+ delay_ms: How long to display before continuing
385
+ """
386
+ status = "PASS" if success else "FAIL"
387
+ css_class = "success" if success else "failure"
388
+ display_text = f"{status}" + (f": {message}" if message else "")
389
+
390
+ page.evaluate("""
391
+ ([displayText, cssClass]) => {
392
+ // Remove existing banner
393
+ const existing = document.getElementById('_scitex_result_banner');
394
+ if (existing) existing.remove();
395
+
396
+ const banner = document.createElement('div');
397
+ banner.id = '_scitex_result_banner';
398
+ banner.className = cssClass;
399
+ banner.textContent = displayText;
400
+ document.body.appendChild(banner);
401
+ }
402
+ """, [display_text, css_class])
403
+ page.wait_for_timeout(delay_ms)
404
+
405
+
406
+ async def show_test_result_async(
407
+ page: "AsyncPage",
408
+ success: bool,
409
+ message: str = "",
410
+ delay_ms: int = 3000
411
+ ) -> None:
412
+ """Show test result banner (PASS/FAIL) and wait (async version)."""
413
+ status = "PASS" if success else "FAIL"
414
+ css_class = "success" if success else "failure"
415
+ display_text = f"{status}" + (f": {message}" if message else "")
416
+
417
+ await page.evaluate("""
418
+ ([displayText, cssClass]) => {
419
+ const existing = document.getElementById('_scitex_result_banner');
420
+ if (existing) existing.remove();
421
+
422
+ const banner = document.createElement('div');
423
+ banner.id = '_scitex_result_banner';
424
+ banner.className = cssClass;
425
+ banner.textContent = displayText;
426
+ document.body.appendChild(banner);
427
+ }
428
+ """, [display_text, css_class])
429
+ await page.wait_for_timeout(delay_ms)
430
+
431
+
432
+ # EOF
scitex/io/_load.py CHANGED
@@ -44,6 +44,7 @@ from ._load_modules._txt import _load_txt
44
44
  from ._load_modules._xml import _load_xml
45
45
  from ._load_modules._yaml import _load_yaml
46
46
  from ._load_modules._zarr import _load_zarr
47
+ from ._load_modules._canvas import _load_canvas
47
48
 
48
49
 
49
50
  def load(
@@ -151,6 +152,10 @@ def load(
151
152
  if verbose:
152
153
  print(f"[DEBUG] After Path conversion: {lpath}")
153
154
 
155
+ # Handle .canvas directories (special case - directory not file)
156
+ if lpath.endswith(".canvas"):
157
+ return _load_canvas(lpath, verbose=verbose, **kwargs)
158
+
154
159
  # Check if it's a glob pattern
155
160
  if "*" in lpath or "?" in lpath or "[" in lpath:
156
161
  # Handle glob pattern
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/io/_load_modules/_canvas.py
5
+ """
6
+ Load canvas directory (.canvas) for scitex.vis.
7
+
8
+ Canvas directories are portable figure bundles containing:
9
+ - canvas.json: Layout, panels, composition settings
10
+ - panels/: Panel directories (scitex or image type)
11
+ - exports/: Composed outputs (PNG, PDF, SVG)
12
+
13
+ Usage:
14
+ >>> import scitex as stx
15
+ >>> # Load canvas from directory
16
+ >>> canvas = stx.io.load("/path/to/fig1_results.canvas")
17
+ >>> # Access canvas properties
18
+ >>> print(canvas["canvas_name"])
19
+ >>> print(canvas["panels"])
20
+ """
21
+
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Any, Dict, Union
25
+
26
+
27
+ def _load_canvas(
28
+ lpath: Union[str, Path],
29
+ verbose: bool = False,
30
+ load_panels: bool = False,
31
+ as_dict: bool = False,
32
+ **kwargs,
33
+ ) -> Any:
34
+ """
35
+ Load a canvas from a .canvas directory.
36
+
37
+ Parameters
38
+ ----------
39
+ lpath : str or Path
40
+ Path to the .canvas directory.
41
+ verbose : bool, optional
42
+ If True, print verbose output. Default is False.
43
+ load_panels : bool, optional
44
+ If True, also load panel images as numpy arrays.
45
+ If False (default), only load canvas.json metadata.
46
+ as_dict : bool, optional
47
+ If True, return raw dict instead of Canvas object.
48
+ Default is False (returns Canvas object).
49
+ **kwargs
50
+ Additional arguments (reserved for future use).
51
+
52
+ Returns
53
+ -------
54
+ Canvas or Dict[str, Any]
55
+ Canvas object (default) or dictionary if as_dict=True.
56
+ Contains:
57
+ - All fields from canvas.json
58
+ - '_canvas_dir': Path to the canvas directory
59
+ - If load_panels=True, panel images are loaded into memory
60
+
61
+ Raises
62
+ ------
63
+ FileNotFoundError
64
+ If the .canvas directory or canvas.json doesn't exist.
65
+ ValueError
66
+ If the path doesn't appear to be a valid canvas directory.
67
+ """
68
+ lpath = Path(lpath)
69
+
70
+ # Validate path
71
+ if not str(lpath).endswith(".canvas"):
72
+ raise ValueError(
73
+ f"Canvas path must end with .canvas extension: {lpath}"
74
+ )
75
+
76
+ if not lpath.exists():
77
+ raise FileNotFoundError(f"Canvas directory not found: {lpath}")
78
+
79
+ if not lpath.is_dir():
80
+ raise ValueError(
81
+ f"Canvas path must be a directory: {lpath}"
82
+ )
83
+
84
+ json_path = lpath / "canvas.json"
85
+ if not json_path.exists():
86
+ raise FileNotFoundError(
87
+ f"canvas.json not found in canvas directory: {lpath}"
88
+ )
89
+
90
+ # Load canvas.json
91
+ with open(json_path, "r") as f:
92
+ canvas_dict = json.load(f)
93
+
94
+ # Add reference to the canvas directory
95
+ canvas_dict["_canvas_dir"] = str(lpath)
96
+
97
+ if verbose:
98
+ print(f"Loaded canvas: {canvas_dict.get('canvas_name', 'unknown')}")
99
+ print(f" Schema version: {canvas_dict.get('schema_version', 'unknown')}")
100
+ print(f" Panels: {len(canvas_dict.get('panels', []))}")
101
+
102
+ # Optionally load panel images
103
+ if load_panels:
104
+ _load_panel_images(lpath, canvas_dict, verbose=verbose)
105
+
106
+ # Return Canvas object by default
107
+ if not as_dict:
108
+ try:
109
+ from scitex.vis.canvas import Canvas
110
+ canvas_obj = Canvas.from_dict(canvas_dict)
111
+ # Store reference to original directory for copying
112
+ canvas_obj._canvas_dir = str(lpath)
113
+ return canvas_obj
114
+ except ImportError:
115
+ # Fall back to dict if Canvas class unavailable
116
+ pass
117
+
118
+ return canvas_dict
119
+
120
+
121
+ def _load_panel_images(
122
+ canvas_dir: Path,
123
+ canvas_dict: Dict[str, Any],
124
+ verbose: bool = False,
125
+ ) -> None:
126
+ """
127
+ Load panel images into canvas_dict.
128
+
129
+ Modifies canvas_dict in place, adding '_image' key to each panel
130
+ containing the loaded numpy array.
131
+ """
132
+ try:
133
+ from PIL import Image
134
+ import numpy as np
135
+ except ImportError:
136
+ if verbose:
137
+ print("PIL/numpy not available, skipping panel image loading")
138
+ return
139
+
140
+ panels_dir = canvas_dir / "panels"
141
+
142
+ for panel in canvas_dict.get("panels", []):
143
+ panel_name = panel.get("name", "")
144
+ if not panel_name:
145
+ continue
146
+
147
+ panel_dir = panels_dir / panel_name
148
+ if not panel_dir.exists():
149
+ continue
150
+
151
+ # Try to find panel image
152
+ panel_type = panel.get("type", "image")
153
+ if panel_type == "scitex":
154
+ img_path = panel_dir / "panel.png"
155
+ else:
156
+ # For image type, use source filename
157
+ source = panel.get("source", "panel.png")
158
+ img_path = panel_dir / source
159
+
160
+ if img_path.exists():
161
+ try:
162
+ img = Image.open(img_path)
163
+ panel["_image"] = np.array(img)
164
+ if verbose:
165
+ print(f" Loaded panel image: {panel_name}")
166
+ except Exception as e:
167
+ if verbose:
168
+ print(f" Failed to load panel image {panel_name}: {e}")
169
+
170
+
171
+ # EOF
scitex/io/_save.py CHANGED
@@ -61,6 +61,7 @@ from ._save_modules import save_torch
61
61
  from ._save_modules import save_yaml
62
62
  from ._save_modules import save_zarr
63
63
  from ._save_modules._bibtex import save_bibtex
64
+ from ._save_modules._canvas import save_canvas
64
65
 
65
66
  logger = logging.getLogger()
66
67
 
@@ -510,6 +511,11 @@ def _save(
510
511
  # Get file extension
511
512
  ext = _os.path.splitext(spath)[1].lower()
512
513
 
514
+ # Handle .canvas directories (special case - path ends with .canvas)
515
+ if spath.endswith(".canvas"):
516
+ save_canvas(obj, spath, **kwargs)
517
+ return
518
+
513
519
  # Try dispatch dictionary first for O(1) lookup
514
520
  if ext in _FILE_HANDLERS:
515
521
  # Check if handler needs special parameters
@@ -1028,6 +1034,8 @@ def _handle_image_with_csv(
1028
1034
 
1029
1035
  # Dispatch dictionary for O(1) file format lookup
1030
1036
  _FILE_HANDLERS = {
1037
+ # Canvas directory format (scitex.vis)
1038
+ ".canvas": save_canvas,
1031
1039
  # Excel formats
1032
1040
  ".xlsx": save_excel,
1033
1041
  ".xls": save_excel,