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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""タイトルと軸ラベルのテキスト(mathtext を含む)を、グラフと同じ matplotlib の描画で QPixmap にする。"""
2import re
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
11# 描いた範囲だけを後で切り取るので大きめでよい。ただし幅を超えた文字は切れ、縮小の判定を誤るので余裕を持たせる
12_CANVAS_SIZE_INCHES = (10.0, 0.6)
13_CROP_PADDING_PX = 3
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]
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"]
31def _has_mathtext(text):
32 """エスケープしていない $ が 2 つ以上あれば、matplotlib はその文字列を数式として描く。"""
33 return len(re.findall(r"(?<!\\)\$", text)) >= 2
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]
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
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)
67# これより小さくすると読めないので、そこからは pixmap ごと縮める
68_MIN_FONTSIZE = 7
71def render_mathtext_to_pixmap(text, color="#000000", fontsize=13, dpi=150, max_width_px=None):
72 """描いた範囲だけを切り取った QPixmap。
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
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()
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())
115class FitWidthPixmapLabel(QLabel):
116 """set_natural_pixmap() で渡した等倍の pixmap を、ラベルの幅に収まるよう縮めて出す。
118 描く時点の width() は、非表示のタブなどでは確定していないので、resizeEvent で合わせ直す。
119 """
121 def __init__(self, parent=None):
122 super().__init__(parent)
123 self._natural_pixmap = None
125 def set_natural_pixmap(self, pixmap):
126 self._natural_pixmap = pixmap
127 self._apply_fitted_pixmap()
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)
149 def resizeEvent(self, event):
150 super().resizeEvent(event)
151 self._apply_fitted_pixmap()
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]
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]