mpl-richtext 0.1.4__tar.gz → 0.1.7__tar.gz
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.
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/PKG-INFO +3 -1
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext/core.py +242 -32
- mpl_richtext-0.1.7/mpl_richtext/shaping.py +291 -0
- mpl_richtext-0.1.7/mpl_richtext/version.py +1 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/PKG-INFO +3 -1
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/SOURCES.txt +1 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/requires.txt +2 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/pyproject.toml +2 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/setup.py +2 -0
- mpl_richtext-0.1.4/mpl_richtext/version.py +0 -1
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/.github/workflows/publish.yml +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/.gitignore +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/LICENSE +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/MANIFEST.in +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/README.md +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/examples/basic_usage.py +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/examples/mpl_richtext_examples.png +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext/__init__.py +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/dependency_links.txt +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/top_level.txt +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/setup.cfg +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/tests/__init__.py +0 -0
- {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/tests/test_basic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpl-richtext
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Rich text rendering for Matplotlib with multi-color and multi-style support
|
|
5
5
|
Home-page: https://github.com/ra8in/mpl-richtext
|
|
6
6
|
Author: Rabin Katel
|
|
@@ -27,6 +27,8 @@ Requires-Python: >=3.8
|
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: matplotlib>=3.5.0
|
|
30
|
+
Requires-Dist: uharfbuzz>=0.30.0
|
|
31
|
+
Requires-Dist: fonttools>=4.34.0
|
|
30
32
|
Provides-Extra: dev
|
|
31
33
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
34
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -2,8 +2,49 @@ import matplotlib.pyplot as plt
|
|
|
2
2
|
from matplotlib.axes import Axes
|
|
3
3
|
from matplotlib.text import Text
|
|
4
4
|
from matplotlib.lines import Line2D
|
|
5
|
+
from matplotlib.lines import Line2D
|
|
6
|
+
import matplotlib.font_manager as fm
|
|
7
|
+
from matplotlib.font_manager import FontProperties, findfont
|
|
5
8
|
from typing import List, Optional, Tuple, Union, Dict, Any
|
|
6
9
|
|
|
10
|
+
from .shaping import ShapedText, HarfbuzzShaper, HAS_HARFBUZZ
|
|
11
|
+
|
|
12
|
+
def _needs_complex_shaping(text: str) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Check if text contains characters from complex scripts that need HarfBuzz shaping.
|
|
15
|
+
Currently checks for Devanagari (used for Nepali, Hindi, Sanskrit, etc.)
|
|
16
|
+
"""
|
|
17
|
+
for char in text:
|
|
18
|
+
code = ord(char)
|
|
19
|
+
# Devanagari: U+0900 to U+097F
|
|
20
|
+
# Devanagari Extended: U+A8E0 to U+A8FF
|
|
21
|
+
# Vedic Extensions: U+1CD0 to U+1CFF
|
|
22
|
+
if (0x0900 <= code <= 0x097F or
|
|
23
|
+
0xA8E0 <= code <= 0xA8FF or
|
|
24
|
+
0x1CD0 <= code <= 0x1CFF):
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
def _resolve_font_path(kwargs: Dict[str, Any]) -> Optional[str]:
|
|
29
|
+
"""Helper to resolve font file path from text kwargs."""
|
|
30
|
+
fp = kwargs.get('fontproperties')
|
|
31
|
+
if fp:
|
|
32
|
+
return findfont(fp)
|
|
33
|
+
|
|
34
|
+
font = kwargs.get('fontfamily') or kwargs.get('family')
|
|
35
|
+
if not font:
|
|
36
|
+
# Fallback to default
|
|
37
|
+
font = plt.rcParams['font.family'][0]
|
|
38
|
+
|
|
39
|
+
if isinstance(font, list):
|
|
40
|
+
font = font[0]
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
fp = FontProperties(family=font)
|
|
44
|
+
return findfont(fp)
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
7
48
|
def richtext(
|
|
8
49
|
x: float,
|
|
9
50
|
y: float,
|
|
@@ -14,6 +55,7 @@ def richtext(
|
|
|
14
55
|
) -> List[Text]:
|
|
15
56
|
"""
|
|
16
57
|
Display text with different colors and properties for each string, supporting word wrapping and alignment.
|
|
58
|
+
Supports complex scripts (e.g. Nepali) via manual shaping if uharfbuzz is installed.
|
|
17
59
|
|
|
18
60
|
Parameters
|
|
19
61
|
----------
|
|
@@ -272,6 +314,46 @@ def _get_text_width(text: str, ax: Axes, renderer, **text_kwargs) -> float:
|
|
|
272
314
|
kwargs = text_kwargs.copy()
|
|
273
315
|
kwargs.pop('underline', None)
|
|
274
316
|
|
|
317
|
+
# Try shaping if available
|
|
318
|
+
if HAS_HARFBUZZ:
|
|
319
|
+
font = kwargs.get('fontfamily') or kwargs.get('family') or plt.rcParams['font.family'][0]
|
|
320
|
+
# Resolve font path
|
|
321
|
+
try:
|
|
322
|
+
# Handle fontfamily being None or list
|
|
323
|
+
if not font:
|
|
324
|
+
font = plt.rcParams['font.family'][0]
|
|
325
|
+
if isinstance(font, list):
|
|
326
|
+
font = font[0]
|
|
327
|
+
|
|
328
|
+
fp = FontProperties(family=font)
|
|
329
|
+
path = findfont(fp)
|
|
330
|
+
|
|
331
|
+
# Simple caching could go here
|
|
332
|
+
if path:
|
|
333
|
+
fontsize = kwargs.get('fontsize') or kwargs.get('size') or plt.rcParams['font.size']
|
|
334
|
+
shaper = HarfbuzzShaper(path)
|
|
335
|
+
width_points = shaper.get_text_width(text, fontsize)
|
|
336
|
+
|
|
337
|
+
# Convert points -> pixels -> data
|
|
338
|
+
# 1. Points to Pixels
|
|
339
|
+
pixels = renderer.points_to_pixels(width_points)
|
|
340
|
+
|
|
341
|
+
# 2. Pixels to Data
|
|
342
|
+
# We can measure the width of a 0-width line vs 'pixels'-width line?
|
|
343
|
+
# Or use proper transform math.
|
|
344
|
+
# bbox width in pixels = 'pixels'.
|
|
345
|
+
# We want width in data.
|
|
346
|
+
|
|
347
|
+
# Create a bbox in display coords
|
|
348
|
+
from matplotlib.transforms import Bbox
|
|
349
|
+
bbox_display = Bbox.from_bounds(0, 0, pixels, 0)
|
|
350
|
+
|
|
351
|
+
# Transform to data coords
|
|
352
|
+
bbox_data = bbox_display.transformed(ax.transData.inverted())
|
|
353
|
+
return bbox_data.width
|
|
354
|
+
except Exception:
|
|
355
|
+
pass # Fallback to native
|
|
356
|
+
|
|
275
357
|
t = ax.text(0, 0, text, **kwargs)
|
|
276
358
|
bbox = t.get_window_extent(renderer=renderer)
|
|
277
359
|
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
@@ -280,12 +362,95 @@ def _get_text_width(text: str, ax: Axes, renderer, **text_kwargs) -> float:
|
|
|
280
362
|
return w
|
|
281
363
|
|
|
282
364
|
|
|
365
|
+
def _get_text_metrics(text: str, ax: Axes, renderer, **text_kwargs) -> tuple:
|
|
366
|
+
"""
|
|
367
|
+
Get text metrics: (width, ascent) in data units.
|
|
368
|
+
- width: horizontal extent
|
|
369
|
+
- ascent: distance from baseline to top of text
|
|
370
|
+
"""
|
|
371
|
+
kwargs = text_kwargs.copy()
|
|
372
|
+
kwargs.pop('underline', None)
|
|
373
|
+
|
|
374
|
+
# Try shaping if available
|
|
375
|
+
# Only use HarfBuzz measurement if the text actually needs complex shaping.
|
|
376
|
+
# Otherwise, trust Matplotlib's native measurement which handles font fallback (e.g. lists of fonts) better.
|
|
377
|
+
if HAS_HARFBUZZ and _needs_complex_shaping(text):
|
|
378
|
+
path = _resolve_font_path(kwargs)
|
|
379
|
+
try:
|
|
380
|
+
if path:
|
|
381
|
+
fontsize = kwargs.get('fontsize') or kwargs.get('size') or plt.rcParams['font.size']
|
|
382
|
+
shaper = HarfbuzzShaper(path)
|
|
383
|
+
|
|
384
|
+
# Get width and ascent in points
|
|
385
|
+
width_points = shaper.get_text_width(text, fontsize)
|
|
386
|
+
ascent_points = shaper.get_ascent(fontsize)
|
|
387
|
+
|
|
388
|
+
# Convert to pixels then to data units
|
|
389
|
+
from matplotlib.transforms import Bbox
|
|
390
|
+
|
|
391
|
+
width_px = renderer.points_to_pixels(width_points)
|
|
392
|
+
ascent_px = renderer.points_to_pixels(ascent_points)
|
|
393
|
+
|
|
394
|
+
# Width: horizontal conversion
|
|
395
|
+
bbox_w = Bbox.from_bounds(0, 0, width_px, 0)
|
|
396
|
+
width_data = bbox_w.transformed(ax.transData.inverted()).width
|
|
397
|
+
|
|
398
|
+
# Ascent: vertical conversion
|
|
399
|
+
bbox_a = Bbox.from_bounds(0, 0, 0, ascent_px)
|
|
400
|
+
ascent_data = bbox_a.transformed(ax.transData.inverted()).height
|
|
401
|
+
|
|
402
|
+
return (width_data, ascent_data)
|
|
403
|
+
except Exception:
|
|
404
|
+
pass # Fallback to native
|
|
405
|
+
|
|
406
|
+
# Native measurement
|
|
407
|
+
t = ax.text(0, 0, text, **kwargs)
|
|
408
|
+
bbox = t.get_window_extent(renderer=renderer)
|
|
409
|
+
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
410
|
+
|
|
411
|
+
width_data = bbox_data.width
|
|
412
|
+
# For native text, ascent ≈ height (simplified; baseline is at bottom of bbox)
|
|
413
|
+
ascent_data = bbox_data.height
|
|
414
|
+
|
|
415
|
+
t.remove()
|
|
416
|
+
return (width_data, ascent_data)
|
|
417
|
+
|
|
418
|
+
|
|
283
419
|
def _get_text_height(text: str, ax: Axes, renderer, **text_kwargs) -> float:
|
|
284
420
|
"""Measure the height of a text string."""
|
|
285
421
|
# Remove custom properties that ax.text doesn't understand
|
|
286
422
|
kwargs = text_kwargs.copy()
|
|
287
423
|
kwargs.pop('underline', None)
|
|
288
424
|
|
|
425
|
+
# Try shaping-based height for Devanagari fonts
|
|
426
|
+
# This avoids measuring with Latin chars that the font might not have
|
|
427
|
+
if HAS_HARFBUZZ:
|
|
428
|
+
path = _resolve_font_path(kwargs)
|
|
429
|
+
try:
|
|
430
|
+
# Check if this is a known Devanagari font (simplified check via path for now?
|
|
431
|
+
# Or assume if resolving worked and contained Devanagari chars earlier...
|
|
432
|
+
# Actually valid logic: if we are here and path resolves, we trust it?
|
|
433
|
+
# But the original code restricted it to known fonts.
|
|
434
|
+
# Let's keep it generally open if path is found, OR check font name.
|
|
435
|
+
|
|
436
|
+
if path:
|
|
437
|
+
# Optional: check for Devanagari-likeness if needed, but path resolution implies intent.
|
|
438
|
+
# However, for height specifically we only wanted this for specific fonts to avoid 'Hg'.
|
|
439
|
+
# Let's be permissive if path is found since we use shaper now.
|
|
440
|
+
|
|
441
|
+
fontsize = kwargs.get('fontsize') or kwargs.get('size') or plt.rcParams['font.size']
|
|
442
|
+
shaper = HarfbuzzShaper(path)
|
|
443
|
+
height_points = shaper.get_font_height(fontsize)
|
|
444
|
+
|
|
445
|
+
# Convert points -> pixels -> data
|
|
446
|
+
pixels = renderer.points_to_pixels(height_points)
|
|
447
|
+
from matplotlib.transforms import Bbox
|
|
448
|
+
bbox_display = Bbox.from_bounds(0, 0, 0, pixels)
|
|
449
|
+
bbox_data = bbox_display.transformed(ax.transData.inverted())
|
|
450
|
+
return bbox_data.height
|
|
451
|
+
except Exception:
|
|
452
|
+
pass # Fallback to native
|
|
453
|
+
|
|
289
454
|
# Use a representative character for height if text is empty or space
|
|
290
455
|
# But we need the height of THIS specific font configuration.
|
|
291
456
|
measure_text = text if text.strip() else "Hg"
|
|
@@ -302,24 +467,25 @@ def _build_lines_wrapped(
|
|
|
302
467
|
ax: Axes,
|
|
303
468
|
renderer,
|
|
304
469
|
box_width: float
|
|
305
|
-
) -> List[List[Tuple[str, Dict[str, Any], float]]]:
|
|
470
|
+
) -> List[List[Tuple[str, Dict[str, Any], float, float]]]:
|
|
306
471
|
"""
|
|
307
472
|
Group words into lines based on box_width.
|
|
473
|
+
Returns: List of lines, where each line is List of (word, props, width, ascent).
|
|
308
474
|
"""
|
|
309
|
-
lines: List[List[Tuple[str, Dict[str, Any], float]]] = []
|
|
310
|
-
current_line: List[Tuple[str, Dict[str, Any], float]] = []
|
|
475
|
+
lines: List[List[Tuple[str, Dict[str, Any], float, float]]] = []
|
|
476
|
+
current_line: List[Tuple[str, Dict[str, Any], float, float]] = []
|
|
311
477
|
current_line_width = 0.0
|
|
312
478
|
|
|
313
479
|
for word, props in words:
|
|
314
|
-
w =
|
|
480
|
+
w, asc = _get_text_metrics(word, ax, renderer, **props)
|
|
315
481
|
|
|
316
482
|
if current_line_width + w > box_width and current_line:
|
|
317
483
|
# Wrap to new line
|
|
318
484
|
lines.append(current_line)
|
|
319
|
-
current_line = [(word, props, w)]
|
|
485
|
+
current_line = [(word, props, w, asc)]
|
|
320
486
|
current_line_width = w
|
|
321
487
|
else:
|
|
322
|
-
current_line.append((word, props, w))
|
|
488
|
+
current_line.append((word, props, w, asc))
|
|
323
489
|
current_line_width += w
|
|
324
490
|
|
|
325
491
|
if current_line:
|
|
@@ -333,19 +499,20 @@ def _build_line_seamless(
|
|
|
333
499
|
properties: List[Dict[str, Any]],
|
|
334
500
|
ax: Axes,
|
|
335
501
|
renderer
|
|
336
|
-
) -> List[Tuple[str, Dict[str, Any], float]]:
|
|
502
|
+
) -> List[Tuple[str, Dict[str, Any], float, float]]:
|
|
337
503
|
"""
|
|
338
504
|
Build a single line from strings without splitting by spaces.
|
|
505
|
+
Returns: List of (string, props, width, ascent).
|
|
339
506
|
"""
|
|
340
|
-
line: List[Tuple[str, Dict[str, Any], float]] = []
|
|
507
|
+
line: List[Tuple[str, Dict[str, Any], float, float]] = []
|
|
341
508
|
for string, props in zip(strings, properties):
|
|
342
|
-
w =
|
|
343
|
-
line.append((string, props, w))
|
|
509
|
+
w, asc = _get_text_metrics(string, ax, renderer, **props)
|
|
510
|
+
line.append((string, props, w, asc))
|
|
344
511
|
return line
|
|
345
512
|
|
|
346
513
|
|
|
347
514
|
def _draw_lines(
|
|
348
|
-
lines: List[List[Tuple[str, Dict[str, Any], float]]],
|
|
515
|
+
lines: List[List[Tuple[str, Dict[str, Any], float, float]]],
|
|
349
516
|
x: float,
|
|
350
517
|
y: float,
|
|
351
518
|
ax: Axes,
|
|
@@ -357,24 +524,26 @@ def _draw_lines(
|
|
|
357
524
|
zorder: int
|
|
358
525
|
) -> List[Text]:
|
|
359
526
|
"""
|
|
360
|
-
Draw the lines of text onto the axes.
|
|
527
|
+
Draw the lines of text onto the axes using baseline alignment.
|
|
528
|
+
Each line item is (word, props, width, ascent).
|
|
361
529
|
"""
|
|
362
530
|
text_objects: List[Text] = []
|
|
363
531
|
|
|
364
|
-
# Calculate
|
|
365
|
-
|
|
532
|
+
# Calculate metrics for each line
|
|
533
|
+
line_metrics = []
|
|
366
534
|
for line in lines:
|
|
367
|
-
# Find max height in this line
|
|
368
|
-
|
|
369
|
-
|
|
535
|
+
# Find max ascent and max total height in this line
|
|
536
|
+
max_ascent = max(item[3] for item in line) if line else 0.0
|
|
537
|
+
max_height = 0.0
|
|
538
|
+
for word, props, w, asc in line:
|
|
370
539
|
h = _get_text_height(word, ax, renderer, **props)
|
|
371
|
-
if h >
|
|
372
|
-
|
|
373
|
-
|
|
540
|
+
if h > max_height:
|
|
541
|
+
max_height = h
|
|
542
|
+
line_metrics.append((max_ascent, max_height * linespacing))
|
|
374
543
|
|
|
375
|
-
total_block_height = sum(
|
|
544
|
+
total_block_height = sum(m[1] for m in line_metrics)
|
|
376
545
|
|
|
377
|
-
# Calculate top Y position based on vertical alignment
|
|
546
|
+
# Calculate top Y position based on vertical alignment of the block
|
|
378
547
|
if va == 'center':
|
|
379
548
|
top_y = y + (total_block_height / 2)
|
|
380
549
|
elif va == 'top':
|
|
@@ -387,10 +556,11 @@ def _draw_lines(
|
|
|
387
556
|
current_y = top_y
|
|
388
557
|
|
|
389
558
|
for i, line in enumerate(lines):
|
|
390
|
-
line_height =
|
|
559
|
+
max_ascent, line_height = line_metrics[i]
|
|
391
560
|
|
|
392
|
-
#
|
|
393
|
-
|
|
561
|
+
# Calculate baseline position
|
|
562
|
+
# Baseline is at: top of line - max_ascent
|
|
563
|
+
baseline_y = current_y - max_ascent
|
|
394
564
|
|
|
395
565
|
# Calculate line width for horizontal alignment
|
|
396
566
|
line_width = sum(item[2] for item in line)
|
|
@@ -399,25 +569,42 @@ def _draw_lines(
|
|
|
399
569
|
line_start_x = x - (line_width / 2)
|
|
400
570
|
elif ha == 'right':
|
|
401
571
|
line_start_x = x - line_width
|
|
402
|
-
else:
|
|
572
|
+
else: # left
|
|
403
573
|
line_start_x = x
|
|
404
574
|
|
|
405
575
|
current_x = line_start_x
|
|
406
576
|
|
|
407
|
-
for word, props, w in line:
|
|
577
|
+
for word, props, w, asc in line:
|
|
408
578
|
text_kwargs = props.copy()
|
|
409
579
|
|
|
410
580
|
# Extract underline property
|
|
411
581
|
underline = text_kwargs.pop('underline', False)
|
|
412
582
|
|
|
583
|
+
# Use baseline alignment for all text
|
|
413
584
|
text_kwargs.update({
|
|
414
|
-
'va': '
|
|
585
|
+
'va': 'baseline',
|
|
415
586
|
'ha': 'left',
|
|
416
587
|
'transform': transform,
|
|
417
588
|
'zorder': zorder
|
|
418
589
|
})
|
|
419
590
|
|
|
420
|
-
|
|
591
|
+
# Determine if we should use ShapedText
|
|
592
|
+
used_shaper = False
|
|
593
|
+
t = None
|
|
594
|
+
|
|
595
|
+
if HAS_HARFBUZZ and _needs_complex_shaping(word):
|
|
596
|
+
try:
|
|
597
|
+
path = _resolve_font_path(text_kwargs)
|
|
598
|
+
if path:
|
|
599
|
+
t = ShapedText(current_x, baseline_y, word, font_path=path, **text_kwargs)
|
|
600
|
+
ax.add_artist(t)
|
|
601
|
+
used_shaper = True
|
|
602
|
+
except Exception as e:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
if not used_shaper:
|
|
606
|
+
t = ax.text(current_x, baseline_y, word, **text_kwargs)
|
|
607
|
+
|
|
421
608
|
text_objects.append(t)
|
|
422
609
|
|
|
423
610
|
# Draw underline if requested
|
|
@@ -433,11 +620,34 @@ def _draw_lines(
|
|
|
433
620
|
# Let's put the underline at line_center_y - (h/2) - padding?
|
|
434
621
|
|
|
435
622
|
# Let's use the bbox of the text object to find the bottom
|
|
436
|
-
bbox = t.get_window_extent(renderer=renderer)
|
|
437
|
-
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
623
|
+
# bbox = t.get_window_extent(renderer=renderer)
|
|
624
|
+
# bbox_data = bbox.transformed(ax.transData.inverted())
|
|
438
625
|
|
|
439
626
|
# y0 is the bottom of the text bbox
|
|
440
|
-
y_bottom = bbox_data.y0
|
|
627
|
+
# y_bottom = bbox_data.y0
|
|
628
|
+
|
|
629
|
+
# Since ShapedText might behave differently with bbox, and we already know 'w'.
|
|
630
|
+
# And we aligned 'va=center'.
|
|
631
|
+
# Let's use the line_center_y and offset down.
|
|
632
|
+
# Text Height approximation:
|
|
633
|
+
fontsize = text_kwargs.get('fontsize', 12)
|
|
634
|
+
# data_height approximation?
|
|
635
|
+
# This is risky if aspect ratio is not 1.
|
|
636
|
+
# Fallback to bbox logic, assuming draw has happened?
|
|
637
|
+
# ShapedText needs to be drawn to have a valid bbox?
|
|
638
|
+
# In Matplotlib, get_window_extent() triggers a draw if needed?
|
|
639
|
+
# Or we can just trust the metrics.
|
|
640
|
+
|
|
641
|
+
# For consistency with previous code, let's try getting bbox.
|
|
642
|
+
try:
|
|
643
|
+
bbox = t.get_window_extent(renderer=renderer)
|
|
644
|
+
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
645
|
+
y_bottom = bbox_data.y0
|
|
646
|
+
except Exception:
|
|
647
|
+
# Fallback if renderer issue
|
|
648
|
+
y_bottom = line_center_y - 5 # arbitrary?
|
|
649
|
+
|
|
650
|
+
# Draw line from current_x to current_x + w
|
|
441
651
|
|
|
442
652
|
# Draw line from current_x to current_x + w
|
|
443
653
|
# Use the same color as the text
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import matplotlib.patches as patches
|
|
4
|
+
import matplotlib.font_manager as fm
|
|
5
|
+
from matplotlib.path import Path
|
|
6
|
+
from matplotlib.transforms import Affine2D
|
|
7
|
+
from typing import List, Tuple, Optional, Any
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import uharfbuzz as hb
|
|
12
|
+
from fontTools.ttLib import TTFont
|
|
13
|
+
from fontTools.pens.basePen import BasePen
|
|
14
|
+
HAS_HARFBUZZ = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAS_HARFBUZZ = False
|
|
17
|
+
|
|
18
|
+
class MatplotlibPathPen(BasePen):
|
|
19
|
+
def __init__(self, glyphSet):
|
|
20
|
+
super().__init__(glyphSet)
|
|
21
|
+
self.verts = []
|
|
22
|
+
self.codes = []
|
|
23
|
+
|
|
24
|
+
def _moveTo(self, p):
|
|
25
|
+
self.verts.append(p)
|
|
26
|
+
self.codes.append(Path.MOVETO)
|
|
27
|
+
|
|
28
|
+
def _lineTo(self, p):
|
|
29
|
+
self.verts.append(p)
|
|
30
|
+
self.codes.append(Path.LINETO)
|
|
31
|
+
|
|
32
|
+
def _curveToOne(self, p1, p2, p3):
|
|
33
|
+
self.verts.extend([p1, p2, p3])
|
|
34
|
+
self.codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4])
|
|
35
|
+
|
|
36
|
+
def _qCurveToOne(self, p1, p2):
|
|
37
|
+
self.verts.extend([p1, p2])
|
|
38
|
+
self.codes.extend([Path.CURVE3, Path.CURVE3])
|
|
39
|
+
|
|
40
|
+
def _closePath(self):
|
|
41
|
+
self.verts.append((0, 0))
|
|
42
|
+
self.codes.append(Path.CLOSEPOLY)
|
|
43
|
+
|
|
44
|
+
class HarfbuzzShaper:
|
|
45
|
+
def __init__(self, font_path: str):
|
|
46
|
+
if not HAS_HARFBUZZ:
|
|
47
|
+
raise ImportError("uharfbuzz and fonttools are required for manual shaping.")
|
|
48
|
+
|
|
49
|
+
self.font_path = font_path
|
|
50
|
+
|
|
51
|
+
# Load for HarfBuzz
|
|
52
|
+
with open(font_path, 'rb') as f:
|
|
53
|
+
self.font_data = f.read()
|
|
54
|
+
self.face = hb.Face(self.font_data)
|
|
55
|
+
self.font = hb.Font(self.face)
|
|
56
|
+
self.upem = self.face.upem
|
|
57
|
+
self.font.scale = (self.upem, self.upem)
|
|
58
|
+
|
|
59
|
+
# Load for Path Extraction
|
|
60
|
+
self.ttfont = TTFont(font_path)
|
|
61
|
+
self.glyph_set = self.ttfont.getGlyphSet()
|
|
62
|
+
self.glyph_order = self.ttfont.getGlyphOrder()
|
|
63
|
+
|
|
64
|
+
def shape(self, text: str) -> Tuple[List[Any], List[Any]]:
|
|
65
|
+
buf = hb.Buffer()
|
|
66
|
+
buf.add_str(text)
|
|
67
|
+
buf.guess_segment_properties()
|
|
68
|
+
hb.shape(self.font, buf)
|
|
69
|
+
return buf.glyph_infos, buf.glyph_positions
|
|
70
|
+
|
|
71
|
+
def get_text_width(self, text: str, fontsize: float) -> float:
|
|
72
|
+
infos, positions = self.shape(text)
|
|
73
|
+
total_advance_units = sum(pos.x_advance for pos in positions)
|
|
74
|
+
return total_advance_units * (fontsize / self.upem)
|
|
75
|
+
|
|
76
|
+
def get_font_height(self, fontsize: float) -> float:
|
|
77
|
+
# Get ascender/descender from fontTools hhea table
|
|
78
|
+
hhea = self.ttfont.get('hhea')
|
|
79
|
+
if hhea:
|
|
80
|
+
ascender = hhea.ascent
|
|
81
|
+
descender = hhea.descent # Usually negative
|
|
82
|
+
return (ascender - descender) * (fontsize / self.upem)
|
|
83
|
+
else:
|
|
84
|
+
# Fallback: approximate based on upem
|
|
85
|
+
return fontsize * 1.2 # Rough approximation
|
|
86
|
+
|
|
87
|
+
def get_ascent(self, fontsize: float) -> float:
|
|
88
|
+
"""Get ascent (baseline to top) in scaled units."""
|
|
89
|
+
hhea = self.ttfont.get('hhea')
|
|
90
|
+
if hhea:
|
|
91
|
+
return hhea.ascent * (fontsize / self.upem)
|
|
92
|
+
else:
|
|
93
|
+
return fontsize * 0.8 # Rough approximation
|
|
94
|
+
|
|
95
|
+
def get_shaped_paths(self, text: str) -> List[Tuple[Path, float, float, float]]:
|
|
96
|
+
"""
|
|
97
|
+
Returns list of (Path, x_pos, y_pos, scale_factor)
|
|
98
|
+
Path is in Font Units.
|
|
99
|
+
Pos is in Font Units.
|
|
100
|
+
"""
|
|
101
|
+
infos, positions = self.shape(text)
|
|
102
|
+
|
|
103
|
+
results = []
|
|
104
|
+
current_x = 0
|
|
105
|
+
current_y = 0
|
|
106
|
+
|
|
107
|
+
for info, pos in zip(infos, positions):
|
|
108
|
+
gid = info.codepoint
|
|
109
|
+
try:
|
|
110
|
+
glyph_name = self.glyph_order[gid]
|
|
111
|
+
except IndexError:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
pen = MatplotlibPathPen(self.glyph_set)
|
|
115
|
+
self.glyph_set[glyph_name].draw(pen)
|
|
116
|
+
|
|
117
|
+
if pen.verts:
|
|
118
|
+
path = Path(pen.verts, pen.codes)
|
|
119
|
+
|
|
120
|
+
# We return position relative to start of string
|
|
121
|
+
# x = cursor + off_x
|
|
122
|
+
# y = cursor + off_y
|
|
123
|
+
x = current_x + pos.x_offset
|
|
124
|
+
y = current_y + pos.y_offset
|
|
125
|
+
|
|
126
|
+
results.append((path, x, y))
|
|
127
|
+
|
|
128
|
+
current_x += pos.x_advance
|
|
129
|
+
current_y += pos.y_advance
|
|
130
|
+
|
|
131
|
+
return results
|
|
132
|
+
|
|
133
|
+
from matplotlib.text import Text
|
|
134
|
+
from matplotlib.transforms import Affine2D
|
|
135
|
+
|
|
136
|
+
class ShapedText(Text):
|
|
137
|
+
"""
|
|
138
|
+
A Text artist that uses manual HarfBuzz shaping.
|
|
139
|
+
"""
|
|
140
|
+
def __init__(self, x, y, text, font_path, **kwargs):
|
|
141
|
+
super().__init__(x, y, text, **kwargs)
|
|
142
|
+
self.shaper = HarfbuzzShaper(font_path)
|
|
143
|
+
|
|
144
|
+
def draw(self, renderer):
|
|
145
|
+
if renderer is not None:
|
|
146
|
+
self._renderer = renderer
|
|
147
|
+
if not self.get_visible():
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# 1. Basic Text Setup (properties)
|
|
151
|
+
gc = renderer.new_gc()
|
|
152
|
+
gc.set_foreground(self.get_color())
|
|
153
|
+
gc.set_alpha(self.get_alpha())
|
|
154
|
+
# gc.set_url(self.get_url())
|
|
155
|
+
|
|
156
|
+
# 2. Get shaping results
|
|
157
|
+
# Path is in Font Units (e.g. 1000 or 2048 UPEM)
|
|
158
|
+
paths_and_pos = self.shaper.get_shaped_paths(self.get_text())
|
|
159
|
+
|
|
160
|
+
# 3. Calculate Transform
|
|
161
|
+
# Text transform: (Data X, Data Y) -> (Screen X, Screen Y)
|
|
162
|
+
# We need to map: FontUnit -> ScreenUnit
|
|
163
|
+
|
|
164
|
+
# Current Font Size in points
|
|
165
|
+
fontsize_points = self.get_fontsize()
|
|
166
|
+
# Pixels per point
|
|
167
|
+
dpi = self.figure.dpi
|
|
168
|
+
pixels_per_point = dpi / 72.0
|
|
169
|
+
|
|
170
|
+
# Scale factor: FontUnit -> Pixels
|
|
171
|
+
# 1 FontUnit = (1/UPEM) * (fontsize_points) * (pixels_per_point) pixels
|
|
172
|
+
upem = self.shaper.upem
|
|
173
|
+
scale = (1.0 / upem) * fontsize_points * pixels_per_point
|
|
174
|
+
|
|
175
|
+
# Position transform
|
|
176
|
+
# The (x,y) of the Text object is processed by self.get_transform()
|
|
177
|
+
# This gives us the Screen Pixel coordinate of the text anchor.
|
|
178
|
+
text_pos_screen = self.get_transform().transform([self.get_position()]) # [[x, y]]
|
|
179
|
+
screen_x, screen_y = text_pos_screen[0]
|
|
180
|
+
|
|
181
|
+
# We also need to handle Horizontal/Vertical Alignment (ha/va)
|
|
182
|
+
# To do this, we need total width/height of the shaped text.
|
|
183
|
+
total_width_font_units = self.shaper.get_text_width(self.get_text(), self.shaper.upem) # pass upem to get unscaled width
|
|
184
|
+
total_width_pixels = total_width_font_units * scale
|
|
185
|
+
|
|
186
|
+
# Height is approximately fontsize_pixels? Or bounding box?
|
|
187
|
+
# For simplicity, approximate with fontsize for alignment??
|
|
188
|
+
# Usually MPL uses bbox. We can calculate bbox from paths if needed.
|
|
189
|
+
# Let's stick to basic alignment for now or assume baseline alignment (y=0).
|
|
190
|
+
|
|
191
|
+
offset_x = 0
|
|
192
|
+
if self.get_horizontalalignment() == 'center':
|
|
193
|
+
offset_x = -total_width_pixels / 2
|
|
194
|
+
elif self.get_horizontalalignment() == 'right':
|
|
195
|
+
offset_x = -total_width_pixels
|
|
196
|
+
|
|
197
|
+
# Vertical alignment
|
|
198
|
+
# Matplotlib centers based on the bounding box of the text.
|
|
199
|
+
# We need to calculate the bounding box of the shaped glyphs in Font Units.
|
|
200
|
+
# Paths are (path, x, y).
|
|
201
|
+
|
|
202
|
+
offset_y = 0
|
|
203
|
+
va = self.get_verticalalignment()
|
|
204
|
+
|
|
205
|
+
if va != 'baseline':
|
|
206
|
+
# Calculate bounds
|
|
207
|
+
min_y = float('inf')
|
|
208
|
+
max_y = float('-inf')
|
|
209
|
+
has_paths = False
|
|
210
|
+
|
|
211
|
+
for path, gx, gy in paths_and_pos:
|
|
212
|
+
if not path.vertices.size:
|
|
213
|
+
continue
|
|
214
|
+
# Get extents of this path (in font units, relative to its origin 0,0)
|
|
215
|
+
ext = path.get_extents()
|
|
216
|
+
# Shift by glyph position
|
|
217
|
+
# path.vertices are relative to glyph origin.
|
|
218
|
+
# (gx, gy) is glyph origin relative to string origin.
|
|
219
|
+
# So absolute y = vert_y + gy
|
|
220
|
+
# extents are (xmin, ymin, xmax, ymax)
|
|
221
|
+
|
|
222
|
+
# Careful: Bbox object behavior
|
|
223
|
+
glyph_ymin = ext.ymin + gy
|
|
224
|
+
glyph_ymax = ext.ymax + gy
|
|
225
|
+
|
|
226
|
+
if glyph_ymin < min_y: min_y = glyph_ymin
|
|
227
|
+
if glyph_ymax > max_y: max_y = glyph_ymax
|
|
228
|
+
has_paths = True
|
|
229
|
+
|
|
230
|
+
if not has_paths:
|
|
231
|
+
# Fallback to font metrics if no glyphs (e.g. space) or bbox failure
|
|
232
|
+
# height ~ ascender - descender?
|
|
233
|
+
# Center ~ (ascender + descender) / 2
|
|
234
|
+
asc = self.shaper.face.ascender
|
|
235
|
+
desc = self.shaper.face.descender
|
|
236
|
+
min_y = desc
|
|
237
|
+
max_y = asc
|
|
238
|
+
|
|
239
|
+
# Now determine offset (in Font Units)
|
|
240
|
+
# We want to shift so that the reference point (e.g. center) is at 0.
|
|
241
|
+
# Then our base transform places 0 at screen_y.
|
|
242
|
+
|
|
243
|
+
if va == 'center':
|
|
244
|
+
center_y = (min_y + max_y) / 2
|
|
245
|
+
offset_y = -center_y * scale
|
|
246
|
+
elif va == 'top':
|
|
247
|
+
offset_y = -max_y * scale
|
|
248
|
+
elif va == 'bottom':
|
|
249
|
+
offset_y = -min_y * scale
|
|
250
|
+
# baseline: offset_y = 0 (already set)
|
|
251
|
+
|
|
252
|
+
# Actual Layout Transform:
|
|
253
|
+
# 1. Scale path by `scale`
|
|
254
|
+
# 2. Translate by (glyph_x * scale, glyph_y * scale) to assemble string
|
|
255
|
+
# 3. Translate by (offset_x, offset_y) for alignment
|
|
256
|
+
# 4. Translate by (screen_x, screen_y) to place on screen
|
|
257
|
+
# 5. Rotate? self.get_rotation().
|
|
258
|
+
|
|
259
|
+
# Matrix:
|
|
260
|
+
# Scale(scale) * Translate(pos_x, pos_y) ...
|
|
261
|
+
|
|
262
|
+
rotation = self.get_rotation()
|
|
263
|
+
# Rotation usually happens around the anchor point (screen_x, screen_y).
|
|
264
|
+
|
|
265
|
+
# Base transform for the whole text block (anchor at 0,0)
|
|
266
|
+
# Font -> Screen (Calculated)
|
|
267
|
+
base_transform = Affine2D().scale(scale)
|
|
268
|
+
|
|
269
|
+
# Alignment translation
|
|
270
|
+
align_transform = Affine2D().translate(offset_x, offset_y)
|
|
271
|
+
|
|
272
|
+
# Rotation & Translation to Screen Position
|
|
273
|
+
placement_transform = Affine2D().rotate_deg(rotation).translate(screen_x, screen_y)
|
|
274
|
+
|
|
275
|
+
# Loop and draw
|
|
276
|
+
for path, gx, gy in paths_and_pos:
|
|
277
|
+
# Transform for this specific glyph
|
|
278
|
+
# Move glyph to its place in the string (scaled)
|
|
279
|
+
# Actually since we scale the whole coordinate system, gx/gy are in FontUnits.
|
|
280
|
+
# So: Translate(gx, gy) -> Scale(scale) ...
|
|
281
|
+
|
|
282
|
+
glyph_trans = Affine2D().translate(gx, gy) + base_transform + align_transform + placement_transform
|
|
283
|
+
|
|
284
|
+
from matplotlib.colors import to_rgba
|
|
285
|
+
rgba_color = to_rgba(self.get_color(), alpha=self.get_alpha())
|
|
286
|
+
renderer.draw_path(gc, path, glyph_trans, rgbFace=rgba_color)
|
|
287
|
+
|
|
288
|
+
gc.restore()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.7'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpl-richtext
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Rich text rendering for Matplotlib with multi-color and multi-style support
|
|
5
5
|
Home-page: https://github.com/ra8in/mpl-richtext
|
|
6
6
|
Author: Rabin Katel
|
|
@@ -27,6 +27,8 @@ Requires-Python: >=3.8
|
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: matplotlib>=3.5.0
|
|
30
|
+
Requires-Dist: uharfbuzz>=0.30.0
|
|
31
|
+
Requires-Dist: fonttools>=4.34.0
|
|
30
32
|
Provides-Extra: dev
|
|
31
33
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
34
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.1.4'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|