mpl-richtext 0.1.4__tar.gz → 0.1.5__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.5}/PKG-INFO +3 -1
  2. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext/core.py +237 -32
  3. mpl_richtext-0.1.5/mpl_richtext/shaping.py +289 -0
  4. mpl_richtext-0.1.5/mpl_richtext/version.py +1 -0
  5. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext.egg-info/PKG-INFO +3 -1
  6. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext.egg-info/SOURCES.txt +1 -0
  7. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext.egg-info/requires.txt +2 -0
  8. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/pyproject.toml +2 -0
  9. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/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.5}/.github/workflows/publish.yml +0 -0
  12. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/.gitignore +0 -0
  13. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/LICENSE +0 -0
  14. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/MANIFEST.in +0 -0
  15. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/README.md +0 -0
  16. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/examples/basic_usage.py +0 -0
  17. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/examples/mpl_richtext_examples.png +0 -0
  18. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext/__init__.py +0 -0
  19. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext.egg-info/dependency_links.txt +0 -0
  20. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/mpl_richtext.egg-info/top_level.txt +0 -0
  21. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/setup.cfg +0 -0
  22. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/tests/__init__.py +0 -0
  23. {mpl_richtext-0.1.4 → mpl_richtext-0.1.5}/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.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"
@@ -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 = _get_text_width(word, ax, renderer, **props)
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 = _get_text_width(string, ax, renderer, **props)
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 height for each line
365
- line_heights = []
519
+ # Calculate metrics for each line
520
+ line_metrics = []
366
521
  for line in lines:
367
- # Find max height in this line
368
- max_h = 0.0
369
- for word, props, _ in line:
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 > max_h:
372
- max_h = h
373
- line_heights.append(max_h * linespacing)
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(line_heights)
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 = line_heights[i]
546
+ max_ascent, line_height = line_metrics[i]
391
547
 
392
- # Position line center
393
- line_center_y = current_y - (line_height / 2)
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: # left
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': 'center',
572
+ 'va': 'baseline',
415
573
  'ha': 'left',
416
574
  'transform': transform,
417
575
  'zorder': zorder
418
576
  })
419
577
 
420
- t = ax.text(current_x, line_center_y, word, **text_kwargs)
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
@@ -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
+
@@ -0,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.4
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"
@@ -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