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,378 @@
1
+ # universal_render/highlevel.py
2
+ """
3
+ High-level convenience API.
4
+
5
+ Users think in languages ("hindi", "bangla"), not Unicode script names,
6
+ and usually want to label a whole plot in one call rather than six.
7
+ This module provides:
8
+
9
+ - language_to_script("hindi") -> "Devanagari"
10
+ - set_language_font("bangla", family) -> pin a font by language name
11
+ - localize_axes(ax, title=..., ...) -> label an entire axes in one call
12
+ - multilingual_heatmap(ax, data, labels=...) -> annotated heatmap in one call
13
+ - multilingual_bar_labels(ax, labels) -> value/category labels on bar charts
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, List, Optional, Sequence, Union
18
+
19
+ import numpy as np
20
+
21
+ from .fonts import find_font_for_script, set_script_font
22
+ from .scripts import detect_script, to_native_numerals
23
+ from .mpl_support import (
24
+ add_multilingual_cell_text,
25
+ apply_multilingual_layout,
26
+ multilingual_text,
27
+ set_multilingual_legend,
28
+ set_multilingual_suptitle,
29
+ set_multilingual_title,
30
+ set_multilingual_xlabel,
31
+ set_multilingual_xticks,
32
+ set_multilingual_ylabel,
33
+ set_multilingual_yticks,
34
+ )
35
+
36
+
37
+ # ---------------------------------------------------------------------
38
+ # Language names -> script
39
+ # ---------------------------------------------------------------------
40
+
41
+ LANGUAGE_TO_SCRIPT: Dict[str, str] = {
42
+ # Indic
43
+ "bengali": "Bengali", "bangla": "Bengali", "assamese": "Bengali",
44
+ "hindi": "Devanagari", "marathi": "Devanagari", "nepali": "Devanagari",
45
+ "sanskrit": "Devanagari", "konkani": "Devanagari",
46
+ "tamil": "Tamil",
47
+ "telugu": "Telugu",
48
+ "kannada": "Kannada",
49
+ "malayalam": "Malayalam",
50
+ "gujarati": "Gujarati",
51
+ "punjabi": "Gurmukhi", "panjabi": "Gurmukhi",
52
+ "odia": "Odia", "oriya": "Odia",
53
+ "sinhala": "Sinhala", "sinhalese": "Sinhala",
54
+ # RTL
55
+ "arabic": "Arabic", "urdu": "Arabic", "persian": "Arabic", "farsi": "Arabic",
56
+ "pashto": "Arabic", "kurdish": "Arabic",
57
+ "hebrew": "Hebrew", "yiddish": "Hebrew",
58
+ # Southeast Asian
59
+ "thai": "Thai",
60
+ "lao": "Lao",
61
+ "khmer": "Khmer", "cambodian": "Khmer",
62
+ "burmese": "Myanmar", "myanmar": "Myanmar",
63
+ "tibetan": "Tibetan", "dzongkha": "Tibetan",
64
+ # East Asian
65
+ "chinese": "Han", "mandarin": "Han", "cantonese": "Han",
66
+ "japanese": "Hiragana",
67
+ "korean": "Hangul",
68
+ # European / other
69
+ "english": "Latin", "french": "Latin", "german": "Latin",
70
+ "spanish": "Latin", "portuguese": "Latin", "italian": "Latin",
71
+ "vietnamese": "Latin", "turkish": "Latin", "indonesian": "Latin",
72
+ "russian": "Cyrillic", "ukrainian": "Cyrillic", "bulgarian": "Cyrillic",
73
+ "serbian": "Cyrillic", "kazakh": "Cyrillic",
74
+ "greek": "Greek",
75
+ "georgian": "Georgian",
76
+ "armenian": "Armenian",
77
+ "amharic": "Ethiopic", "tigrinya": "Ethiopic",
78
+ }
79
+
80
+
81
+ def supported_languages() -> List[str]:
82
+ """Language names accepted by language_to_script()."""
83
+ return sorted(LANGUAGE_TO_SCRIPT)
84
+
85
+
86
+ def language_to_script(language: str) -> str:
87
+ """
88
+ Map a language name to its Unicode script.
89
+
90
+ >>> language_to_script("hindi")
91
+ 'Devanagari'
92
+ >>> language_to_script("urdu")
93
+ 'Arabic'
94
+ """
95
+ key = str(language).strip().lower()
96
+ script = LANGUAGE_TO_SCRIPT.get(key)
97
+ if script is None:
98
+ raise KeyError(
99
+ f"Unknown language: {language!r}. "
100
+ f"Known languages: {', '.join(supported_languages())}"
101
+ )
102
+ return script
103
+
104
+
105
+ def set_language_font(language: str, family: str) -> None:
106
+ """Pin a font family by language name, e.g. ("bangla", "Kalpurush")."""
107
+ set_script_font(language_to_script(language), family)
108
+
109
+
110
+ def font_for_language(language: str, sample_text: Optional[str] = None) -> str:
111
+ """Best installed font family for a language name."""
112
+ return find_font_for_script(language_to_script(language), sample_text=sample_text)
113
+
114
+
115
+ def localized_numerals(value, language: str) -> str:
116
+ """to_native_numerals() addressed by language name."""
117
+ return to_native_numerals(value, language_to_script(language))
118
+
119
+
120
+ # ---------------------------------------------------------------------
121
+ # One-call axes localization
122
+ # ---------------------------------------------------------------------
123
+
124
+ def localize_axes(
125
+ ax,
126
+ title: Optional[str] = None,
127
+ xlabel: Optional[str] = None,
128
+ ylabel: Optional[str] = None,
129
+ suptitle: Optional[str] = None,
130
+ xticklabels: Optional[Sequence[str]] = None,
131
+ xtick_positions: Optional[Sequence[float]] = None,
132
+ yticklabels: Optional[Sequence[str]] = None,
133
+ ytick_positions: Optional[Sequence[float]] = None,
134
+ legend_labels: Optional[Sequence[str]] = None,
135
+ legend_loc: str = "upper right",
136
+ title_size: int = 32,
137
+ label_size: int = 24,
138
+ tick_size: int = 16,
139
+ legend_size: int = 16,
140
+ color: str = "black",
141
+ font_family: Optional[str] = None,
142
+ auto_layout: bool = True,
143
+ ) -> Dict[str, Any]:
144
+ """
145
+ Label an entire axes with script-aware text in a single call.
146
+
147
+ Every argument is optional; only what you pass gets applied. Each
148
+ string picks its own font from its dominant script, so the title can
149
+ be Bengali while the xlabel is Arabic.
150
+
151
+ ur.localize_axes(
152
+ ax,
153
+ title="বিক্রয় প্রতিবেদন",
154
+ xlabel="महीना",
155
+ ylabel="மதிப்பு",
156
+ xticklabels=["এক", "दो", "மூன்று"],
157
+ )
158
+
159
+ Returns a dict of the created artists, keyed by role.
160
+ """
161
+ artists: Dict[str, Any] = {}
162
+
163
+ if title is not None:
164
+ artists["title"] = set_multilingual_title(
165
+ ax, title, font_size=title_size, color=color, font_family=font_family,
166
+ )
167
+ if xlabel is not None:
168
+ artists["xlabel"] = set_multilingual_xlabel(
169
+ ax, xlabel, font_size=label_size, color=color, font_family=font_family,
170
+ )
171
+ if ylabel is not None:
172
+ artists["ylabel"] = set_multilingual_ylabel(
173
+ ax, ylabel, font_size=label_size, color=color, font_family=font_family,
174
+ )
175
+ if suptitle is not None:
176
+ artists["suptitle"] = set_multilingual_suptitle(
177
+ ax.figure, suptitle, font_size=title_size + 2, color=color,
178
+ font_family=font_family,
179
+ )
180
+
181
+ if xticklabels is not None:
182
+ positions = (
183
+ list(xtick_positions) if xtick_positions is not None
184
+ else list(ax.get_xticks())[: len(xticklabels)]
185
+ )
186
+ if len(positions) != len(xticklabels):
187
+ positions = list(range(1, len(xticklabels) + 1))
188
+ artists["xticks"] = set_multilingual_xticks(
189
+ ax, positions, list(xticklabels),
190
+ font_size=tick_size, color=color, font_family=font_family,
191
+ )
192
+
193
+ if yticklabels is not None:
194
+ positions = (
195
+ list(ytick_positions) if ytick_positions is not None
196
+ else list(ax.get_yticks())[: len(yticklabels)]
197
+ )
198
+ if len(positions) != len(yticklabels):
199
+ positions = list(range(1, len(yticklabels) + 1))
200
+ artists["yticks"] = set_multilingual_yticks(
201
+ ax, positions, list(yticklabels),
202
+ font_size=tick_size, color=color, font_family=font_family,
203
+ )
204
+
205
+ if legend_labels is not None:
206
+ artists["legend"] = set_multilingual_legend(
207
+ ax, list(legend_labels), loc=legend_loc,
208
+ font_size=legend_size, color=color, font_family=font_family,
209
+ )
210
+
211
+ if auto_layout:
212
+ apply_multilingual_layout(ax.figure, auto=True)
213
+
214
+ return artists
215
+
216
+
217
+ # ---------------------------------------------------------------------
218
+ # One-call heatmap
219
+ # ---------------------------------------------------------------------
220
+
221
+ def multilingual_heatmap(
222
+ ax,
223
+ data,
224
+ labels=None,
225
+ row_labels: Optional[Sequence[str]] = None,
226
+ col_labels: Optional[Sequence[str]] = None,
227
+ title: Optional[str] = None,
228
+ cmap: str = "viridis",
229
+ cell_font_size: int = 24,
230
+ tick_font_size: int = 16,
231
+ title_font_size: int = 30,
232
+ cell_color: str = "auto",
233
+ colorbar: bool = True,
234
+ value_format: Optional[str] = None,
235
+ value_script: Optional[str] = None,
236
+ imshow_kwargs: Optional[Dict[str, Any]] = None,
237
+ ):
238
+ """
239
+ Draw a fully script-aware annotated heatmap in one call.
240
+
241
+ Parameters
242
+ ----------
243
+ data:
244
+ 2D array of cell values.
245
+ labels:
246
+ Optional 2D array of cell label strings (any script). When None
247
+ and ``value_format`` is given, numeric values are formatted (and
248
+ converted to ``value_script`` digits if provided).
249
+ row_labels / col_labels:
250
+ Script-aware tick labels for the axes.
251
+ cell_color:
252
+ "auto" flips each cell's text between black/white based on the
253
+ cell's luminance; any other value is used as-is.
254
+
255
+ Returns (im, artists_dict).
256
+ """
257
+ import matplotlib.pyplot as plt # local import; backend already chosen
258
+
259
+ data = np.asarray(data, dtype=float)
260
+ rows, cols = data.shape
261
+
262
+ kwargs = dict(imshow_kwargs or {})
263
+ kwargs.setdefault("cmap", cmap)
264
+ im = ax.imshow(data, **kwargs)
265
+
266
+ # Build labels from values when not supplied
267
+ if labels is None and value_format is not None:
268
+ labels = np.empty(data.shape, dtype=object)
269
+ for i in range(rows):
270
+ for j in range(cols):
271
+ s = format(data[i, j], value_format)
272
+ if value_script:
273
+ s = to_native_numerals(s, value_script)
274
+ labels[i, j] = s
275
+
276
+ artists: Dict[str, Any] = {"cells": []}
277
+
278
+ if labels is not None:
279
+ labels = np.asarray(labels, dtype=object)
280
+ if labels.shape != data.shape:
281
+ raise ValueError(
282
+ f"labels shape {labels.shape} != data shape {data.shape}"
283
+ )
284
+ cmap_obj = im.get_cmap()
285
+ vmin, vmax = im.get_clim()
286
+ span = (vmax - vmin) or 1.0
287
+ for i in range(rows):
288
+ for j in range(cols):
289
+ if cell_color == "auto":
290
+ r, g, b, _ = cmap_obj((data[i, j] - vmin) / span)
291
+ luminance = 0.299 * r + 0.587 * g + 0.114 * b
292
+ color = "black" if luminance > 0.55 else "white"
293
+ else:
294
+ color = cell_color
295
+ artists["cells"].append(add_multilingual_cell_text(
296
+ ax, i, j, str(labels[i, j]),
297
+ rows=rows, cols=cols,
298
+ font_size=cell_font_size, color=color,
299
+ ))
300
+
301
+ ax.set_xticks(range(cols))
302
+ ax.set_yticks(range(rows))
303
+ ax.set_xticklabels([])
304
+ ax.set_yticklabels([])
305
+
306
+ if col_labels is not None:
307
+ artists["xticks"] = set_multilingual_xticks(
308
+ ax, list(range(cols)), list(col_labels), font_size=tick_font_size,
309
+ )
310
+ if row_labels is not None:
311
+ artists["yticks"] = set_multilingual_yticks(
312
+ ax, list(range(rows)), list(row_labels), font_size=tick_font_size,
313
+ )
314
+ if title is not None:
315
+ artists["title"] = set_multilingual_title(
316
+ ax, title, font_size=title_font_size,
317
+ )
318
+ if colorbar:
319
+ artists["colorbar"] = ax.figure.colorbar(im, ax=ax)
320
+
321
+ return im, artists
322
+
323
+
324
+ # ---------------------------------------------------------------------
325
+ # Bar-chart labels
326
+ # ---------------------------------------------------------------------
327
+
328
+ def multilingual_bar_labels(
329
+ ax,
330
+ labels: Sequence[str],
331
+ bars=None,
332
+ orientation: str = "vertical",
333
+ font_size: int = 16,
334
+ color: str = "black",
335
+ offset_frac: float = 0.02,
336
+ ):
337
+ """
338
+ Put a script-aware label at the end of each bar.
339
+
340
+ ``bars`` defaults to the first BarContainer on the axes (the result
341
+ of ax.bar()/ax.barh()). ``orientation`` is "vertical" for ax.bar()
342
+ or "horizontal" for ax.barh().
343
+ """
344
+ if bars is None:
345
+ containers = getattr(ax, "containers", [])
346
+ if not containers:
347
+ raise ValueError("No bar containers found on axes; pass bars=")
348
+ bars = containers[0]
349
+
350
+ patches = list(bars)
351
+ labels = list(labels)
352
+ if len(patches) != len(labels):
353
+ raise ValueError("labels and bars must have the same length")
354
+
355
+ vertical = str(orientation).lower().startswith("v")
356
+ if vertical:
357
+ span = ax.get_ylim()
358
+ pad = (span[1] - span[0]) * offset_frac
359
+ else:
360
+ span = ax.get_xlim()
361
+ pad = (span[1] - span[0]) * offset_frac
362
+
363
+ artists = []
364
+ for patch, label in zip(patches, labels):
365
+ if vertical:
366
+ x = patch.get_x() + patch.get_width() / 2.0
367
+ y = patch.get_y() + patch.get_height() + pad
368
+ ha, va = "center", "bottom"
369
+ else:
370
+ x = patch.get_x() + patch.get_width() + pad
371
+ y = patch.get_y() + patch.get_height() / 2.0
372
+ ha, va = "left", "center"
373
+ artists.append(multilingual_text(
374
+ ax, x, y, str(label),
375
+ coord="data", ha=ha, va=va,
376
+ font_size=font_size, color=color,
377
+ ))
378
+ return artists