Coverage for graphica/gui/mathtext_preview.py: 87.5%

98 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""タイトルと軸ラベルのテキスト(mathtext を含む)を、グラフと同じ matplotlib の描画で QPixmap にする。""" 

2import re 

3 

4import numpy as np 

5from matplotlib.backends.backend_agg import FigureCanvasAgg 

6from matplotlib.figure import Figure 

7from PySide6.QtCore import Qt 

8from PySide6.QtGui import QImage, QPixmap 

9from PySide6.QtWidgets import QLabel 

10 

11# 描いた範囲だけを後で切り取るので大きめでよい。ただし幅を超えた文字は切れ、縮小の判定を誤るので余裕を持たせる 

12_CANVAS_SIZE_INCHES = (10.0, 0.6) 

13_CROP_PADDING_PX = 3 

14 

15# 日本語を描けるフォントの候補。matplotlib の既定(DejaVu Sans)には日本語が無い。Qt の UI 用の名前 

16# ("Yu Gothic UI")は matplotlib では見つからないので使わない。先頭から順にグリフのあるフォントが使われ、 

17# 無い名前は飛ばされるので OS ごとの候補を並べる(Yu Gothic などは Windows、Hiragino は macOS、Noto は Linux)。 

18# グラフ本体の既定フォント(main_window の PLOT_DEFAULT_FONT_FAMILIES)と注釈(rendering/annotations.py)もこれを使う。 

19# $...$ を含む文字列の数式の外の文字は、候補の先頭 1 つで描かれる(families_for_text)。mathtext.fontset は 

20# プロセス全体の設定で、変えると全部の描画に効くので触らない。 

21JP_CAPABLE_FONT_FAMILIES = [ 

22 "DejaVu Sans", "Yu Gothic", "Hiragino Sans", "Hiragino Kaku Gothic ProN", 

23 "Meiryo", "MS Gothic", "Noto Sans CJK JP", 

24] 

25 

26_CJK_PATTERN = re.compile(r"[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\uff00-\uffef]") 

27# 数式の外の文字を描ける日本語の字体(DejaVu Sans は日本語を持たない) 

28_JAPANESE_FONT_FAMILIES = [f for f in JP_CAPABLE_FONT_FAMILIES if f != "DejaVu Sans"] 

29 

30 

31def _has_mathtext(text): 

32 """エスケープしていない $ が 2 つ以上あれば、matplotlib はその文字列を数式として描く。""" 

33 return len(re.findall(r"(?<!\\)\$", text)) >= 2 

34 

35 

36def families_for_text(text, families): 

37 """数式を含む文字列では、matplotlib は数式の外の文字を字体の候補の先頭 1 つだけで描く(ほかの候補に頼らない)。 

38 日本語と数式を混ぜたときだけ、日本語の字体を先頭に置いて □ にならないようにする。それ以外は families のまま。""" 

39 if not text or not _CJK_PATTERN.search(text) or not _has_mathtext(text): 

40 return families 

41 if isinstance(families, str): 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true

42 families = [families] 

43 families = list(families or []) 

44 if families and families[0].lower() in {f.lower() for f in _JAPANESE_FONT_FAMILIES}: 

45 return families 

46 return _JAPANESE_FONT_FAMILIES + [f for f in families if f not in _JAPANESE_FONT_FAMILIES] 

47 

48 

49def families_for_texts(texts, families): 

50 """凡例のように 1 つの字体で複数の文字列を描くとき。どれかが日本語と数式を混ぜていれば、その並びにする。""" 

51 for text in texts: 

52 adjusted = families_for_text(text, families) 

53 if adjusted is not families: 

54 return adjusted 

55 return families 

56 

57 

58def font_kwargs_for_text(text, font_kwargs): 

59 """ax.set_title(text, **font_kwargs) などに渡す字体の辞書を、families_for_text() に合わせて返す(元は変えない)。""" 

60 family = font_kwargs.get('family') if font_kwargs else None 

61 adjusted = families_for_text(text, family) 

62 if adjusted is family: 

63 return font_kwargs 

64 return dict(font_kwargs, family=adjusted) 

65 

66 

67# これより小さくすると読めないので、そこからは pixmap ごと縮める 

68_MIN_FONTSIZE = 7 

69 

70 

71def render_mathtext_to_pixmap(text, color="#000000", fontsize=13, dpi=150, max_width_px=None): 

72 """描いた範囲だけを切り取った QPixmap。 

73 

74 mathtext の構文が壊れていれば、$ をエスケープして文字のまま描く(入力の邪魔をしない)。 

75 max_width_px を超えればフォントを小さくし、下限でも収まらなければ pixmap を縮める。 

