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,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