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,806 @@
|
|
|
1
|
+
# universal_render/renderer.py
|
|
2
|
+
"""
|
|
3
|
+
Qt-based text rasterization.
|
|
4
|
+
|
|
5
|
+
Text is shaped by Qt's text stack (HarfBuzz underneath), which handles
|
|
6
|
+
conjunct formation, vowel reordering, contextual shaping, combining
|
|
7
|
+
marks, and bidirectional (RTL) layout. Each string is rendered to a
|
|
8
|
+
transparent RGBA image that Matplotlib embeds as an image artist.
|
|
9
|
+
|
|
10
|
+
Script-awareness: when no font is specified, the family is chosen from
|
|
11
|
+
the text's dominant Unicode script (see fonts.resolve_font). Font
|
|
12
|
+
merging stays enabled, so mixed-script strings (e.g. Bengali + English
|
|
13
|
+
+ numbers) draw seamlessly — secondary runs fall back to a suitable
|
|
14
|
+
installed font at draw time.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from dataclasses import dataclass, asdict
|
|
20
|
+
from typing import Any, Dict, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from .backend import ensure_qt_application
|
|
25
|
+
from .fonts import resolve_font
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from PySide6.QtCore import Qt, QRect, QRectF
|
|
29
|
+
from PySide6.QtGui import (
|
|
30
|
+
QColor,
|
|
31
|
+
QFont,
|
|
32
|
+
QFontMetrics,
|
|
33
|
+
QImage,
|
|
34
|
+
QPainter,
|
|
35
|
+
QPen,
|
|
36
|
+
QTextOption,
|
|
37
|
+
)
|
|
38
|
+
QT_RENDER_AVAILABLE = True
|
|
39
|
+
QT_RENDER_IMPORT_ERROR = None
|
|
40
|
+
except Exception as e: # pragma: no cover
|
|
41
|
+
Qt = None
|
|
42
|
+
QRect = None
|
|
43
|
+
QRectF = None
|
|
44
|
+
QColor = None
|
|
45
|
+
QFont = None
|
|
46
|
+
QFontMetrics = None
|
|
47
|
+
QImage = None
|
|
48
|
+
QPainter = None
|
|
49
|
+
QPen = None
|
|
50
|
+
QTextOption = None
|
|
51
|
+
QT_RENDER_AVAILABLE = False
|
|
52
|
+
QT_RENDER_IMPORT_ERROR = e
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------
|
|
56
|
+
# Defaults / cache
|
|
57
|
+
# ---------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_RENDER_CACHE_MAXSIZE = 256
|
|
60
|
+
_RENDER_CACHE: "OrderedDict[Tuple[Any, ...], QImage]" = OrderedDict()
|
|
61
|
+
|
|
62
|
+
_RENDER_DEFAULTS: Dict[str, Any] = {
|
|
63
|
+
"color": "black",
|
|
64
|
+
"bg": "transparent",
|
|
65
|
+
"padding": 10,
|
|
66
|
+
"scale": 1.0,
|
|
67
|
+
"trim": True,
|
|
68
|
+
"trim_margin_px": 1,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RenderParams:
|
|
74
|
+
text: str
|
|
75
|
+
font_family: str
|
|
76
|
+
font_path: Optional[str]
|
|
77
|
+
font_size: int
|
|
78
|
+
color: str
|
|
79
|
+
bg: str
|
|
80
|
+
padding: int
|
|
81
|
+
scale: float
|
|
82
|
+
weight: Optional[str] = None
|
|
83
|
+
italic: bool = False
|
|
84
|
+
alpha: float = 1.0
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
87
|
+
return asdict(self)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# CSS-like weight names/numbers -> QFont.Weight
|
|
91
|
+
_WEIGHT_MAP = {
|
|
92
|
+
"thin": 100, "extralight": 200, "ultralight": 200, "light": 300,
|
|
93
|
+
"normal": 400, "regular": 400, "medium": 500, "demibold": 600,
|
|
94
|
+
"semibold": 600, "bold": 700, "extrabold": 800, "ultrabold": 800,
|
|
95
|
+
"heavy": 900, "black": 900,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_weight(weight) -> Optional[int]:
|
|
100
|
+
if weight is None:
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(weight, (int, float)):
|
|
103
|
+
return int(min(900, max(100, weight)))
|
|
104
|
+
return _WEIGHT_MAP.get(str(weight).strip().lower(), None)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------
|
|
108
|
+
# Core helpers
|
|
109
|
+
# ---------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _ensure_runtime() -> None:
|
|
112
|
+
ensure_qt_application()
|
|
113
|
+
if not QT_RENDER_AVAILABLE:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
"Qt rendering classes are not available. "
|
|
116
|
+
f"Original import error: {QT_RENDER_IMPORT_ERROR}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize_color(color: str) -> QColor:
|
|
121
|
+
qc = QColor(color)
|
|
122
|
+
if not qc.isValid():
|
|
123
|
+
qc = QColor("black")
|
|
124
|
+
return qc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _normalize_bg(bg: str) -> Optional[QColor]:
|
|
128
|
+
if bg is None:
|
|
129
|
+
return None
|
|
130
|
+
s = str(bg).strip().lower()
|
|
131
|
+
if s in ("", "none", "transparent", "rgba(0,0,0,0)"):
|
|
132
|
+
return None
|
|
133
|
+
qc = QColor(bg)
|
|
134
|
+
if not qc.isValid():
|
|
135
|
+
return None
|
|
136
|
+
return qc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _font_for_render(
|
|
140
|
+
font_family: str,
|
|
141
|
+
font_size: int,
|
|
142
|
+
scale: float = 1.0,
|
|
143
|
+
weight=None,
|
|
144
|
+
italic: bool = False,
|
|
145
|
+
) -> QFont:
|
|
146
|
+
scaled_size = max(1, int(round(font_size * scale)))
|
|
147
|
+
font = QFont(font_family)
|
|
148
|
+
font.setPixelSize(scaled_size)
|
|
149
|
+
w = _normalize_weight(weight)
|
|
150
|
+
if w is not None:
|
|
151
|
+
font.setWeight(QFont.Weight(w))
|
|
152
|
+
if italic:
|
|
153
|
+
font.setItalic(True)
|
|
154
|
+
# Font merging intentionally stays enabled (Qt default) so that
|
|
155
|
+
# mixed-script text renders even when the primary family lacks
|
|
156
|
+
# glyphs for secondary runs.
|
|
157
|
+
return font
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _rotate_qimage(qimg: QImage, rotation: float) -> QImage:
|
|
161
|
+
"""
|
|
162
|
+
Rotate counter-clockwise by ``rotation`` degrees (Matplotlib
|
|
163
|
+
convention), keeping transparency and smooth resampling.
|
|
164
|
+
"""
|
|
165
|
+
from PySide6.QtGui import QTransform
|
|
166
|
+
# Qt device coordinates have y pointing down, so a positive Qt angle
|
|
167
|
+
# is clockwise on screen; negate to match Matplotlib's CCW rotation.
|
|
168
|
+
t = QTransform().rotate(-float(rotation))
|
|
169
|
+
return qimg.transformed(t, Qt.TransformationMode.SmoothTransformation)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _trim_rgba_alpha_bounds(alpha: np.ndarray, threshold: int = 0) -> Optional[Tuple[int, int, int, int]]:
|
|
173
|
+
"""
|
|
174
|
+
Return (top, bottom, left, right) bounds inclusive-exclusive
|
|
175
|
+
for non-empty alpha content.
|
|
176
|
+
"""
|
|
177
|
+
if alpha.size == 0:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
ys, xs = np.where(alpha > threshold)
|
|
181
|
+
if len(xs) == 0 or len(ys) == 0:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
top = int(ys.min())
|
|
185
|
+
bottom = int(ys.max()) + 1
|
|
186
|
+
left = int(xs.min())
|
|
187
|
+
right = int(xs.max()) + 1
|
|
188
|
+
return top, bottom, left, right
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _qimage_to_rgba_uint8(qimg: QImage) -> np.ndarray:
|
|
192
|
+
qimg = qimg.convertToFormat(QImage.Format.Format_RGBA8888)
|
|
193
|
+
w, h = qimg.width(), qimg.height()
|
|
194
|
+
ptr = qimg.bits()
|
|
195
|
+
buf = ptr.tobytes()
|
|
196
|
+
return np.frombuffer(buf, np.uint8).reshape((h, w, 4)).copy()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _rgba_uint8_to_qimage(arr: np.ndarray) -> QImage:
|
|
200
|
+
arr = np.ascontiguousarray(arr)
|
|
201
|
+
h, w, _ = arr.shape
|
|
202
|
+
qimg = QImage(arr.data, w, h, 4 * w, QImage.Format.Format_RGBA8888)
|
|
203
|
+
return qimg.copy()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _trim_transparent_borders(
|
|
207
|
+
qimg: QImage,
|
|
208
|
+
*,
|
|
209
|
+
margin_px: int = 1,
|
|
210
|
+
alpha_threshold: int = 0,
|
|
211
|
+
) -> QImage:
|
|
212
|
+
"""
|
|
213
|
+
Trim fully transparent borders and keep a small safety margin.
|
|
214
|
+
"""
|
|
215
|
+
arr = _qimage_to_rgba_uint8(qimg)
|
|
216
|
+
bounds = _trim_rgba_alpha_bounds(arr[:, :, 3], threshold=alpha_threshold)
|
|
217
|
+
|
|
218
|
+
if bounds is None:
|
|
219
|
+
return qimg
|
|
220
|
+
|
|
221
|
+
top, bottom, left, right = bounds
|
|
222
|
+
|
|
223
|
+
top = max(0, top - margin_px)
|
|
224
|
+
bottom = min(arr.shape[0], bottom + margin_px)
|
|
225
|
+
left = max(0, left - margin_px)
|
|
226
|
+
right = min(arr.shape[1], right + margin_px)
|
|
227
|
+
|
|
228
|
+
cropped = np.ascontiguousarray(arr[top:bottom, left:right, :])
|
|
229
|
+
if cropped.size == 0:
|
|
230
|
+
return qimg
|
|
231
|
+
return _rgba_uint8_to_qimage(cropped)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _make_cache_key(params: RenderParams) -> Tuple[Any, ...]:
|
|
235
|
+
return (
|
|
236
|
+
params.text,
|
|
237
|
+
params.font_family,
|
|
238
|
+
params.font_path,
|
|
239
|
+
params.font_size,
|
|
240
|
+
params.color,
|
|
241
|
+
params.bg,
|
|
242
|
+
params.padding,
|
|
243
|
+
round(float(params.scale), 4),
|
|
244
|
+
params.weight,
|
|
245
|
+
params.italic,
|
|
246
|
+
round(float(params.alpha), 4),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _get_cached_qimage(key: Tuple[Any, ...]) -> Optional[QImage]:
|
|
251
|
+
cached = _RENDER_CACHE.get(key)
|
|
252
|
+
if cached is None:
|
|
253
|
+
return None
|
|
254
|
+
_RENDER_CACHE.move_to_end(key)
|
|
255
|
+
return cached.copy()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _set_cached_qimage(key: Tuple[Any, ...], qimg: QImage) -> None:
|
|
259
|
+
_RENDER_CACHE[key] = qimg.copy()
|
|
260
|
+
_RENDER_CACHE.move_to_end(key)
|
|
261
|
+
while len(_RENDER_CACHE) > _RENDER_CACHE_MAXSIZE:
|
|
262
|
+
_RENDER_CACHE.popitem(last=False)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_render_cache_info() -> Dict[str, int]:
|
|
266
|
+
return {
|
|
267
|
+
"size": len(_RENDER_CACHE),
|
|
268
|
+
"maxsize": _RENDER_CACHE_MAXSIZE,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def clear_render_cache() -> None:
|
|
273
|
+
_RENDER_CACHE.clear()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def set_render_cache_maxsize(maxsize: int) -> int:
|
|
277
|
+
"""
|
|
278
|
+
Set the maximum number of cached rendered text images.
|
|
279
|
+
"""
|
|
280
|
+
global _RENDER_CACHE_MAXSIZE
|
|
281
|
+
|
|
282
|
+
maxsize = int(maxsize)
|
|
283
|
+
if maxsize < 1:
|
|
284
|
+
raise ValueError("maxsize must be at least 1")
|
|
285
|
+
|
|
286
|
+
_RENDER_CACHE_MAXSIZE = maxsize
|
|
287
|
+
|
|
288
|
+
while len(_RENDER_CACHE) > _RENDER_CACHE_MAXSIZE:
|
|
289
|
+
_RENDER_CACHE.popitem(last=False)
|
|
290
|
+
|
|
291
|
+
return _RENDER_CACHE_MAXSIZE
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def set_render_defaults(
|
|
295
|
+
*,
|
|
296
|
+
color: Optional[str] = None,
|
|
297
|
+
bg: Optional[str] = None,
|
|
298
|
+
padding: Optional[int] = None,
|
|
299
|
+
scale: Optional[float] = None,
|
|
300
|
+
trim: Optional[bool] = None,
|
|
301
|
+
trim_margin_px: Optional[int] = None,
|
|
302
|
+
) -> Dict[str, Any]:
|
|
303
|
+
"""
|
|
304
|
+
Set package-level render defaults.
|
|
305
|
+
"""
|
|
306
|
+
if color is not None:
|
|
307
|
+
_RENDER_DEFAULTS["color"] = str(color)
|
|
308
|
+
if bg is not None:
|
|
309
|
+
_RENDER_DEFAULTS["bg"] = str(bg)
|
|
310
|
+
if padding is not None:
|
|
311
|
+
_RENDER_DEFAULTS["padding"] = int(padding)
|
|
312
|
+
if scale is not None:
|
|
313
|
+
_RENDER_DEFAULTS["scale"] = float(scale)
|
|
314
|
+
if trim is not None:
|
|
315
|
+
_RENDER_DEFAULTS["trim"] = bool(trim)
|
|
316
|
+
if trim_margin_px is not None:
|
|
317
|
+
_RENDER_DEFAULTS["trim_margin_px"] = int(trim_margin_px)
|
|
318
|
+
|
|
319
|
+
return dict(_RENDER_DEFAULTS)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def get_render_defaults() -> Dict[str, Any]:
|
|
323
|
+
"""
|
|
324
|
+
Return current render defaults.
|
|
325
|
+
"""
|
|
326
|
+
return dict(_RENDER_DEFAULTS)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------
|
|
330
|
+
# Measurement
|
|
331
|
+
# ---------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def _resolve_render_params(
|
|
334
|
+
text: str,
|
|
335
|
+
font_family: Optional[str] = None,
|
|
336
|
+
font_path: Optional[str] = None,
|
|
337
|
+
font_size: int = 24,
|
|
338
|
+
color: Optional[str] = None,
|
|
339
|
+
bg: Optional[str] = None,
|
|
340
|
+
padding: Optional[int] = None,
|
|
341
|
+
scale: Optional[float] = None,
|
|
342
|
+
weight=None,
|
|
343
|
+
italic: bool = False,
|
|
344
|
+
alpha: float = 1.0,
|
|
345
|
+
) -> RenderParams:
|
|
346
|
+
_ensure_runtime()
|
|
347
|
+
|
|
348
|
+
# Script-aware resolution: the text itself drives the font choice
|
|
349
|
+
# when no explicit family/path is given.
|
|
350
|
+
resolved_family = resolve_font(
|
|
351
|
+
font_family=font_family,
|
|
352
|
+
font_path=font_path,
|
|
353
|
+
text=str(text),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if color is None:
|
|
357
|
+
color = _RENDER_DEFAULTS["color"]
|
|
358
|
+
if bg is None:
|
|
359
|
+
bg = _RENDER_DEFAULTS["bg"]
|
|
360
|
+
if padding is None:
|
|
361
|
+
padding = _RENDER_DEFAULTS["padding"]
|
|
362
|
+
if scale is None:
|
|
363
|
+
scale = _RENDER_DEFAULTS["scale"]
|
|
364
|
+
|
|
365
|
+
return RenderParams(
|
|
366
|
+
text=str(text),
|
|
367
|
+
font_family=resolved_family,
|
|
368
|
+
font_path=font_path,
|
|
369
|
+
font_size=int(font_size),
|
|
370
|
+
color=str(color),
|
|
371
|
+
bg=str(bg),
|
|
372
|
+
padding=int(padding),
|
|
373
|
+
scale=float(scale),
|
|
374
|
+
weight=weight,
|
|
375
|
+
italic=bool(italic),
|
|
376
|
+
alpha=float(alpha),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def measure_text(
|
|
381
|
+
text: str,
|
|
382
|
+
font_family: Optional[str] = None,
|
|
383
|
+
font_path: Optional[str] = None,
|
|
384
|
+
font_size: int = 24,
|
|
385
|
+
padding: Optional[int] = None,
|
|
386
|
+
scale: Optional[float] = None,
|
|
387
|
+
) -> Dict[str, Any]:
|
|
388
|
+
"""
|
|
389
|
+
Measure single-line text before rendering.
|
|
390
|
+
"""
|
|
391
|
+
params = _resolve_render_params(
|
|
392
|
+
text=text,
|
|
393
|
+
font_family=font_family,
|
|
394
|
+
font_path=font_path,
|
|
395
|
+
font_size=font_size,
|
|
396
|
+
padding=padding,
|
|
397
|
+
scale=scale,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
font = _font_for_render(params.font_family, params.font_size, params.scale)
|
|
401
|
+
fm = QFontMetrics(font)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
rect = fm.boundingRect(params.text)
|
|
405
|
+
except Exception:
|
|
406
|
+
rect = QRect(0, 0, 1, 1)
|
|
407
|
+
|
|
408
|
+
text_w = max(1, rect.width())
|
|
409
|
+
text_h = max(1, rect.height())
|
|
410
|
+
ascent = fm.ascent()
|
|
411
|
+
descent = fm.descent()
|
|
412
|
+
|
|
413
|
+
full_w = text_w + (2 * params.padding)
|
|
414
|
+
full_h = text_h + (2 * params.padding)
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
"font_family": params.font_family,
|
|
418
|
+
"font_size": params.font_size,
|
|
419
|
+
"scale": params.scale,
|
|
420
|
+
"text_width_px": text_w,
|
|
421
|
+
"text_height_px": text_h,
|
|
422
|
+
"image_width_px": full_w,
|
|
423
|
+
"image_height_px": full_h,
|
|
424
|
+
"ascent_px": ascent,
|
|
425
|
+
"descent_px": descent,
|
|
426
|
+
"bounding_rect": {
|
|
427
|
+
"x": rect.x(),
|
|
428
|
+
"y": rect.y(),
|
|
429
|
+
"width": rect.width(),
|
|
430
|
+
"height": rect.height(),
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------
|
|
436
|
+
# Single-line render
|
|
437
|
+
# ---------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def render_text_qimage(
|
|
440
|
+
text: str,
|
|
441
|
+
font_family: Optional[str] = None,
|
|
442
|
+
font_path: Optional[str] = None,
|
|
443
|
+
font_size: int = 24,
|
|
444
|
+
color: Optional[str] = None,
|
|
445
|
+
bg: Optional[str] = None,
|
|
446
|
+
padding: Optional[int] = None,
|
|
447
|
+
scale: Optional[float] = None,
|
|
448
|
+
trim: Optional[bool] = None,
|
|
449
|
+
trim_margin_px: Optional[int] = None,
|
|
450
|
+
weight=None,
|
|
451
|
+
italic: bool = False,
|
|
452
|
+
alpha: float = 1.0,
|
|
453
|
+
rotation: float = 0.0,
|
|
454
|
+
align: str = "center",
|
|
455
|
+
) -> QImage:
|
|
456
|
+
"""
|
|
457
|
+
Render a text string of any script to a QImage.
|
|
458
|
+
|
|
459
|
+
Shaping (conjuncts, reordering, contextual forms) and bidirectional
|
|
460
|
+
layout are handled by Qt; RTL strings such as Arabic or Hebrew come
|
|
461
|
+
out in correct visual order. Transparent borders are trimmed so
|
|
462
|
+
layout boxes track the visible ink.
|
|
463
|
+
|
|
464
|
+
Matplotlib-style styling:
|
|
465
|
+
- ``weight``: "bold", "light", ..., or a 100-900 number
|
|
466
|
+
- ``italic``: italic style
|
|
467
|
+
- ``alpha``: 0..1 text opacity
|
|
468
|
+
- ``rotation``: counter-clockwise degrees (any angle)
|
|
469
|
+
- ``align``: line alignment for multiline text ("left"/"center"/"right")
|
|
470
|
+
|
|
471
|
+
Strings containing ``\\n`` render as multiple lines.
|
|
472
|
+
"""
|
|
473
|
+
if trim is None:
|
|
474
|
+
trim = _RENDER_DEFAULTS["trim"]
|
|
475
|
+
if trim_margin_px is None:
|
|
476
|
+
trim_margin_px = _RENDER_DEFAULTS["trim_margin_px"]
|
|
477
|
+
|
|
478
|
+
params = _resolve_render_params(
|
|
479
|
+
text=text,
|
|
480
|
+
font_family=font_family,
|
|
481
|
+
font_path=font_path,
|
|
482
|
+
font_size=font_size,
|
|
483
|
+
color=color,
|
|
484
|
+
bg=bg,
|
|
485
|
+
padding=padding,
|
|
486
|
+
scale=scale,
|
|
487
|
+
weight=weight,
|
|
488
|
+
italic=italic,
|
|
489
|
+
alpha=alpha,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
key = _make_cache_key(params) + (
|
|
493
|
+
bool(trim), int(trim_margin_px),
|
|
494
|
+
round(float(rotation), 2) % 360.0, str(align),
|
|
495
|
+
)
|
|
496
|
+
cached = _get_cached_qimage(key)
|
|
497
|
+
if cached is not None:
|
|
498
|
+
return cached
|
|
499
|
+
|
|
500
|
+
font = _font_for_render(
|
|
501
|
+
params.font_family, params.font_size, params.scale,
|
|
502
|
+
weight=params.weight, italic=params.italic,
|
|
503
|
+
)
|
|
504
|
+
fm = QFontMetrics(font)
|
|
505
|
+
|
|
506
|
+
multiline = "\n" in params.text
|
|
507
|
+
if multiline:
|
|
508
|
+
align_flag = {
|
|
509
|
+
"left": Qt.AlignmentFlag.AlignLeft,
|
|
510
|
+
"right": Qt.AlignmentFlag.AlignRight,
|
|
511
|
+
}.get(str(align).lower(), Qt.AlignmentFlag.AlignHCenter)
|
|
512
|
+
flags = int(align_flag)
|
|
513
|
+
rect = fm.boundingRect(QRect(0, 0, 1_000_000, 1_000_000), flags, params.text)
|
|
514
|
+
else:
|
|
515
|
+
rect = fm.boundingRect(params.text)
|
|
516
|
+
|
|
517
|
+
text_w = max(1, rect.width())
|
|
518
|
+
text_h = max(1, rect.height())
|
|
519
|
+
|
|
520
|
+
img_w = max(1, text_w + (2 * params.padding))
|
|
521
|
+
img_h = max(1, text_h + (2 * params.padding))
|
|
522
|
+
|
|
523
|
+
qimg = QImage(img_w, img_h, QImage.Format.Format_ARGB32)
|
|
524
|
+
qimg.fill(Qt.GlobalColor.transparent)
|
|
525
|
+
|
|
526
|
+
painter = QPainter(qimg)
|
|
527
|
+
try:
|
|
528
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
529
|
+
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
530
|
+
painter.setFont(font)
|
|
531
|
+
|
|
532
|
+
bg_qc = _normalize_bg(params.bg)
|
|
533
|
+
if bg_qc is not None:
|
|
534
|
+
painter.fillRect(0, 0, img_w, img_h, bg_qc)
|
|
535
|
+
|
|
536
|
+
qc = _normalize_color(params.color)
|
|
537
|
+
if params.alpha < 1.0:
|
|
538
|
+
qc.setAlphaF(max(0.0, min(1.0, qc.alphaF() * params.alpha)))
|
|
539
|
+
painter.setPen(QPen(qc))
|
|
540
|
+
|
|
541
|
+
if multiline:
|
|
542
|
+
draw_rect = QRect(
|
|
543
|
+
params.padding, params.padding, text_w, text_h,
|
|
544
|
+
)
|
|
545
|
+
painter.drawText(draw_rect, flags, params.text)
|
|
546
|
+
else:
|
|
547
|
+
x = params.padding - rect.left()
|
|
548
|
+
y = params.padding - rect.top()
|
|
549
|
+
painter.drawText(x, y, params.text)
|
|
550
|
+
finally:
|
|
551
|
+
painter.end()
|
|
552
|
+
|
|
553
|
+
if trim:
|
|
554
|
+
qimg = _trim_transparent_borders(qimg, margin_px=int(trim_margin_px))
|
|
555
|
+
|
|
556
|
+
if float(rotation) % 360.0 != 0.0:
|
|
557
|
+
qimg = _rotate_qimage(qimg, rotation)
|
|
558
|
+
|
|
559
|
+
_set_cached_qimage(key, qimg)
|
|
560
|
+
return qimg.copy()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def render_text_array(
|
|
564
|
+
text: str,
|
|
565
|
+
font_family: Optional[str] = None,
|
|
566
|
+
font_path: Optional[str] = None,
|
|
567
|
+
font_size: int = 24,
|
|
568
|
+
color: Optional[str] = None,
|
|
569
|
+
bg: Optional[str] = None,
|
|
570
|
+
padding: Optional[int] = None,
|
|
571
|
+
scale: Optional[float] = None,
|
|
572
|
+
trim: Optional[bool] = None,
|
|
573
|
+
trim_margin_px: Optional[int] = None,
|
|
574
|
+
**style,
|
|
575
|
+
) -> np.ndarray:
|
|
576
|
+
qimg = render_text_qimage(
|
|
577
|
+
text=text,
|
|
578
|
+
font_family=font_family,
|
|
579
|
+
font_path=font_path,
|
|
580
|
+
font_size=font_size,
|
|
581
|
+
color=color,
|
|
582
|
+
bg=bg,
|
|
583
|
+
padding=padding,
|
|
584
|
+
scale=scale,
|
|
585
|
+
trim=trim,
|
|
586
|
+
trim_margin_px=trim_margin_px,
|
|
587
|
+
**style,
|
|
588
|
+
)
|
|
589
|
+
return _qimage_to_rgba_uint8(qimg)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def render_text(
|
|
593
|
+
text: str,
|
|
594
|
+
output_path: Optional[str] = None,
|
|
595
|
+
font_family: Optional[str] = None,
|
|
596
|
+
font_path: Optional[str] = None,
|
|
597
|
+
font_size: int = 24,
|
|
598
|
+
width: Optional[int] = None,
|
|
599
|
+
height: Optional[int] = None,
|
|
600
|
+
color: Optional[str] = None,
|
|
601
|
+
bg: Optional[str] = None,
|
|
602
|
+
padding: Optional[int] = None,
|
|
603
|
+
scale: Optional[float] = None,
|
|
604
|
+
trim: Optional[bool] = None,
|
|
605
|
+
trim_margin_px: Optional[int] = None,
|
|
606
|
+
**style,
|
|
607
|
+
):
|
|
608
|
+
"""
|
|
609
|
+
Public render helper; optionally saves to ``output_path``.
|
|
610
|
+
Accepts the same styling keywords as render_text_qimage()
|
|
611
|
+
(weight, italic, alpha, rotation, align).
|
|
612
|
+
"""
|
|
613
|
+
qimg = render_text_qimage(
|
|
614
|
+
text=text,
|
|
615
|
+
font_family=font_family,
|
|
616
|
+
font_path=font_path,
|
|
617
|
+
font_size=font_size,
|
|
618
|
+
color=color,
|
|
619
|
+
bg=bg,
|
|
620
|
+
padding=padding,
|
|
621
|
+
scale=scale,
|
|
622
|
+
trim=trim,
|
|
623
|
+
trim_margin_px=trim_margin_px,
|
|
624
|
+
**style,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
if width is not None or height is not None:
|
|
628
|
+
target_w = max(qimg.width(), int(width) if width is not None else qimg.width())
|
|
629
|
+
target_h = max(qimg.height(), int(height) if height is not None else qimg.height())
|
|
630
|
+
if target_w != qimg.width() or target_h != qimg.height():
|
|
631
|
+
canvas = QImage(target_w, target_h, QImage.Format.Format_ARGB32)
|
|
632
|
+
bg_qc = _normalize_bg(bg if bg is not None else _RENDER_DEFAULTS["bg"])
|
|
633
|
+
if bg_qc is None:
|
|
634
|
+
canvas.fill(Qt.GlobalColor.transparent)
|
|
635
|
+
else:
|
|
636
|
+
canvas.fill(bg_qc)
|
|
637
|
+
|
|
638
|
+
painter = QPainter(canvas)
|
|
639
|
+
try:
|
|
640
|
+
painter.drawImage(0, 0, qimg)
|
|
641
|
+
finally:
|
|
642
|
+
painter.end()
|
|
643
|
+
qimg = canvas
|
|
644
|
+
|
|
645
|
+
if output_path:
|
|
646
|
+
ok = qimg.save(output_path)
|
|
647
|
+
if not ok:
|
|
648
|
+
raise RuntimeError(f"Failed to save rendered image to: {output_path}")
|
|
649
|
+
|
|
650
|
+
return qimg
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def render_mixed_text(text: str, **kwargs):
|
|
654
|
+
"""
|
|
655
|
+
Render mixed-script content (e.g. Bengali + English + numbers).
|
|
656
|
+
|
|
657
|
+
The dominant script chooses the primary font; Qt's shaping engine
|
|
658
|
+
lays out each run and font merging covers glyphs the primary family
|
|
659
|
+
lacks. Accepts the same keyword arguments as render_text().
|
|
660
|
+
"""
|
|
661
|
+
return render_text(text, **kwargs)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# ---------------------------------------------------------------------
|
|
665
|
+
# Paragraph render
|
|
666
|
+
# ---------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
def _estimate_paragraph_height(width: int, font: QFont, text: str, margin: int) -> int:
|
|
669
|
+
fm = QFontMetrics(font)
|
|
670
|
+
inner_w = max(1, width - (2 * margin))
|
|
671
|
+
rect = fm.boundingRect(QRect(0, 0, inner_w, 100000), int(Qt.TextFlag.TextWordWrap), text)
|
|
672
|
+
return max(1, rect.height() + (2 * margin))
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def render_paragraph_qimage(
|
|
676
|
+
text: str,
|
|
677
|
+
width: int = 600,
|
|
678
|
+
height: Optional[int] = None,
|
|
679
|
+
font_family: Optional[str] = None,
|
|
680
|
+
font_path: Optional[str] = None,
|
|
681
|
+
font_size: int = 24,
|
|
682
|
+
color: Optional[str] = None,
|
|
683
|
+
bg: Optional[str] = None,
|
|
684
|
+
margin: int = 12,
|
|
685
|
+
scale: Optional[float] = None,
|
|
686
|
+
trim: Optional[bool] = None,
|
|
687
|
+
trim_margin_px: Optional[int] = None,
|
|
688
|
+
align: Optional[str] = None,
|
|
689
|
+
) -> QImage:
|
|
690
|
+
"""
|
|
691
|
+
Render wrapped paragraph text to a QImage.
|
|
692
|
+
|
|
693
|
+
``align`` may be "left", "right", "center", or None. When None, RTL
|
|
694
|
+
paragraphs (dominant Arabic/Hebrew script) align right and others
|
|
695
|
+
left, matching reader expectations.
|
|
696
|
+
"""
|
|
697
|
+
_ensure_runtime()
|
|
698
|
+
|
|
699
|
+
from .scripts import is_rtl_text # local import to avoid cycles
|
|
700
|
+
|
|
701
|
+
if color is None:
|
|
702
|
+
color = _RENDER_DEFAULTS["color"]
|
|
703
|
+
if bg is None:
|
|
704
|
+
bg = _RENDER_DEFAULTS["bg"]
|
|
705
|
+
if scale is None:
|
|
706
|
+
scale = _RENDER_DEFAULTS["scale"]
|
|
707
|
+
if trim is None:
|
|
708
|
+
trim = _RENDER_DEFAULTS["trim"]
|
|
709
|
+
if trim_margin_px is None:
|
|
710
|
+
trim_margin_px = _RENDER_DEFAULTS["trim_margin_px"]
|
|
711
|
+
|
|
712
|
+
resolved_family = resolve_font(
|
|
713
|
+
font_family=font_family, font_path=font_path, text=str(text)
|
|
714
|
+
)
|
|
715
|
+
font = _font_for_render(resolved_family, font_size, scale)
|
|
716
|
+
|
|
717
|
+
width = max(1, int(width))
|
|
718
|
+
margin = max(0, int(margin))
|
|
719
|
+
|
|
720
|
+
if height is None:
|
|
721
|
+
height = _estimate_paragraph_height(width, font, str(text), margin)
|
|
722
|
+
else:
|
|
723
|
+
height = max(1, int(height))
|
|
724
|
+
|
|
725
|
+
qimg = QImage(width, height, QImage.Format.Format_ARGB32)
|
|
726
|
+
bg_qc = _normalize_bg(bg)
|
|
727
|
+
if bg_qc is None:
|
|
728
|
+
qimg.fill(Qt.GlobalColor.transparent)
|
|
729
|
+
else:
|
|
730
|
+
qimg.fill(bg_qc)
|
|
731
|
+
|
|
732
|
+
painter = QPainter(qimg)
|
|
733
|
+
try:
|
|
734
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
735
|
+
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
736
|
+
painter.setFont(font)
|
|
737
|
+
painter.setPen(QPen(_normalize_color(color)))
|
|
738
|
+
|
|
739
|
+
text_rect = QRectF(
|
|
740
|
+
float(margin),
|
|
741
|
+
float(margin),
|
|
742
|
+
float(max(1, width - (2 * margin))),
|
|
743
|
+
float(max(1, height - (2 * margin))),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
option = QTextOption()
|
|
747
|
+
option.setWrapMode(QTextOption.WrapMode.WordWrap)
|
|
748
|
+
|
|
749
|
+
if align is None:
|
|
750
|
+
align = "right" if is_rtl_text(str(text)) else "left"
|
|
751
|
+
align = str(align).lower()
|
|
752
|
+
if align == "right":
|
|
753
|
+
option.setAlignment(Qt.AlignmentFlag.AlignRight)
|
|
754
|
+
elif align == "center":
|
|
755
|
+
option.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
756
|
+
else:
|
|
757
|
+
option.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
758
|
+
|
|
759
|
+
painter.drawText(text_rect, str(text), option)
|
|
760
|
+
finally:
|
|
761
|
+
painter.end()
|
|
762
|
+
|
|
763
|
+
if trim and _normalize_bg(bg) is None:
|
|
764
|
+
qimg = _trim_transparent_borders(qimg, margin_px=int(trim_margin_px))
|
|
765
|
+
|
|
766
|
+
return qimg
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def render_paragraph(
|
|
770
|
+
text: str,
|
|
771
|
+
output_path: Optional[str] = None,
|
|
772
|
+
width: int = 600,
|
|
773
|
+
height: Optional[int] = None,
|
|
774
|
+
font_family: Optional[str] = None,
|
|
775
|
+
font_path: Optional[str] = None,
|
|
776
|
+
font_size: int = 24,
|
|
777
|
+
color: Optional[str] = None,
|
|
778
|
+
bg: Optional[str] = None,
|
|
779
|
+
margin: int = 12,
|
|
780
|
+
scale: Optional[float] = None,
|
|
781
|
+
trim: Optional[bool] = None,
|
|
782
|
+
trim_margin_px: Optional[int] = None,
|
|
783
|
+
align: Optional[str] = None,
|
|
784
|
+
):
|
|
785
|
+
qimg = render_paragraph_qimage(
|
|
786
|
+
text=text,
|
|
787
|
+
width=width,
|
|
788
|
+
height=height,
|
|
789
|
+
font_family=font_family,
|
|
790
|
+
font_path=font_path,
|
|
791
|
+
font_size=font_size,
|
|
792
|
+
color=color,
|
|
793
|
+
bg=bg,
|
|
794
|
+
margin=margin,
|
|
795
|
+
scale=scale,
|
|
796
|
+
trim=trim,
|
|
797
|
+
trim_margin_px=trim_margin_px,
|
|
798
|
+
align=align,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
if output_path:
|
|
802
|
+
ok = qimg.save(output_path)
|
|
803
|
+
if not ok:
|
|
804
|
+
raise RuntimeError(f"Failed to save rendered paragraph image to: {output_path}")
|
|
805
|
+
|
|
806
|
+
return qimg
|