mpl-richtext 0.1.3.1__py3-none-any.whl → 0.1.5__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.
- mpl_richtext/core.py +237 -32
- mpl_richtext/shaping.py +289 -0
- mpl_richtext/version.py +1 -1
- {mpl_richtext-0.1.3.1.dist-info → mpl_richtext-0.1.5.dist-info}/METADATA +3 -1
- mpl_richtext-0.1.5.dist-info/RECORD +9 -0
- {mpl_richtext-0.1.3.1.dist-info → mpl_richtext-0.1.5.dist-info}/WHEEL +1 -1
- mpl_richtext-0.1.3.1.dist-info/RECORD +0 -8
- {mpl_richtext-0.1.3.1.dist-info → mpl_richtext-0.1.5.dist-info}/licenses/LICENSE +0 -0
- {mpl_richtext-0.1.3.1.dist-info → mpl_richtext-0.1.5.dist-info}/top_level.txt +0 -0
mpl_richtext/core.py
CHANGED
|
@@ -2,8 +2,29 @@ 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
|
+
|
|
7
28
|
def richtext(
|
|
8
29
|
x: float,
|
|
9
30
|
y: float,
|
|
@@ -14,6 +35,7 @@ def richtext(
|
|
|
14
35
|
) -> List[Text]:
|
|
15
36
|
"""
|
|
16
37
|
Display text with different colors and properties for each string, supporting word wrapping and alignment.
|
|
38
|
+
Supports complex scripts (e.g. Nepali) via manual shaping if uharfbuzz is installed.
|
|
17
39
|
|
|
18
40
|
Parameters
|
|
19
41
|
----------
|
|
@@ -272,6 +294,46 @@ def _get_text_width(text: str, ax: Axes, renderer, **text_kwargs) -> float:
|
|
|
272
294
|
kwargs = text_kwargs.copy()
|
|
273
295
|
kwargs.pop('underline', None)
|
|
274
296
|
|
|
297
|
+
# Try shaping if available
|
|
298
|
+
if HAS_HARFBUZZ:
|
|
299
|
+
font = kwargs.get('fontfamily') or kwargs.get('family') or plt.rcParams['font.family'][0]
|
|
300
|
+
# Resolve font path
|
|
301
|
+
try:
|
|
302
|
+
# Handle fontfamily being None or list
|
|
303
|
+
if not font:
|
|
304
|
+
font = plt.rcParams['font.family'][0]
|
|
305
|
+
if isinstance(font, list):
|
|
306
|
+
font = font[0]
|
|
307
|
+
|
|
308
|
+
fp = FontProperties(family=font)
|
|
309
|
+
path = findfont(fp)
|
|
310
|
+
|
|
311
|
+
# Simple caching could go here
|
|
312
|
+
if path:
|
|
313
|
+
fontsize = kwargs.get('fontsize') or kwargs.get('size') or plt.rcParams['font.size']
|
|
314
|
+
shaper = HarfbuzzShaper(path)
|
|
315
|
+
width_points = shaper.get_text_width(text, fontsize)
|
|
316
|
+
|
|
317
|
+
# Convert points -> pixels -> data
|
|
318
|
+
# 1. Points to Pixels
|
|
319
|
+
pixels = renderer.points_to_pixels(width_points)
|
|
320
|
+
|
|
321
|
+
# 2. Pixels to Data
|
|
322
|
+
# We can measure the width of a 0-width line vs 'pixels'-width line?
|
|
323
|
+
# Or use proper transform math.
|
|
324
|
+
# bbox width in pixels = 'pixels'.
|
|
325
|
+
# We want width in data.
|
|
326
|
+
|
|
327
|
+
# Create a bbox in display coords
|
|
328
|
+
from matplotlib.transforms import Bbox
|
|
329
|
+
bbox_display = Bbox.from_bounds(0, 0, pixels, 0)
|
|
330
|
+
|
|
331
|
+
# Transform to data coords
|
|
332
|
+
bbox_data = bbox_display.transformed(ax.transData.inverted())
|
|
333
|
+
return bbox_data.width
|
|
334
|
+
except Exception:
|
|
335
|
+
pass # Fallback to native
|
|
336
|
+
|
|
275
337
|
t = ax.text(0, 0, text, **kwargs)
|
|
276
338
|
bbox = t.get_window_extent(renderer=renderer)
|
|
277
339
|
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
@@ -280,12 +342,102 @@ def _get_text_width(text: str, ax: Axes, renderer, **text_kwargs) -> float:
|
|
|
280
342
|
return w
|
|
281
343
|
|
|
282
344
|
|
|
345
|
+
def _get_text_metrics(text: str, ax: Axes, renderer, **text_kwargs) -> tuple:
|
|
346
|
+
"""
|
|
347
|
+
Get text metrics: (width, ascent) in data units.
|
|
348
|
+
- width: horizontal extent
|
|
349
|
+
- ascent: distance from baseline to top of text
|
|
350
|
+
"""
|
|
351
|
+
kwargs = text_kwargs.copy()
|
|
352
|
+
kwargs.pop('underline', None)
|
|
353
|
+
|
|
354
|
+
# Try shaping if available
|
|
355
|
+
if HAS_HARFBUZZ:
|
|
356
|
+
font = kwargs.get('fontfamily') or kwargs.get('family')
|
|
357
|
+
try:
|
|
358
|
+
if not font:
|
|
359
|
+
font = plt.rcParams['font.family'][0]
|
|
360
|
+
if isinstance(font, list):
|
|
361
|
+
font = font[0]
|
|
362
|
+
|
|
363
|
+
fp = FontProperties(family=font)
|
|
364
|
+
path = findfont(fp)
|
|
365
|
+
|
|
366
|
+
if path:
|
|
367
|
+
fontsize = kwargs.get('fontsize') or kwargs.get('size') or plt.rcParams['font.size']
|
|
368
|
+
shaper = HarfbuzzShaper(path)
|
|
369
|
+
|
|
370
|
+
# Get width and ascent in points
|
|
371
|
+
width_points = shaper.get_text_width(text, fontsize)
|
|
372
|
+
ascent_points = shaper.get_ascent(fontsize)
|
|
373
|
+
|
|
374
|
+
# Convert to pixels then to data units
|
|
375
|
+
from matplotlib.transforms import Bbox
|
|
376
|
+
|
|
377
|
+
width_px = renderer.points_to_pixels(width_points)
|
|
378
|
+
ascent_px = renderer.points_to_pixels(ascent_points)
|
|
379
|
+
|
|
380
|
+
# Width: horizontal conversion
|
|
381
|
+
bbox_w = Bbox.from_bounds(0, 0, width_px, 0)
|
|
382
|
+
width_data = bbox_w.transformed(ax.transData.inverted()).width
|
|
383
|
+
|
|
384
|
+
# Ascent: vertical conversion
|
|
385
|
+
bbox_a = Bbox.from_bounds(0, 0, 0, ascent_px)
|
|
386
|
+
ascent_data = bbox_a.transformed(ax.transData.inverted()).height
|
|
387
|
+
|
|
388
|
+
return (width_data, ascent_data)
|
|
389
|
+
except Exception:
|
|
390
|
+
pass # Fallback to native
|
|
391
|
+
|
|
392
|
+
# Native measurement
|
|
393
|
+
t = ax.text(0, 0, text, **kwargs)
|
|
394
|
+
bbox = t.get_window_extent(renderer=renderer)
|
|
395
|
+
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
396
|
+
|
|
397
|
+
width_data = bbox_data.width
|
|
398
|
+
# For native text, ascent ≈ height (simplified; baseline is at bottom of bbox)
|
|
399
|
+
ascent_data = bbox_data.height
|
|
400
|
+
|
|
401
|
+
t.remove()
|
|
402
|
+
return (width_data, ascent_data)
|
|
403
|
+
|
|
404
|
+
|
|
283
405
|
def _get_text_height(text: str, ax: Axes, renderer, **text_kwargs) -> float:
|
|
284
406
|
"""Measure the height of a text string."""
|
|
285
407
|
# Remove custom properties that ax.text doesn't understand
|
|
286
408
|
kwargs = text_kwargs.copy()
|
|
287
409
|
kwargs.pop('underline', None)
|
|
288
410
|
|
|
411
|
+
# Try shaping-based height for Devanagari fonts
|
|
412
|
+
# This avoids measuring with Latin chars that the font might not have
|
|
413
|
+
if HAS_HARFBUZZ:
|
|
414
|
+
try:
|
|
415
|
+
font = kwargs.get('fontfamily') or kwargs.get('family')
|
|
416
|
+
if not font:
|
|
417
|
+
font = plt.rcParams['font.family'][0]
|
|
418
|
+
if isinstance(font, list):
|
|
419
|
+
font = font[0]
|
|
420
|
+
|
|
421
|
+
# Check if this is a known Devanagari font
|
|
422
|
+
devanagari_fonts = ['Noto Sans Devanagari', 'Kalimati', 'Mangal', 'Lohit Devanagari', 'Madan']
|
|
423
|
+
if font and any(df.lower() in str(font).lower() for df in devanagari_fonts):
|
|
424
|
+
fp = FontProperties(family=font)
|
|
425
|
+
path = findfont(fp)
|
|
426
|
+
|
|
427
|
+
if path:
|
|
428
|
+
fontsize = kwargs.get('fontsize') or kwargs.get('size') or plt.rcParams['font.size']
|
|
429
|
+
shaper = HarfbuzzShaper(path)
|
|
430
|
+
height_points = shaper.get_font_height(fontsize)
|
|
431
|
+
|
|
432
|
+
# Convert points -> pixels -> data
|
|
433
|
+
pixels = renderer.points_to_pixels(height_points)
|
|
434
|
+
from matplotlib.transforms import Bbox
|
|
435
|
+
bbox_display = Bbox.from_bounds(0, 0, 0, pixels)
|
|
436
|
+
bbox_data = bbox_display.transformed(ax.transData.inverted())
|
|
437
|
+
return bbox_data.height
|
|
438
|
+
except Exception:
|
|
439
|
+
pass # Fallback to native
|
|
440
|
+
|
|
289
441
|
# Use a representative character for height if text is empty or space
|
|
290
442
|
# But we need the height of THIS specific font configuration.
|
|
291
443
|
measure_text = text if text.strip() else "Hg"
|
|
@@ -302,24 +454,25 @@ def _build_lines_wrapped(
|
|
|
302
454
|
ax: Axes,
|
|
303
455
|
renderer,
|
|
304
456
|
box_width: float
|
|
305
|
-
) -> List[List[Tuple[str, Dict[str, Any], float]]]:
|
|
457
|
+
) -> List[List[Tuple[str, Dict[str, Any], float, float]]]:
|
|
306
458
|
"""
|
|
307
459
|
Group words into lines based on box_width.
|
|
460
|
+
Returns: List of lines, where each line is List of (word, props, width, ascent).
|
|
308
461
|
"""
|
|
309
|
-
lines: List[List[Tuple[str, Dict[str, Any], float]]] = []
|
|
310
|
-
current_line: List[Tuple[str, Dict[str, Any], float]] = []
|
|
462
|
+
lines: List[List[Tuple[str, Dict[str, Any], float, float]]] = []
|
|
463
|
+
current_line: List[Tuple[str, Dict[str, Any], float, float]] = []
|
|
311
464
|
current_line_width = 0.0
|
|
312
465
|
|
|
313
466
|
for word, props in words:
|
|
314
|
-
w =
|
|
467
|
+
w, asc = _get_text_metrics(word, ax, renderer, **props)
|
|
315
468
|
|
|
316
469
|
if current_line_width + w > box_width and current_line:
|
|
317
470
|
# Wrap to new line
|
|
318
471
|
lines.append(current_line)
|
|
319
|
-
current_line = [(word, props, w)]
|
|
472
|
+
current_line = [(word, props, w, asc)]
|
|
320
473
|
current_line_width = w
|
|
321
474
|
else:
|
|
322
|
-
current_line.append((word, props, w))
|
|
475
|
+
current_line.append((word, props, w, asc))
|
|
323
476
|
current_line_width += w
|
|
324
477
|
|
|
325
478
|
if current_line:
|
|
@@ -333,19 +486,20 @@ def _build_line_seamless(
|
|
|
333
486
|
properties: List[Dict[str, Any]],
|
|
334
487
|
ax: Axes,
|
|
335
488
|
renderer
|
|
336
|
-
) -> List[Tuple[str, Dict[str, Any], float]]:
|
|
489
|
+
) -> List[Tuple[str, Dict[str, Any], float, float]]:
|
|
337
490
|
"""
|
|
338
491
|
Build a single line from strings without splitting by spaces.
|
|
492
|
+
Returns: List of (string, props, width, ascent).
|
|
339
493
|
"""
|
|
340
|
-
line: List[Tuple[str, Dict[str, Any], float]] = []
|
|
494
|
+
line: List[Tuple[str, Dict[str, Any], float, float]] = []
|
|
341
495
|
for string, props in zip(strings, properties):
|
|
342
|
-
w =
|
|
343
|
-
line.append((string, props, w))
|
|
496
|
+
w, asc = _get_text_metrics(string, ax, renderer, **props)
|
|
497
|
+
line.append((string, props, w, asc))
|
|
344
498
|
return line
|
|
345
499
|
|
|
346
500
|
|
|
347
501
|
def _draw_lines(
|
|
348
|
-
lines: List[List[Tuple[str, Dict[str, Any], float]]],
|
|
502
|
+
lines: List[List[Tuple[str, Dict[str, Any], float, float]]],
|
|
349
503
|
x: float,
|
|
350
504
|
y: float,
|
|
351
505
|
ax: Axes,
|
|
@@ -357,24 +511,26 @@ def _draw_lines(
|
|
|
357
511
|
zorder: int
|
|
358
512
|
) -> List[Text]:
|
|
359
513
|
"""
|
|
360
|
-
Draw the lines of text onto the axes.
|
|
514
|
+
Draw the lines of text onto the axes using baseline alignment.
|
|
515
|
+
Each line item is (word, props, width, ascent).
|
|
361
516
|
"""
|
|
362
517
|
text_objects: List[Text] = []
|
|
363
518
|
|
|
364
|
-
# Calculate
|
|
365
|
-
|
|
519
|
+
# Calculate metrics for each line
|
|
520
|
+
line_metrics = []
|
|
366
521
|
for line in lines:
|
|
367
|
-
# Find max height in this line
|
|
368
|
-
|
|
369
|
-
|
|
522
|
+
# Find max ascent and max total height in this line
|
|
523
|
+
max_ascent = max(item[3] for item in line) if line else 0.0
|
|
524
|
+
max_height = 0.0
|
|
525
|
+
for word, props, w, asc in line:
|
|
370
526
|
h = _get_text_height(word, ax, renderer, **props)
|
|
371
|
-
if h >
|
|
372
|
-
|
|
373
|
-
|
|
527
|
+
if h > max_height:
|
|
528
|
+
max_height = h
|
|
529
|
+
line_metrics.append((max_ascent, max_height * linespacing))
|
|
374
530
|
|
|
375
|
-
total_block_height = sum(
|
|
531
|
+
total_block_height = sum(m[1] for m in line_metrics)
|
|
376
532
|
|
|
377
|
-
# Calculate top Y position based on vertical alignment
|
|
533
|
+
# Calculate top Y position based on vertical alignment of the block
|
|
378
534
|
if va == 'center':
|
|
379
535
|
top_y = y + (total_block_height / 2)
|
|
380
536
|
elif va == 'top':
|
|
@@ -387,10 +543,11 @@ def _draw_lines(
|
|
|
387
543
|
current_y = top_y
|
|
388
544
|
|
|
389
545
|
for i, line in enumerate(lines):
|
|
390
|
-
line_height =
|
|
546
|
+
max_ascent, line_height = line_metrics[i]
|
|
391
547
|
|
|
392
|
-
#
|
|
393
|
-
|
|
548
|
+
# Calculate baseline position
|
|
549
|
+
# Baseline is at: top of line - max_ascent
|
|
550
|
+
baseline_y = current_y - max_ascent
|
|
394
551
|
|
|
395
552
|
# Calculate line width for horizontal alignment
|
|
396
553
|
line_width = sum(item[2] for item in line)
|
|
@@ -399,25 +556,50 @@ def _draw_lines(
|
|
|
399
556
|
line_start_x = x - (line_width / 2)
|
|
400
557
|
elif ha == 'right':
|
|
401
558
|
line_start_x = x - line_width
|
|
402
|
-
else:
|
|
559
|
+
else: # left
|
|
403
560
|
line_start_x = x
|
|
404
561
|
|
|
405
562
|
current_x = line_start_x
|
|
406
563
|
|
|
407
|
-
for word, props, w in line:
|
|
564
|
+
for word, props, w, asc in line:
|
|
408
565
|
text_kwargs = props.copy()
|
|
409
566
|
|
|
410
567
|
# Extract underline property
|
|
411
568
|
underline = text_kwargs.pop('underline', False)
|
|
412
569
|
|
|
570
|
+
# Use baseline alignment for all text
|
|
413
571
|
text_kwargs.update({
|
|
414
|
-
'va': '
|
|
572
|
+
'va': 'baseline',
|
|
415
573
|
'ha': 'left',
|
|
416
574
|
'transform': transform,
|
|
417
575
|
'zorder': zorder
|
|
418
576
|
})
|
|
419
577
|
|
|
420
|
-
|
|
578
|
+
# Determine if we should use ShapedText
|
|
579
|
+
used_shaper = False
|
|
580
|
+
t = None
|
|
581
|
+
|
|
582
|
+
if HAS_HARFBUZZ and _needs_complex_shaping(word):
|
|
583
|
+
try:
|
|
584
|
+
font = text_kwargs.get('fontfamily') or text_kwargs.get('family')
|
|
585
|
+
if not font:
|
|
586
|
+
font = plt.rcParams['font.family'][0]
|
|
587
|
+
if isinstance(font, list):
|
|
588
|
+
font = font[0]
|
|
589
|
+
|
|
590
|
+
fp = FontProperties(family=font)
|
|
591
|
+
path = findfont(fp)
|
|
592
|
+
|
|
593
|
+
if path:
|
|
594
|
+
t = ShapedText(current_x, baseline_y, word, font_path=path, **text_kwargs)
|
|
595
|
+
ax.add_artist(t)
|
|
596
|
+
used_shaper = True
|
|
597
|
+
except Exception as e:
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
if not used_shaper:
|
|
601
|
+
t = ax.text(current_x, baseline_y, word, **text_kwargs)
|
|
602
|
+
|
|
421
603
|
text_objects.append(t)
|
|
422
604
|
|
|
423
605
|
# Draw underline if requested
|
|
@@ -433,11 +615,34 @@ def _draw_lines(
|
|
|
433
615
|
# Let's put the underline at line_center_y - (h/2) - padding?
|
|
434
616
|
|
|
435
617
|
# 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())
|
|
618
|
+
# bbox = t.get_window_extent(renderer=renderer)
|
|
619
|
+
# bbox_data = bbox.transformed(ax.transData.inverted())
|
|
438
620
|
|
|
439
621
|
# y0 is the bottom of the text bbox
|
|
440
|
-
y_bottom = bbox_data.y0
|
|
622
|
+
# y_bottom = bbox_data.y0
|
|
623
|
+
|
|
624
|
+
# Since ShapedText might behave differently with bbox, and we already know 'w'.
|
|
625
|
+
# And we aligned 'va=center'.
|
|
626
|
+
# Let's use the line_center_y and offset down.
|
|
627
|
+
# Text Height approximation:
|
|
628
|
+
fontsize = text_kwargs.get('fontsize', 12)
|
|
629
|
+
# data_height approximation?
|
|
630
|
+
# This is risky if aspect ratio is not 1.
|
|
631
|
+
# Fallback to bbox logic, assuming draw has happened?
|
|
632
|
+
# ShapedText needs to be drawn to have a valid bbox?
|
|
633
|
+
# In Matplotlib, get_window_extent() triggers a draw if needed?
|
|
634
|
+
# Or we can just trust the metrics.
|
|
635
|
+
|
|
636
|
+
# For consistency with previous code, let's try getting bbox.
|
|
637
|
+
try:
|
|
638
|
+
bbox = t.get_window_extent(renderer=renderer)
|
|
639
|
+
bbox_data = bbox.transformed(ax.transData.inverted())
|
|
640
|
+
y_bottom = bbox_data.y0
|
|
641
|
+
except Exception:
|
|
642
|
+
# Fallback if renderer issue
|
|
643
|
+
y_bottom = line_center_y - 5 # arbitrary?
|
|
644
|
+
|
|
645
|
+
# Draw line from current_x to current_x + w
|
|
441
646
|
|
|
442
647
|
# Draw line from current_x to current_x + w
|
|
443
648
|
# Use the same color as the text
|
mpl_richtext/shaping.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
renderer.draw_path(gc, path, glyph_trans)
|
|
285
|
+
|
|
286
|
+
gc.restore()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
mpl_richtext/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '0.1.
|
|
1
|
+
__version__ = '0.1.5'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpl-richtext
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mpl_richtext/__init__.py,sha256=430UvdXDNTa9xCoQ48OT_lzHrFCW_xCuVaTr1apEVq4,246
|
|
2
|
+
mpl_richtext/core.py,sha256=DGzlbJkWTSzLOdhHVHVor2hPnw4Ra_bJIFhZnCnb-I4,24474
|
|
3
|
+
mpl_richtext/shaping.py,sha256=E-RrjWE7JvAvVi407xwFjCuzfQ4vpoSKv4YWhzfob40,10705
|
|
4
|
+
mpl_richtext/version.py,sha256=CjpRE9sT1AfD9Ft__Fhhop9Bh9IBCVEmm9E9TX1aV2k,21
|
|
5
|
+
mpl_richtext-0.1.5.dist-info/licenses/LICENSE,sha256=3ldFhzXg_DuFYRbdYJaN661E5PAzUpVjWxFCxVm7kG8,1067
|
|
6
|
+
mpl_richtext-0.1.5.dist-info/METADATA,sha256=oMbscy02JU86mlSEnYqcagm3XgIwrIFghkV8p1DCFxM,5443
|
|
7
|
+
mpl_richtext-0.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
+
mpl_richtext-0.1.5.dist-info/top_level.txt,sha256=3K_jSX3xxD2Dhaqm4jtejlO0HI_5tl7F7AMq4lGk6xw,13
|
|
9
|
+
mpl_richtext-0.1.5.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
mpl_richtext/__init__.py,sha256=430UvdXDNTa9xCoQ48OT_lzHrFCW_xCuVaTr1apEVq4,246
|
|
2
|
-
mpl_richtext/core.py,sha256=OZ1NCJQOHpIsdcM6c-7NKXdZxeTKBGoAPWkPkhpTfr4,15504
|
|
3
|
-
mpl_richtext/version.py,sha256=aQBEKpvpKEKYji4cYsgB4g2HDG_MabPM9YDpijQ9vcY,23
|
|
4
|
-
mpl_richtext-0.1.3.1.dist-info/licenses/LICENSE,sha256=3ldFhzXg_DuFYRbdYJaN661E5PAzUpVjWxFCxVm7kG8,1067
|
|
5
|
-
mpl_richtext-0.1.3.1.dist-info/METADATA,sha256=szEAkVMk8xoUtF1vzmhtcKlTj-YELdpLnuFzYm3hwWg,5379
|
|
6
|
-
mpl_richtext-0.1.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
-
mpl_richtext-0.1.3.1.dist-info/top_level.txt,sha256=3K_jSX3xxD2Dhaqm4jtejlO0HI_5tl7F7AMq4lGk6xw,13
|
|
8
|
-
mpl_richtext-0.1.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|