universal-render 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,277 @@
1
+ # universal_render/__init__.py
2
+
3
+ """
4
+ universal_render: universal complex-script text rendering for Matplotlib.
5
+
6
+ Text is shaped through Qt's text stack (HarfBuzz), so scripts that
7
+ Matplotlib's own text engine cannot shape — Bengali, Hindi, Tamil,
8
+ Arabic, Thai, and other complex scripts — render correctly in titles,
9
+ axis labels, tick labels, annotations, legends, and heatmap cells.
10
+ Fonts are chosen automatically from each string's dominant Unicode
11
+ script, with per-script fallback tables and glyph-coverage validation.
12
+
13
+ Typical usage:
14
+
15
+ import universal_render as ur
16
+ import matplotlib.pyplot as plt
17
+
18
+ ur.init_renderer()
19
+
20
+ fig, ax = plt.subplots()
21
+ ax.plot([1, 2, 3], [3, 1, 4])
22
+
23
+ ur.set_multilingual_title(ax, "বাংলা শিরোনাম") # Bengali
24
+ ur.set_multilingual_xlabel(ax, "أشهر السنة") # Arabic (RTL)
25
+ ur.set_multilingual_ylabel(ax, "மாத விற்பனை") # Tamil
26
+
27
+ ur.set_multilingual_xticks(ax, [1, 2, 3], ["एक", "दो", "तीन"]) # Hindi
28
+ ur.text(ax, 0.5, 0.5, "ข้อความ Mixed 2026", coord="axes") # Thai + Latin
29
+
30
+ ur.apply_multilingual_layout(fig, auto=True)
31
+ """
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ # ---------------------------------------------------------------------
36
+ # Backend / environment
37
+ # ---------------------------------------------------------------------
38
+
39
+ from .backend import (
40
+ init_renderer,
41
+ ensure_qt_application,
42
+ get_renderer_status,
43
+ check_environment,
44
+ is_headless_environment,
45
+ is_notebook_environment,
46
+ is_colab_environment,
47
+ is_kaggle_environment,
48
+ reset_renderer_state,
49
+ )
50
+
51
+ # ---------------------------------------------------------------------
52
+ # Script detection
53
+ # ---------------------------------------------------------------------
54
+
55
+ from .scripts import (
56
+ KNOWN_SCRIPTS,
57
+ RTL_SCRIPTS,
58
+ TextRun,
59
+ detect_char_script,
60
+ detect_script,
61
+ detect_scripts,
62
+ describe_text,
63
+ is_mixed_script,
64
+ is_rtl_text,
65
+ script_direction,
66
+ scripts_with_native_digits,
67
+ segment_runs,
68
+ to_native_numerals,
69
+ )
70
+
71
+ # Roadmap-compatible alias
72
+ script_detection_auto = detect_script
73
+
74
+ # ---------------------------------------------------------------------
75
+ # Font management
76
+ # ---------------------------------------------------------------------
77
+
78
+ from .fonts import (
79
+ SCRIPT_FONT_CANDIDATES,
80
+ auto_font_fallback,
81
+ coverage_report,
82
+ family_exists,
83
+ find_font_for_script,
84
+ font_covers_text,
85
+ font_info,
86
+ get_default_font,
87
+ get_script_font,
88
+ list_available_fonts,
89
+ list_registered_fonts,
90
+ register_font,
91
+ register_fonts,
92
+ resolve_font,
93
+ resolve_fonts_for_runs,
94
+ set_default_font,
95
+ set_script_font,
96
+ validate_font,
97
+ )
98
+
99
+ # ---------------------------------------------------------------------
100
+ # Rendering
101
+ # ---------------------------------------------------------------------
102
+
103
+ from .renderer import (
104
+ render_text,
105
+ render_text_array,
106
+ render_mixed_text,
107
+ render_paragraph,
108
+ render_text_qimage,
109
+ render_paragraph_qimage,
110
+ measure_text,
111
+ clear_render_cache,
112
+ get_render_cache_info,
113
+ set_render_cache_maxsize,
114
+ set_render_defaults,
115
+ get_render_defaults,
116
+ )
117
+
118
+ # ---------------------------------------------------------------------
119
+ # Layout
120
+ # ---------------------------------------------------------------------
121
+
122
+ from .layout import (
123
+ get_layout_manager,
124
+ clear_layout_manager,
125
+ )
126
+
127
+ # ---------------------------------------------------------------------
128
+ # Matplotlib-facing APIs
129
+ # ---------------------------------------------------------------------
130
+
131
+ from .mpl_support import (
132
+ set_multilingual_legend,
133
+ set_multilingual_numeric_ticks,
134
+ set_multilingual_title,
135
+ set_multilingual_xlabel,
136
+ set_multilingual_ylabel,
137
+ set_multilingual_suptitle,
138
+ set_multilingual_xticks,
139
+ set_multilingual_yticks,
140
+ add_multilingual_cell_text,
141
+ multilingual_text,
142
+ annotate_multilingual,
143
+ multilingual_paragraph,
144
+ apply_multilingual_layout,
145
+ )
146
+
147
+ # ---------------------------------------------------------------------
148
+ # High-level API
149
+ # ---------------------------------------------------------------------
150
+
151
+ from .highlevel import (
152
+ LANGUAGE_TO_SCRIPT,
153
+ font_for_language,
154
+ language_to_script,
155
+ localize_axes,
156
+ localized_numerals,
157
+ multilingual_bar_labels,
158
+ multilingual_heatmap,
159
+ set_language_font,
160
+ supported_languages,
161
+ )
162
+
163
+ # ---------------------------------------------------------------------
164
+ # Diagnostics / evaluation
165
+ # ---------------------------------------------------------------------
166
+
167
+ from .diagnostics import (
168
+ SCRIPT_TEST_SAMPLES,
169
+ benchmark_render,
170
+ save_comparison_figure,
171
+ self_test,
172
+ )
173
+
174
+
175
+ def text(ax, *args, **kwargs):
176
+ """
177
+ Short alias for multilingual_text(), so you can write:
178
+
179
+ import universal_render as ur
180
+ ur.text(ax, 0.5, 0.5, "বাংলা / العربية / ไทย", coord="axes")
181
+ """
182
+ return multilingual_text(ax, *args, **kwargs)
183
+
184
+
185
+ __all__ = [
186
+ "__version__",
187
+ # backend
188
+ "init_renderer",
189
+ "ensure_qt_application",
190
+ "get_renderer_status",
191
+ "check_environment",
192
+ "is_headless_environment",
193
+ "is_notebook_environment",
194
+ "is_colab_environment",
195
+ "is_kaggle_environment",
196
+ "reset_renderer_state",
197
+ # scripts
198
+ "KNOWN_SCRIPTS",
199
+ "RTL_SCRIPTS",
200
+ "TextRun",
201
+ "detect_char_script",
202
+ "detect_script",
203
+ "detect_scripts",
204
+ "describe_text",
205
+ "is_mixed_script",
206
+ "is_rtl_text",
207
+ "script_direction",
208
+ "script_detection_auto",
209
+ "scripts_with_native_digits",
210
+ "segment_runs",
211
+ "to_native_numerals",
212
+ # fonts
213
+ "SCRIPT_FONT_CANDIDATES",
214
+ "auto_font_fallback",
215
+ "coverage_report",
216
+ "family_exists",
217
+ "find_font_for_script",
218
+ "font_covers_text",
219
+ "font_info",
220
+ "get_default_font",
221
+ "get_script_font",
222
+ "list_available_fonts",
223
+ "list_registered_fonts",
224
+ "register_font",
225
+ "register_fonts",
226
+ "resolve_font",
227
+ "resolve_fonts_for_runs",
228
+ "set_default_font",
229
+ "set_script_font",
230
+ "validate_font",
231
+ # renderer
232
+ "render_text",
233
+ "render_text_array",
234
+ "render_mixed_text",
235
+ "render_paragraph",
236
+ "render_text_qimage",
237
+ "render_paragraph_qimage",
238
+ "measure_text",
239
+ "clear_render_cache",
240
+ "get_render_cache_info",
241
+ "set_render_cache_maxsize",
242
+ "set_render_defaults",
243
+ "get_render_defaults",
244
+ # layout
245
+ "get_layout_manager",
246
+ "clear_layout_manager",
247
+ # mpl support
248
+ "set_multilingual_legend",
249
+ "set_multilingual_numeric_ticks",
250
+ "set_multilingual_title",
251
+ "set_multilingual_xlabel",
252
+ "set_multilingual_ylabel",
253
+ "set_multilingual_suptitle",
254
+ "set_multilingual_xticks",
255
+ "set_multilingual_yticks",
256
+ "add_multilingual_cell_text",
257
+ "multilingual_text",
258
+ "annotate_multilingual",
259
+ "multilingual_paragraph",
260
+ "apply_multilingual_layout",
261
+ "text",
262
+ # high-level
263
+ "LANGUAGE_TO_SCRIPT",
264
+ "font_for_language",
265
+ "language_to_script",
266
+ "localize_axes",
267
+ "localized_numerals",
268
+ "multilingual_bar_labels",
269
+ "multilingual_heatmap",
270
+ "set_language_font",
271
+ "supported_languages",
272
+ # diagnostics
273
+ "SCRIPT_TEST_SAMPLES",
274
+ "benchmark_render",
275
+ "save_comparison_figure",
276
+ "self_test",
277
+ ]
@@ -0,0 +1,375 @@
1
+ # universal_render/backend.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import platform
7
+ from dataclasses import dataclass, asdict, field
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ try:
11
+ from PySide6.QtGui import QGuiApplication
12
+ from PySide6.QtWidgets import QApplication
13
+ QT_AVAILABLE = True
14
+ QT_IMPORT_ERROR = None
15
+ except Exception as e: # pragma: no cover
16
+ QGuiApplication = None
17
+ QApplication = None
18
+ QT_AVAILABLE = False
19
+ QT_IMPORT_ERROR = e
20
+
21
+
22
+ # ---------------------------------------------------------------------
23
+ # Internal state
24
+ # ---------------------------------------------------------------------
25
+
26
+ _APP = None
27
+ _INITIALIZED = False
28
+
29
+
30
+ @dataclass
31
+ class RendererStatus:
32
+ qt_available: bool = QT_AVAILABLE
33
+ qt_import_error: Optional[str] = None
34
+ initialized: bool = False
35
+ app_created: bool = False
36
+ app_class: Optional[str] = None
37
+ platform_name: str = field(default_factory=lambda: platform.system())
38
+ python_version: str = field(default_factory=lambda: platform.python_version())
39
+ headless: bool = False
40
+ offscreen_requested: bool = False
41
+ offscreen_enabled: bool = False
42
+ qt_qpa_platform: Optional[str] = None
43
+ warnings: List[str] = field(default_factory=list)
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ return asdict(self)
47
+
48
+
49
+ _STATUS = RendererStatus(
50
+ qt_import_error=str(QT_IMPORT_ERROR) if QT_IMPORT_ERROR else None
51
+ )
52
+
53
+
54
+ # ---------------------------------------------------------------------
55
+ # Environment helpers
56
+ # ---------------------------------------------------------------------
57
+
58
+ def is_notebook_environment() -> bool:
59
+ """
60
+ Best-effort detection for Jupyter/Colab/Kaggle-like notebook environments.
61
+ """
62
+ try:
63
+ from IPython import get_ipython # type: ignore
64
+ shell = get_ipython()
65
+ if shell is None:
66
+ return False
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+
72
+ def is_colab_environment() -> bool:
73
+ return "google.colab" in sys.modules
74
+
75
+
76
+ def is_kaggle_environment() -> bool:
77
+ return (
78
+ "KAGGLE_KERNEL_RUN_TYPE" in os.environ
79
+ or "KAGGLE_URL_BASE" in os.environ
80
+ or "/kaggle/" in os.getcwd().replace("\\", "/")
81
+ )
82
+
83
+
84
+ def is_headless_environment() -> bool:
85
+ """
86
+ Heuristic headless detection.
87
+
88
+ On Linux, the absence of DISPLAY/WAYLAND_DISPLAY usually means headless.
89
+ On Colab/Kaggle, assume headless.
90
+ On Windows/macOS, default to False unless explicitly offscreen.
91
+ """
92
+ if is_colab_environment() or is_kaggle_environment():
93
+ return True
94
+
95
+ system_name = platform.system().lower()
96
+
97
+ if system_name == "linux":
98
+ display = os.environ.get("DISPLAY")
99
+ wayland = os.environ.get("WAYLAND_DISPLAY")
100
+ return not (display or wayland)
101
+
102
+ return False
103
+
104
+
105
+ def _normalize_bool(value: Optional[bool], default: bool) -> bool:
106
+ if value is None:
107
+ return default
108
+ return bool(value)
109
+
110
+
111
+ def _ensure_offscreen_env(force: bool = False) -> bool:
112
+ """
113
+ Enable Qt offscreen mode by setting QT_QPA_PLATFORM=offscreen.
114
+
115
+ Returns True if the environment variable is set to 'offscreen' after this call.
116
+ """
117
+ current = os.environ.get("QT_QPA_PLATFORM")
118
+
119
+ if current:
120
+ # Respect explicit user setting unless force=True
121
+ if force:
122
+ os.environ["QT_QPA_PLATFORM"] = "offscreen"
123
+ return True
124
+ return current.lower() == "offscreen"
125
+
126
+ os.environ["QT_QPA_PLATFORM"] = "offscreen"
127
+ return True
128
+
129
+
130
+ def _existing_app():
131
+ """
132
+ Return an existing Qt application instance if one exists.
133
+ """
134
+ if not QT_AVAILABLE:
135
+ return None
136
+
137
+ try:
138
+ app = QApplication.instance()
139
+ if app is not None:
140
+ return app
141
+ except Exception:
142
+ pass
143
+
144
+ try:
145
+ app = QGuiApplication.instance()
146
+ if app is not None:
147
+ return app
148
+ except Exception:
149
+ pass
150
+
151
+ return None
152
+
153
+
154
+ # ---------------------------------------------------------------------
155
+ # Public API
156
+ # ---------------------------------------------------------------------
157
+
158
+ def init_renderer(
159
+ headless: Optional[bool] = None,
160
+ offscreen: bool = True,
161
+ force: bool = False,
162
+ ) -> Any:
163
+ """
164
+ Initialize a Qt application safely for multilingual text rendering.
165
+
166
+ Parameters
167
+ ----------
168
+ headless:
169
+ Whether to treat the current environment as headless.
170
+ If None, a best-effort detection is used.
171
+ offscreen:
172
+ Whether to request Qt offscreen mode in headless environments.
173
+ force:
174
+ If True, re-run initialization logic even if already initialized.
175
+
176
+ Returns
177
+ -------
178
+ app:
179
+ The active QApplication/QGuiApplication instance.
180
+
181
+ Notes
182
+ -----
183
+ - In Colab/Kaggle/headless Linux, offscreen mode is usually required.
184
+ - This function tries to reuse an existing Qt app if one already exists.
185
+ """
186
+ global _APP, _INITIALIZED, _STATUS
187
+
188
+ if not QT_AVAILABLE:
189
+ raise RuntimeError(
190
+ "PySide6 is not available. Install PySide6 to use universal_render.\n"
191
+ f"Original import error: {QT_IMPORT_ERROR}"
192
+ )
193
+
194
+ inferred_headless = is_headless_environment()
195
+ use_headless = _normalize_bool(headless, inferred_headless)
196
+
197
+ warnings: List[str] = []
198
+
199
+ if _INITIALIZED and not force:
200
+ _STATUS.headless = use_headless
201
+ _STATUS.offscreen_requested = bool(offscreen)
202
+ _STATUS.qt_qpa_platform = os.environ.get("QT_QPA_PLATFORM")
203
+ _STATUS.offscreen_enabled = (
204
+ (_STATUS.qt_qpa_platform or "").lower() == "offscreen"
205
+ )
206
+ if warnings:
207
+ _STATUS.warnings.extend(warnings)
208
+ return _APP
209
+
210
+ if use_headless and offscreen:
211
+ enabled = _ensure_offscreen_env(force=force)
212
+ if enabled:
213
+ _STATUS.offscreen_enabled = True
214
+ else:
215
+ warnings.append(
216
+ "Headless mode detected, but QT_QPA_PLATFORM is not set to 'offscreen'."
217
+ )
218
+
219
+ existing = _existing_app()
220
+ if existing is not None:
221
+ _APP = existing
222
+ _INITIALIZED = True
223
+
224
+ _STATUS.initialized = True
225
+ _STATUS.app_created = True
226
+ _STATUS.app_class = type(existing).__name__
227
+ _STATUS.headless = use_headless
228
+ _STATUS.offscreen_requested = bool(offscreen)
229
+ _STATUS.qt_qpa_platform = os.environ.get("QT_QPA_PLATFORM")
230
+ _STATUS.offscreen_enabled = (
231
+ (_STATUS.qt_qpa_platform or "").lower() == "offscreen"
232
+ )
233
+ _STATUS.warnings = warnings
234
+ return _APP
235
+
236
+ argv = [sys.argv[0] if sys.argv else "universal_render"]
237
+
238
+ try:
239
+ _APP = QApplication(argv)
240
+ except Exception as e:
241
+ _STATUS.initialized = False
242
+ _STATUS.app_created = False
243
+ _STATUS.app_class = None
244
+ _STATUS.headless = use_headless
245
+ _STATUS.offscreen_requested = bool(offscreen)
246
+ _STATUS.qt_qpa_platform = os.environ.get("QT_QPA_PLATFORM")
247
+ _STATUS.offscreen_enabled = (
248
+ (_STATUS.qt_qpa_platform or "").lower() == "offscreen"
249
+ )
250
+ _STATUS.warnings = warnings
251
+
252
+ extra = []
253
+ if use_headless and offscreen:
254
+ extra.append(
255
+ "This looks like a headless environment. Qt offscreen rendering was requested."
256
+ )
257
+ if is_colab_environment():
258
+ extra.append(
259
+ "Detected Google Colab. You may also need to install fonts and keep QT_QPA_PLATFORM=offscreen."
260
+ )
261
+ if is_kaggle_environment():
262
+ extra.append(
263
+ "Detected Kaggle. You may also need to install fonts and keep QT_QPA_PLATFORM=offscreen."
264
+ )
265
+
266
+ extra_text = "\n".join(extra)
267
+ raise RuntimeError(
268
+ "Failed to initialize Qt for universal_render.\n"
269
+ f"Qt error: {e}\n"
270
+ f"{extra_text}".strip()
271
+ ) from e
272
+
273
+ _INITIALIZED = True
274
+
275
+ _STATUS.initialized = True
276
+ _STATUS.app_created = True
277
+ _STATUS.app_class = type(_APP).__name__
278
+ _STATUS.headless = use_headless
279
+ _STATUS.offscreen_requested = bool(offscreen)
280
+ _STATUS.qt_qpa_platform = os.environ.get("QT_QPA_PLATFORM")
281
+ _STATUS.offscreen_enabled = (
282
+ (_STATUS.qt_qpa_platform or "").lower() == "offscreen"
283
+ )
284
+ _STATUS.warnings = warnings
285
+
286
+ if use_headless and not _STATUS.offscreen_enabled:
287
+ _STATUS.warnings.append(
288
+ "Headless environment detected, but offscreen Qt platform is not active."
289
+ )
290
+
291
+ return _APP
292
+
293
+
294
+ def ensure_qt_application() -> Any:
295
+ """
296
+ Convenience wrapper for lazy initialization with safe defaults.
297
+ """
298
+ return init_renderer(headless=None, offscreen=True, force=False)
299
+
300
+
301
+ def get_renderer_status() -> Dict[str, Any]:
302
+ """
303
+ Return backend/Qt initialization status as a plain dictionary.
304
+ """
305
+ _STATUS.qt_available = QT_AVAILABLE
306
+ _STATUS.qt_import_error = str(QT_IMPORT_ERROR) if QT_IMPORT_ERROR else None
307
+ _STATUS.qt_qpa_platform = os.environ.get("QT_QPA_PLATFORM")
308
+ _STATUS.offscreen_enabled = ((_STATUS.qt_qpa_platform or "").lower() == "offscreen")
309
+ _STATUS.headless = is_headless_environment()
310
+ return _STATUS.to_dict()
311
+
312
+
313
+ def check_environment() -> Dict[str, Any]:
314
+ """
315
+ Lightweight diagnostic snapshot of the current runtime.
316
+ """
317
+ warnings: List[str] = []
318
+
319
+ qt_qpa = os.environ.get("QT_QPA_PLATFORM")
320
+ headless = is_headless_environment()
321
+
322
+ if not QT_AVAILABLE:
323
+ warnings.append("PySide6 is not installed or failed to import.")
324
+
325
+ if headless and (qt_qpa or "").lower() != "offscreen":
326
+ warnings.append(
327
+ "Headless environment detected but QT_QPA_PLATFORM is not 'offscreen'."
328
+ )
329
+
330
+ if is_colab_environment():
331
+ warnings.append(
332
+ "Running in Google Colab. Script fonts may need manual installation or registration."
333
+ )
334
+
335
+ if is_kaggle_environment():
336
+ warnings.append(
337
+ "Running in Kaggle. Script fonts may need manual installation or registration."
338
+ )
339
+
340
+ return {
341
+ "qt_available": QT_AVAILABLE,
342
+ "qt_import_error": str(QT_IMPORT_ERROR) if QT_IMPORT_ERROR else None,
343
+ "headless": headless,
344
+ "notebook": is_notebook_environment(),
345
+ "colab": is_colab_environment(),
346
+ "kaggle": is_kaggle_environment(),
347
+ "qt_qpa_platform": qt_qpa,
348
+ "warnings": warnings,
349
+ }
350
+
351
+
352
+ def reset_renderer_state() -> None:
353
+ """
354
+ Reset only the Python-side tracking state.
355
+
356
+ This does not destroy an already-running Qt application, because Qt apps are
357
+ generally process-global and should not be forcefully torn down here.
358
+ """
359
+ global _APP, _INITIALIZED, _STATUS
360
+
361
+ existing = _existing_app()
362
+ _APP = existing
363
+ _INITIALIZED = existing is not None
364
+
365
+ _STATUS = RendererStatus(
366
+ qt_import_error=str(QT_IMPORT_ERROR) if QT_IMPORT_ERROR else None
367
+ )
368
+ _STATUS.initialized = _INITIALIZED
369
+ _STATUS.app_created = existing is not None
370
+ _STATUS.app_class = type(existing).__name__ if existing is not None else None
371
+ _STATUS.qt_qpa_platform = os.environ.get("QT_QPA_PLATFORM")
372
+ _STATUS.offscreen_enabled = (
373
+ ((_STATUS.qt_qpa_platform or "").lower() == "offscreen")
374
+ )
375
+ _STATUS.headless = is_headless_environment()