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.
- universal_render/__init__.py +277 -0
- universal_render/backend.py +375 -0
- universal_render/compat.py +128 -0
- universal_render/diagnostics.py +249 -0
- universal_render/fonts.py +516 -0
- universal_render/highlevel.py +378 -0
- universal_render/layout.py +967 -0
- universal_render/mpl_support.py +1144 -0
- universal_render/renderer.py +806 -0
- universal_render/scripts.py +311 -0
- universal_render-0.1.0.dist-info/METADATA +189 -0
- universal_render-0.1.0.dist-info/RECORD +15 -0
- universal_render-0.1.0.dist-info/WHEEL +5 -0
- universal_render-0.1.0.dist-info/licenses/LICENSE +21 -0
- universal_render-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# universal_render/compat.py
|
|
2
|
+
"""
|
|
3
|
+
bangla_render compatibility layer.
|
|
4
|
+
|
|
5
|
+
Existing bangla-render code keeps working after switching the import:
|
|
6
|
+
|
|
7
|
+
# old: import bangla_render as br
|
|
8
|
+
import universal_render.compat as br
|
|
9
|
+
|
|
10
|
+
br.set_bangla_title(ax, "বাংলা শিরোনাম")
|
|
11
|
+
br.set_bangla_xticks(ax, [1, 2], ["এক", "দুই"])
|
|
12
|
+
|
|
13
|
+
Every set_bangla_* name maps to its set_multilingual_* equivalent; the
|
|
14
|
+
behavior is identical for Bengali text (same Qt pipeline, same layout
|
|
15
|
+
manager), with automatic script-aware font fallback as a bonus.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .backend import ( # noqa: F401
|
|
20
|
+
check_environment,
|
|
21
|
+
ensure_qt_application,
|
|
22
|
+
get_renderer_status,
|
|
23
|
+
init_renderer,
|
|
24
|
+
is_colab_environment,
|
|
25
|
+
is_headless_environment,
|
|
26
|
+
is_kaggle_environment,
|
|
27
|
+
is_notebook_environment,
|
|
28
|
+
reset_renderer_state,
|
|
29
|
+
)
|
|
30
|
+
from .fonts import ( # noqa: F401
|
|
31
|
+
get_default_font,
|
|
32
|
+
list_available_fonts,
|
|
33
|
+
list_registered_fonts,
|
|
34
|
+
register_font,
|
|
35
|
+
register_fonts,
|
|
36
|
+
resolve_font,
|
|
37
|
+
set_default_font,
|
|
38
|
+
validate_font,
|
|
39
|
+
font_info,
|
|
40
|
+
SCRIPT_FONT_CANDIDATES,
|
|
41
|
+
)
|
|
42
|
+
from .renderer import ( # noqa: F401
|
|
43
|
+
clear_render_cache,
|
|
44
|
+
get_render_cache_info,
|
|
45
|
+
get_render_defaults,
|
|
46
|
+
measure_text,
|
|
47
|
+
render_paragraph,
|
|
48
|
+
render_paragraph_qimage,
|
|
49
|
+
render_text,
|
|
50
|
+
render_text_qimage,
|
|
51
|
+
set_render_cache_maxsize,
|
|
52
|
+
set_render_defaults,
|
|
53
|
+
)
|
|
54
|
+
from .layout import ( # noqa: F401
|
|
55
|
+
clear_layout_manager,
|
|
56
|
+
get_layout_manager,
|
|
57
|
+
)
|
|
58
|
+
from .scripts import to_native_numerals
|
|
59
|
+
from .mpl_support import (
|
|
60
|
+
add_multilingual_cell_text,
|
|
61
|
+
annotate_multilingual,
|
|
62
|
+
apply_multilingual_layout,
|
|
63
|
+
multilingual_paragraph,
|
|
64
|
+
multilingual_text,
|
|
65
|
+
set_multilingual_legend,
|
|
66
|
+
set_multilingual_numeric_ticks,
|
|
67
|
+
set_multilingual_suptitle,
|
|
68
|
+
set_multilingual_title,
|
|
69
|
+
set_multilingual_xlabel,
|
|
70
|
+
set_multilingual_xticks,
|
|
71
|
+
set_multilingual_ylabel,
|
|
72
|
+
set_multilingual_yticks,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Bengali-candidate list under its historical name
|
|
76
|
+
BANGLA_FONT_CANDIDATES = list(SCRIPT_FONT_CANDIDATES["Bengali"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_bangla_numerals(value):
|
|
80
|
+
"""Convert ASCII digits to Bengali digits (bangla_render API)."""
|
|
81
|
+
return to_native_numerals(value, "Bengali")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Drop-in aliases -----------------------------------------------------
|
|
85
|
+
|
|
86
|
+
set_bangla_title = set_multilingual_title
|
|
87
|
+
set_bangla_xlabel = set_multilingual_xlabel
|
|
88
|
+
set_bangla_ylabel = set_multilingual_ylabel
|
|
89
|
+
set_bangla_suptitle = set_multilingual_suptitle
|
|
90
|
+
set_bangla_xticks = set_multilingual_xticks
|
|
91
|
+
set_bangla_yticks = set_multilingual_yticks
|
|
92
|
+
set_bangla_legend = set_multilingual_legend
|
|
93
|
+
bangla_text = multilingual_text
|
|
94
|
+
annotate_bangla = annotate_multilingual
|
|
95
|
+
add_bangla_in_cell = add_multilingual_cell_text
|
|
96
|
+
bangla_paragraph = multilingual_paragraph
|
|
97
|
+
apply_bangla_layout = apply_multilingual_layout
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_bangla_numeric_ticks(ax, *args, **kwargs):
|
|
101
|
+
"""bangla_render numeric ticks: always Bengali digits."""
|
|
102
|
+
kwargs.setdefault("script", "Bengali")
|
|
103
|
+
return set_multilingual_numeric_ticks(ax, *args, **kwargs)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def text(ax, *args, **kwargs):
|
|
107
|
+
"""Alias for bangla_text()/multilingual_text()."""
|
|
108
|
+
return multilingual_text(ax, *args, **kwargs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = [
|
|
112
|
+
"BANGLA_FONT_CANDIDATES",
|
|
113
|
+
"to_bangla_numerals",
|
|
114
|
+
"set_bangla_title",
|
|
115
|
+
"set_bangla_xlabel",
|
|
116
|
+
"set_bangla_ylabel",
|
|
117
|
+
"set_bangla_suptitle",
|
|
118
|
+
"set_bangla_xticks",
|
|
119
|
+
"set_bangla_yticks",
|
|
120
|
+
"set_bangla_legend",
|
|
121
|
+
"set_bangla_numeric_ticks",
|
|
122
|
+
"bangla_text",
|
|
123
|
+
"annotate_bangla",
|
|
124
|
+
"add_bangla_in_cell",
|
|
125
|
+
"bangla_paragraph",
|
|
126
|
+
"apply_bangla_layout",
|
|
127
|
+
"text",
|
|
128
|
+
]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# universal_render/diagnostics.py
|
|
2
|
+
"""
|
|
3
|
+
Self-testing, benchmarking, and evaluation figure generation.
|
|
4
|
+
|
|
5
|
+
- self_test() : does each script actually render here?
|
|
6
|
+
- benchmark_render() : cold/warm render latency statistics
|
|
7
|
+
- save_comparison_figure() : side-by-side default-Matplotlib vs
|
|
8
|
+
universal_render figure (paper-ready)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .fonts import auto_font_fallback, font_covers_text
|
|
18
|
+
from .renderer import (
|
|
19
|
+
clear_render_cache,
|
|
20
|
+
render_text_array,
|
|
21
|
+
)
|
|
22
|
+
from .scripts import detect_script, script_direction
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Known-tricky sample words per script: conjuncts, reordering vowels,
|
|
26
|
+
#: contextual joining, stacked marks. Used by self_test() and the
|
|
27
|
+
#: comparison figure.
|
|
28
|
+
SCRIPT_TEST_SAMPLES: Dict[str, str] = {
|
|
29
|
+
"Bengali": "বিশ্ববিদ্যালয়ের শিক্ষার্থী",
|
|
30
|
+
"Devanagari": "विश्वविद्यालय क्षेत्र",
|
|
31
|
+
"Tamil": "பல்கலைக்கழகம்",
|
|
32
|
+
"Telugu": "విశ్వవిద్యాలయం",
|
|
33
|
+
"Kannada": "ವಿಶ್ವವಿದ್ಯಾಲಯ",
|
|
34
|
+
"Malayalam": "സർവ്വകലാശാല",
|
|
35
|
+
"Gujarati": "યુનિવર્સિટી",
|
|
36
|
+
"Gurmukhi": "ਯੂਨੀਵਰਸਿਟੀ",
|
|
37
|
+
"Odia": "ବିଶ୍ୱବିଦ୍ୟାଳୟ",
|
|
38
|
+
"Sinhala": "විශ්වවිද්යාලය",
|
|
39
|
+
"Arabic": "جامعة القاهرة",
|
|
40
|
+
"Hebrew": "אוניברסיטה",
|
|
41
|
+
"Thai": "มหาวิทยาลัย",
|
|
42
|
+
"Lao": "ມະຫາວິທະຍາໄລ",
|
|
43
|
+
"Khmer": "សាកលវិទ្យាល័យ",
|
|
44
|
+
"Myanmar": "တက္ကသိုလ်",
|
|
45
|
+
"Han": "大学研究",
|
|
46
|
+
"Hangul": "대학교 연구",
|
|
47
|
+
"Hiragana": "だいがく",
|
|
48
|
+
"Greek": "Πανεπιστήμιο",
|
|
49
|
+
"Cyrillic": "Университет",
|
|
50
|
+
"Latin": "University",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------
|
|
55
|
+
# Self test
|
|
56
|
+
# ---------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _ink_pixels(arr: np.ndarray) -> int:
|
|
59
|
+
"""Count non-transparent pixels in an RGBA uint8 array."""
|
|
60
|
+
return int((arr[:, :, 3] > 0).sum())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def self_test(
|
|
64
|
+
scripts: Optional[Sequence[str]] = None,
|
|
65
|
+
font_size: int = 32,
|
|
66
|
+
verbose: bool = True,
|
|
67
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
68
|
+
"""
|
|
69
|
+
Render a known-tricky sample for each script and report whether this
|
|
70
|
+
environment can display it.
|
|
71
|
+
|
|
72
|
+
For each script: the resolved font, whether that font actually has
|
|
73
|
+
the glyphs (coverage), whether the render produced visible ink, and
|
|
74
|
+
a pass/fail flag. Returns {script: report} and prints a table when
|
|
75
|
+
``verbose``.
|
|
76
|
+
"""
|
|
77
|
+
chosen = list(scripts) if scripts else list(SCRIPT_TEST_SAMPLES)
|
|
78
|
+
results: Dict[str, Dict[str, Any]] = {}
|
|
79
|
+
|
|
80
|
+
for script in chosen:
|
|
81
|
+
sample = SCRIPT_TEST_SAMPLES.get(script)
|
|
82
|
+
if sample is None:
|
|
83
|
+
results[script] = {"ok": False, "error": "no test sample defined"}
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
report: Dict[str, Any] = {"sample": sample}
|
|
87
|
+
try:
|
|
88
|
+
report["detected_script"] = detect_script(sample)
|
|
89
|
+
report["direction"] = script_direction(script)
|
|
90
|
+
family = auto_font_fallback(sample)
|
|
91
|
+
report["font_family"] = family
|
|
92
|
+
report["glyph_coverage"] = font_covers_text(family, sample)
|
|
93
|
+
arr = render_text_array(sample, font_size=font_size)
|
|
94
|
+
ink = _ink_pixels(arr)
|
|
95
|
+
report["image_size"] = (arr.shape[1], arr.shape[0])
|
|
96
|
+
report["ink_pixels"] = ink
|
|
97
|
+
report["rendered_nonempty"] = ink > 0
|
|
98
|
+
report["ok"] = bool(
|
|
99
|
+
report["detected_script"] == script
|
|
100
|
+
and report["glyph_coverage"]
|
|
101
|
+
and report["rendered_nonempty"]
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
report["ok"] = False
|
|
105
|
+
report["error"] = str(e)
|
|
106
|
+
results[script] = report
|
|
107
|
+
|
|
108
|
+
if verbose:
|
|
109
|
+
print(f"{'script':<12} {'ok':<4} {'font':<24} {'coverage':<9} {'ink_px':>8}")
|
|
110
|
+
print("-" * 62)
|
|
111
|
+
for script, r in results.items():
|
|
112
|
+
print(
|
|
113
|
+
f"{script:<12} "
|
|
114
|
+
f"{'yes' if r.get('ok') else 'NO ':<4} "
|
|
115
|
+
f"{str(r.get('font_family', '-')):<24} "
|
|
116
|
+
f"{str(r.get('glyph_coverage', '-')):<9} "
|
|
117
|
+
f"{r.get('ink_pixels', 0):>8}"
|
|
118
|
+
)
|
|
119
|
+
n_ok = sum(1 for r in results.values() if r.get("ok"))
|
|
120
|
+
print("-" * 62)
|
|
121
|
+
print(f"{n_ok}/{len(results)} scripts render correctly in this environment")
|
|
122
|
+
|
|
123
|
+
return results
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------
|
|
127
|
+
# Benchmark
|
|
128
|
+
# ---------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def benchmark_render(
|
|
131
|
+
texts: Optional[Sequence[str]] = None,
|
|
132
|
+
font_size: int = 32,
|
|
133
|
+
repeats: int = 20,
|
|
134
|
+
) -> Dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Measure render latency, cold (cache cleared each call) and warm
|
|
137
|
+
(cache hits). Returns per-text and aggregate statistics in
|
|
138
|
+
milliseconds — the raw material for the paper's performance table.
|
|
139
|
+
"""
|
|
140
|
+
if texts is None:
|
|
141
|
+
texts = list(SCRIPT_TEST_SAMPLES.values())
|
|
142
|
+
|
|
143
|
+
per_text: List[Dict[str, Any]] = []
|
|
144
|
+
|
|
145
|
+
for text in texts:
|
|
146
|
+
cold: List[float] = []
|
|
147
|
+
for _ in range(repeats):
|
|
148
|
+
clear_render_cache()
|
|
149
|
+
t0 = time.perf_counter()
|
|
150
|
+
render_text_array(text, font_size=font_size)
|
|
151
|
+
cold.append((time.perf_counter() - t0) * 1000.0)
|
|
152
|
+
|
|
153
|
+
# Prime the cache once, then measure hits
|
|
154
|
+
render_text_array(text, font_size=font_size)
|
|
155
|
+
warm: List[float] = []
|
|
156
|
+
for _ in range(repeats):
|
|
157
|
+
t0 = time.perf_counter()
|
|
158
|
+
render_text_array(text, font_size=font_size)
|
|
159
|
+
warm.append((time.perf_counter() - t0) * 1000.0)
|
|
160
|
+
|
|
161
|
+
per_text.append({
|
|
162
|
+
"text": text,
|
|
163
|
+
"script": detect_script(text),
|
|
164
|
+
"cold_ms_mean": float(np.mean(cold)),
|
|
165
|
+
"cold_ms_std": float(np.std(cold)),
|
|
166
|
+
"warm_ms_mean": float(np.mean(warm)),
|
|
167
|
+
"warm_ms_std": float(np.std(warm)),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"font_size": font_size,
|
|
172
|
+
"repeats": repeats,
|
|
173
|
+
"per_text": per_text,
|
|
174
|
+
"cold_ms_mean_overall": float(np.mean([r["cold_ms_mean"] for r in per_text])),
|
|
175
|
+
"warm_ms_mean_overall": float(np.mean([r["warm_ms_mean"] for r in per_text])),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------
|
|
180
|
+
# Before/after comparison figure
|
|
181
|
+
# ---------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def save_comparison_figure(
|
|
184
|
+
output_path: str,
|
|
185
|
+
samples: Optional[Dict[str, str]] = None,
|
|
186
|
+
font_size: int = 30,
|
|
187
|
+
dpi: int = 200,
|
|
188
|
+
left_title: str = "Matplotlib default",
|
|
189
|
+
right_title: str = "universal_render",
|
|
190
|
+
):
|
|
191
|
+
"""
|
|
192
|
+
Produce a two-column figure: each sample rendered by Matplotlib's
|
|
193
|
+
own text engine (left) and by universal_render (right). This is the
|
|
194
|
+
before/after evidence figure for each script family.
|
|
195
|
+
|
|
196
|
+
``samples`` maps a row label to the text, defaulting to
|
|
197
|
+
SCRIPT_TEST_SAMPLES. Returns the Matplotlib figure.
|
|
198
|
+
|
|
199
|
+
Matplotlib emits "glyph missing" warnings for the left panel; that
|
|
200
|
+
is the defect being demonstrated, so those warnings are suppressed.
|
|
201
|
+
"""
|
|
202
|
+
import warnings
|
|
203
|
+
|
|
204
|
+
import matplotlib.pyplot as plt
|
|
205
|
+
from matplotlib.offsetbox import AnnotationBbox, OffsetImage
|
|
206
|
+
|
|
207
|
+
if samples is None:
|
|
208
|
+
samples = dict(SCRIPT_TEST_SAMPLES)
|
|
209
|
+
|
|
210
|
+
n = len(samples)
|
|
211
|
+
fig_h = max(2.0, 0.62 * n + 1.0)
|
|
212
|
+
fig, axes = plt.subplots(1, 2, figsize=(11, fig_h))
|
|
213
|
+
|
|
214
|
+
for ax, title in zip(axes, (left_title, right_title)):
|
|
215
|
+
ax.set_xlim(0, 1)
|
|
216
|
+
ax.set_ylim(0, n)
|
|
217
|
+
ax.set_title(title, fontsize=13)
|
|
218
|
+
ax.axis("off")
|
|
219
|
+
|
|
220
|
+
for i, (label, text) in enumerate(samples.items()):
|
|
221
|
+
y = n - i - 0.5
|
|
222
|
+
|
|
223
|
+
# Row label on the left margin of the left panel
|
|
224
|
+
axes[0].text(
|
|
225
|
+
-0.02, y, label, fontsize=10, ha="right", va="center",
|
|
226
|
+
transform=axes[0].transData, clip_on=False,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Left: Matplotlib's own text engine (typically broken shaping)
|
|
230
|
+
axes[0].text(
|
|
231
|
+
0.5, y, text, fontsize=font_size * 0.55,
|
|
232
|
+
ha="center", va="center",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Right: Qt-shaped image artist
|
|
236
|
+
arr = render_text_array(text, font_size=font_size) / 255.0
|
|
237
|
+
oi = OffsetImage(arr, zoom=0.5)
|
|
238
|
+
ab = AnnotationBbox(
|
|
239
|
+
oi, (0.5, y), frameon=False, box_alignment=(0.5, 0.5),
|
|
240
|
+
annotation_clip=False,
|
|
241
|
+
)
|
|
242
|
+
axes[1].add_artist(ab)
|
|
243
|
+
|
|
244
|
+
with warnings.catch_warnings():
|
|
245
|
+
warnings.filterwarnings("ignore", message=".*[Gg]lyph.*missing.*")
|
|
246
|
+
warnings.filterwarnings("ignore", message=".*does not support.*")
|
|
247
|
+
fig.tight_layout()
|
|
248
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
249
|
+
return fig
|