76 """ 

77 pixmap = _render_once(text, color, fontsize, dpi) 

78 if max_width_px and pixmap.width() > max_width_px > 0: 78 ↛ 80line 78 didn't jump to line 80 because the condition on line 78 was never true

79 # 幅はフォントサイズにほぼ比例するので、比例で縮めて1回だけ描き直す 

80 scaled_fontsize = max(_MIN_FONTSIZE, fontsize * (max_width_px / pixmap.width()) * 0.95) 

81 pixmap = _render_once(text, color, scaled_fontsize, dpi) 

82 if pixmap.width() > max_width_px > 0: 

83 pixmap = pixmap.scaledToWidth( 

84 max_width_px, Qt.TransformationMode.SmoothTransformation 

85 ) 

86 return pixmap 

87 

88 

89def _render_once(text, color, fontsize, dpi): 

90 fig = Figure(figsize=_CANVAS_SIZE_INCHES, dpi=dpi) 

91 canvas = FigureCanvasAgg(fig) 

92 fig.patch.set_alpha(0.0) 

93 display_text = text if text else " " 

94 try: 

95 fig.text(0.01, 0.5, display_text, fontsize=fontsize, color=color, 

96 family=families_for_text(display_text, JP_CAPABLE_FONT_FAMILIES), va='center', ha='left') 

97 canvas.draw() 

98 except ValueError: # mathtext の構文エラー。$ をそのまま文字として描き直す 

99 fig = Figure(figsize=_CANVAS_SIZE_INCHES, dpi=dpi) 

100 canvas = FigureCanvasAgg(fig) 

101 fig.patch.set_alpha(0.0) 

102 fig.text(0.01, 0.5, display_text.replace("$", "\\$"), fontsize=fontsize, 

103 color=color, family=JP_CAPABLE_FONT_FAMILIES, va='center', ha='left') 

104 canvas.draw() 

105 

106 buf = np.asarray(canvas.buffer_rgba()) 

107 cropped = _crop_to_content(buf) 

108 cropped = np.ascontiguousarray(cropped) 

109 height, width, _ = cropped.shape 

110 image = QImage(cropped.data, width, height, width * 4, QImage.Format.Format_RGBA8888) 

111 # QImage は cropped の配列を参照しているだけなので、関数を抜けると宙に浮く。copy() する 

112 return QPixmap.fromImage(image.copy()) 

113 

114 

115class FitWidthPixmapLabel(QLabel): 

116 """set_natural_pixmap() で渡した等倍の pixmap を、ラベルの幅に収まるよう縮めて出す。 

117 

118 描く時点の width() は、非表示のタブなどでは確定していないので、resizeEvent で合わせ直す。 

119 """ 

120 

121 def __init__(self, parent=None): 

122 super().__init__(parent) 

123 self._natural_pixmap = None 

124 

125 def set_natural_pixmap(self, pixmap): 

126 self._natural_pixmap = pixmap 

127 self._apply_fitted_pixmap() 

128 

129 def _apply_fitted_pixmap(self): 

130 if self._natural_pixmap is None or self._natural_pixmap.isNull(): 

131 return 

132 # QSS の枠と余白を除いた contentsRect() に合わせる(でないと端が欠ける)。 

133 # 幅だけでなく高さもはみ出していれば、はみ出した方に合わせて縮める。 

134 available_rect = self.contentsRect() 

135 available_width = available_rect.width() 

136 available_height = available_rect.height() 

137 natural = self._natural_pixmap 

138 if (available_width > 0 and natural.width() > available_width) or ( 

139 available_height > 0 and natural.height() > available_height 

140 ): 

141 fitted = natural.scaled( 

142 max(available_width, 1), max(available_height, 1), 

143 Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, 

144 ) 

145 else: 

146 fitted = natural 

147 super().setPixmap(fitted) 

148 

149 def resizeEvent(self, event): 

150 super().resizeEvent(event) 

151 self._apply_fitted_pixmap() 

152 

153 

154def _crop_to_content(rgba_array): 

155 """不透明なピクセルの外接矩形(と少しの余白)で切り取る。""" 

156 alpha = rgba_array[:, :, 3] 

157 rows = np.any(alpha > 0, axis=1) 

158 cols = np.any(alpha > 0, axis=0) 

159 if not rows.any() or not cols.any(): 

160 return rgba_array[:1, :1] 

161 

162 row_indices = np.where(rows)[0] 

163 col_indices = np.where(cols)[0] 

164 row_min = max(0, row_indices[0] - _CROP_PADDING_PX) 

165 row_max = min(rgba_array.shape[0] - 1, row_indices[-1] + _CROP_PADDING_PX) 

166 col_min = max(0, col_indices[0] - _CROP_PADDING_PX) 

167 col_max = min(rgba_array.shape[1] - 1, col_indices[-1] + _CROP_PADDING_PX) 

168 return rgba_array[row_min:row_max + 1, col_min:col_max + 1]