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.
Files changed (23) hide show
  1. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/PKG-INFO +3 -1
  2. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext/core.py +242 -32
  3. mpl_richtext-0.1.7/mpl_richtext/shaping.py +291 -0
  4. mpl_richtext-0.1.7/mpl_richtext/version.py +1 -0
  5. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/PKG-INFO +3 -1
  6. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/SOURCES.txt +1 -0
  7. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/requires.txt +2 -0
  8. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/pyproject.toml +2 -0
  9. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/setup.py +2 -0
  10. mpl_richtext-0.1.4/mpl_richtext/version.py +0 -1
  11. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/.github/workflows/publish.yml +0 -0
  12. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/.gitignore +0 -0
  13. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/LICENSE +0 -0
  14. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/MANIFEST.in +0 -0
  15. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/README.md +0 -0
  16. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/examples/basic_usage.py +0 -0
  17. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/examples/mpl_richtext_examples.png +0 -0
  18. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext/__init__.py +0 -0
  19. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/dependency_links.txt +0 -0
  20. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/mpl_richtext.egg-info/top_level.txt +0 -0
  21. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/setup.cfg +0 -0
  22. {mpl_richtext-0.1.4 → mpl_richtext-0.1.7}/tests/__init__.py +0 -0
  23. {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.4
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 = _get_text_width(word, ax, renderer, **props)
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 = _get_text_width(string, ax, renderer, **props)
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 height for each line
365
- line_heights = []
532
+ # Calculate metrics for each line
533
+ line_metrics = []
366
534
  for line in lines:
367
- # Find max height in this line
368
- max_h = 0.0
369
- for word, props, _ in line:
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 > max_h:
372
- max_h = h
373
- line_heights.append(max_h * linespacing)
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(line_heights)
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 = line_heights[i]
559
+ max_ascent, line_height = line_metrics[i]
391
560
 
392
- # Position line center
393
- line_center_y = current_y - (line_height / 2)
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: # left
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': 'center',
585
+ 'va': 'baseline',
415
586
  'ha': 'left',
416
587
  'transform': transform,
417
588
  'zorder': zorder
418
589
  })
419
590
 
420
- t = ax.text(current_x, line_center_y, word, **text_kwargs)
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.4
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"
@@ -9,6 +9,7 @@ examples/basic_usage.py
9
9
  examples/mpl_richtext_examples.png
10
10
  mpl_richtext/__init__.py
11
11
  mpl_richtext/core.py
12
+ mpl_richtext/shaping.py
12
13
  mpl_richtext/version.py
13
14
  mpl_richtext.egg-info/PKG-INFO
14
15
  mpl_richtext.egg-info/SOURCES.txt
@@ -1,4 +1,6 @@
1
1
  matplotlib>=3.5.0
2
+ uharfbuzz>=0.30.0
3
+ fonttools>=4.34.0
2
4
 
3
5
  [dev]
4
6
  pytest>=7.0
@@ -37,6 +37,8 @@ classifiers = [
37
37
  ]
38
38
  dependencies = [
39
39
  "matplotlib>=3.5.0",
40
+ "uharfbuzz>=0.30.0",
41
+ "fonttools>=4.34.0",
40
42
  ]
41
43
 
42
44
  [project.optional-dependencies]
@@ -44,6 +44,8 @@ setup(
44
44
  python_requires=">=3.8",
45
45
  install_requires=[
46
46
  "matplotlib>=3.5.0",
47
+ "uharfbuzz>=0.30.0",
48
+ "fonttools>=4.34.0",
47
49
  ],
48
50
  extras_require={
49
51
  "dev": [
@@ -1 +0,0 @@
1
- __version__ = '0.1.4'
File without changes
File without changes
File without changes
File without changes
File without changes