Coverage for graphica/gui/export_preview_panel.py: 97.7%
243 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"""書き出しのプレビューのドック。設定を変えるたびに全サブプロットの完成形を描き直し、ここから保存とコピーもできる。
3一時的な MplCanvas に描くので、画面のキャンバスには触れない。
4"""
5import copy
6import io
7import os
8import logging
9import matplotlib as mpl
11from PySide6.QtCore import Qt, QTimer, QByteArray, QMimeData
12from PySide6.QtGui import QPixmap
13from PySide6.QtWidgets import (QWidget, QVBoxLayout, QFormLayout, QHBoxLayout,
14 QLabel, QComboBox, QCheckBox, QDoubleSpinBox, QSpinBox,
15 QPushButton, QApplication)
17from graphica.core.i18n import tr
18from graphica.gui import notify
19from graphica.gui import dialog_dirs
20from graphica.gui.canvas import MplCanvas
21from graphica.gui.export_settings import export_rc_params
22from graphica.gui.export_size import ExportSizeFields, confirm_output_size, preview_dpi, preview_too_large_text
23from graphica.gui.theme import apply_form_spacing
25logger = logging.getLogger(__name__)
27# 矢印の連打などで毎回重い描き直しが走らないよう、まとめるまでの待ち(ms)
28PREVIEW_DEBOUNCE_MS = 150
31class ExportPreviewPanel(QWidget):
32 def __init__(self, main_window, parent=None):
33 super().__init__(parent)
34 self.main_window = main_window
35 self._current_pixmap = None
37 layout = QVBoxLayout(self)
39 form = QFormLayout()
40 self.width_spinbox = QDoubleSpinBox()
41 self.width_spinbox.setRange(1, 10000)
42 self.width_spinbox.setDecimals(1)
43 self.width_spinbox.setValue(800)
44 form.addRow("幅", self.width_spinbox)
46 self.height_spinbox = QDoubleSpinBox()
47 self.height_spinbox.setRange(1, 10000)
48 self.height_spinbox.setDecimals(1)
49 self.height_spinbox.setValue(600)
50 form.addRow("高さ", self.height_spinbox)
52 self.unit_combo = QComboBox()
53 # 値は日本語の元の文字(書き出しの処理がそれで単位を見分ける)。表示だけを訳す
54 for unit in ("ピクセル (px)", "インチ (in)", "センチメートル (cm)"):
55 self.unit_combo.addItem(tr(unit), unit)
56 form.addRow("単位", self.unit_combo)
58 self.dpi_spinbox = QSpinBox()
59 self.dpi_spinbox.setRange(50, 1200)
60 self.dpi_spinbox.setValue(150)
61 self.dpi_spinbox.setSuffix(" dpi")
62 form.addRow("解像度", self.dpi_spinbox)
64 self.output_size_label = QLabel()
65 form.addRow(self.output_size_label)
66 # 単位のコンボの描き直しより先に換算が済むよう、ほかの通知をつなぐ前に作る
67 self._size_fields = ExportSizeFields(
68 self.width_spinbox, self.height_spinbox, self.unit_combo, self.dpi_spinbox, self.output_size_label)
70 self.transparent_checkbox = QCheckBox("背景を透過")
71 self.transparent_checkbox.setChecked(True)
72 form.addRow(self.transparent_checkbox)
74 # 「名前を付けてエクスポート」は画面の図をそのまま書き出すので、既定はそれと同じ範囲にする
75 self.visible_range_checkbox = QCheckBox("表示中の範囲で書き出す")
76 self.visible_range_checkbox.setChecked(True)
77 self.visible_range_checkbox.setToolTip("グラフの上でマウスで拡大・移動した範囲で書き出します。外すと、プロットのプロパティで決めた範囲(自動ならデータ全体)で書き出します。")
78 form.addRow(self.visible_range_checkbox)
80 self.svg_text_as_path_checkbox = QCheckBox("文字をアウトライン化する(SVG)")
81 self.svg_text_as_path_checkbox.setToolTip(
82 "SVG保存/コピー時、目盛りの数字やラベルの文字をパス(輪郭線)として出力します。"
83 )
84 form.addRow(self.svg_text_as_path_checkbox)
86 # プレビューは応答を優先して常に間引いて描く。このチェックは保存とコピーだけに効く
87 # 解像度(dpi)の設定と取り違えられたので、「間引かない」ことを名前にする
88 self.full_resolution_checkbox = QCheckBox("データを間引かずに保存/コピー")
89 self.full_resolution_checkbox.setToolTip(
90 "「名前を付けて保存」「コピー」で、点数の多い Line(折れ線)データセットや 2D マップ(ヒートマップ/等高線)を、"
91 "画面表示用に間引かず全データ点で描きます(処理が遅くなる場合があります)。画像の解像度(dpi)とは別の設定です。常に描き直すプレビュー自体は、応答を優先して間引いたまま表示します。"
92 )
93 form.addRow(self.full_resolution_checkbox)
95 layout.addLayout(form)
96 apply_form_spacing(self)
98 self.preview_label = QLabel("プレビューがここに表示されます")
99 self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
100 self.preview_label.setMinimumSize(200, 150)
101 self.preview_label.setFrameShape(QLabel.Shape.StyledPanel)
102 self.preview_label.setWordWrap(True)
103 layout.addWidget(self.preview_label, 1)
105 button_row = QHBoxLayout()
106 # SVG はベクター画像を受け付けるアプリ(Illustrator、Inkscape など)に貼れる
107 self.copy_format_combo = QComboBox()
108 self.copy_format_combo.addItems(["PNG", "SVG"])
109 self.copy_format_combo.setToolTip("クリップボードにコピーする形式を選択します")
110 button_row.addWidget(self.copy_format_combo)
112 self.copy_button = QPushButton("コピー")
113 self.copy_button.setToolTip("現在の設定でレンダリングした画像をクリップボードにコピーします")
114 self.copy_button.clicked.connect(self._on_copy_clicked)
115 button_row.addWidget(self.copy_button)
117 self.save_button = QPushButton("名前を付けて保存...")
118 self.save_button.clicked.connect(self._on_save_clicked)
119 button_row.addWidget(self.save_button)
120 layout.addLayout(button_row)
122 self.width_spinbox.valueChanged.connect(self.refresh_preview)
123 self.height_spinbox.valueChanged.connect(self.refresh_preview)
124 self.unit_combo.currentIndexChanged.connect(self.refresh_preview)
125 self.dpi_spinbox.valueChanged.connect(self.refresh_preview)
126 self.visible_range_checkbox.toggled.connect(self.refresh_preview)
128 self._refresh_timer = QTimer(self)
129 self._refresh_timer.setSingleShot(True)
130 self._refresh_timer.setInterval(PREVIEW_DEBOUNCE_MS)
131 self._refresh_timer.timeout.connect(self._render_if_shown)
133 def get_options(self):
134 """ExportDialog と同じ形の設定(_calculate_size_in_inches で使える)。"""
135 return {
136 "width": self.width_spinbox.value(),
137 "height": self.height_spinbox.value(),
138 "unit": self.unit_combo.currentData(),
139 "dpi": self.dpi_spinbox.value(),
140 "transparent": self.transparent_checkbox.isChecked(),
141 "svg_text_as_path": self.svg_text_as_path_checkbox.isChecked(),
142 "full_resolution": self.full_resolution_checkbox.isChecked(),
143 "visible_range": self.visible_range_checkbox.isChecked(),
144 }
146 def refresh_preview(self):
147 """少し待ってまとめて描く。隠れている間は描かない(表示したときに refresh_preview が呼ばれる)。"""
148 if not self.isVisible():
149 return
150 self._refresh_timer.start()
152 def _render_if_shown(self):
153 """実際に見えているときだけ描く。プロパティとタブで並べたドックは、後ろのタブでも isVisible() が真のままなので、
154 見えている領域で確かめる(前に出たときはドックの visibilityChanged から refresh_preview が呼ばれる)。"""
155 if self.isVisible() and not self.visibleRegion().isEmpty(): 155 ↛ exitline 155 didn't return from function '_render_if_shown' because the condition on line 155 was always true
156 self._render_preview()
158 def _render_preview(self):
159 options = self.get_options()
160 width_in, height_in = self.main_window._calculate_size_in_inches(options)
161 if width_in <= 0 or height_in <= 0:
162 return
164 # 書き出しの解像度のまま描いてから縮めると、大きな図や高い解像度で固まる。欄に合う解像度で描く
165 dpi = preview_dpi(width_in, height_in, options["dpi"],
166 self.preview_label.width(), self.preview_label.height())
167 if dpi is None:
168 self._current_pixmap = None
169 self.preview_label.setPixmap(QPixmap())
170 self.preview_label.setText(preview_too_large_text(width_in, height_in, options["dpi"]))
171 return
172 pixmap = self._render_full_figure_pixmap(width_in, height_in, dpi)
173 self._current_pixmap = pixmap
174 if pixmap is None or pixmap.isNull():
175 self.preview_label.setPixmap(QPixmap())
176 self.preview_label.setText("プレビューを生成できませんでした\n(データセットがないか、設定を確認してください)")
177 return
178 self.preview_label.setText("")
179 self.preview_label.setPixmap(
180 pixmap.scaled(self.preview_label.size(),
181 Qt.AspectRatioMode.KeepAspectRatio,
182 Qt.TransformationMode.SmoothTransformation)
183 )
185 def resizeEvent(self, event):
186 super().resizeEvent(event)
187 if self._current_pixmap is not None and not self._current_pixmap.isNull():
188 self.preview_label.setPixmap(
189 self._current_pixmap.scaled(self.preview_label.size(),
190 Qt.AspectRatioMode.KeepAspectRatio,
191 Qt.TransformationMode.SmoothTransformation)
192 )
193 # 欄に合わせた解像度で描いているので、欄が描いた画像より大きくなったら描き直す
194 if (self._current_pixmap.width() < self.preview_label.width() 194 ↛ 196line 194 didn't jump to line 196 because the condition on line 194 was never true
195 and self._current_pixmap.height() < self.preview_label.height()):
196 self.refresh_preview()
198 def _make_temp_canvas_for_full_figure(self, width_in, height_in, dpi, full_resolution=False):
199 """全サブプロットを描いた一時的な MplCanvas。描けるものが無ければ None。
201 full_resolution=True は間引かない。常に描き直すプレビューでは False、保存とコピーでだけ設定の値を渡す。
202 """
203 mw = self.main_window
204 layout_mode = getattr(mw.project, 'layout_mode', 'grid')
205 if layout_mode == 'free':
206 rows, cols = 0, 0
207 if not mw.project.all_plot_settings: 207 ↛ 215line 207 didn't jump to line 215 because the condition on line 207 was always true
208 return None
209 else:
210 rows = mw.subplot_rows_spinbox.value()
211 cols = mw.subplot_cols_spinbox.value()
212 if rows * cols == 0:
213 return None
215 temp_canvas = MplCanvas(width=width_in, height=height_in, dpi=dpi)
216 temp_canvas.dark_mode = mw.canvas.dark_mode
217 if self.visible_range_checkbox.isChecked():
218 # 当て直すときに設定と合わない範囲を消すので、画面の記録を写して渡す
219 temp_canvas.view_overrides = copy.deepcopy(mw.canvas.view_overrides)
220 temp_canvas.redraw_all(
221 mw.project.datasets, rows, cols, mw.project.all_plot_settings, layout_mode=layout_mode,
222 panel_labels_enabled=mw.project.panel_labels_enabled,
223 full_resolution=full_resolution,
224 )
225 return temp_canvas
227 def _render_full_figure_pixmap(self, width_in, height_in, dpi):
228 """画面のプレビュー用。常に不透明で描く(透過は保存とコピーのときだけ選べる)。"""
229 try:
230 temp_canvas = self._make_temp_canvas_for_full_figure(width_in, height_in, dpi)
231 if temp_canvas is None:
232 return None
233 try:
234 buf = io.BytesIO()
235 temp_canvas.fig.savefig(
236 buf, format='png', dpi=dpi, bbox_inches='tight',
237 facecolor=temp_canvas.fig.get_facecolor()
238 )
239 buf.seek(0)
240 pixmap = QPixmap()
241 pixmap.loadFromData(buf.read())
242 buf.close()
243 return pixmap
244 finally:
245 temp_canvas.deleteLater()
246 except Exception:
247 logger.exception("エクスポートプレビューの生成に失敗しました。")
248 return None
250 def _render_full_figure_bytes(self, width_in, height_in, dpi, fmt, transparent, svg_text_as_path=False,
251 full_resolution=False):
252 """'png' か 'svg' で書いたバイト列(保存とコピーで共有)。"""
253 try:
254 temp_canvas = self._make_temp_canvas_for_full_figure(width_in, height_in, dpi, full_resolution=full_resolution)
255 if temp_canvas is None:
256 return None
257 try:
258 buf = io.BytesIO()
259 save_kwargs = {'format': fmt, 'bbox_inches': 'tight', 'transparent': transparent}
260 if fmt != 'svg':
261 save_kwargs['dpi'] = dpi
262 with mpl.rc_context(export_rc_params(fmt, svg_text_as_path)):
263 temp_canvas.fig.savefig(buf, **save_kwargs)
264 buf.seek(0)
265 data = buf.read()
266 buf.close()
267 return data
268 finally:
269 temp_canvas.deleteLater()
270 except Exception:
271 logger.exception("エクスポートプレビューのレンダリングに失敗しました(形式: %s)。", fmt)
272 return None
274 def _on_copy_clicked(self):
275 options = self.get_options()
276 width_in, height_in = self.main_window._calculate_size_in_inches(options)
277 if width_in <= 0 or height_in <= 0:
278 notify.warning(self, "コピーエラー", "コピーする画像がありません。")
279 return
280 if not confirm_output_size(self, width_in, height_in, options["dpi"]):
281 return
283 if self.copy_format_combo.currentText() == "SVG":
284 svg_bytes = self._render_full_figure_bytes(
285 width_in, height_in, options["dpi"], fmt='svg', transparent=options["transparent"],
286 svg_text_as_path=options["svg_text_as_path"], full_resolution=options["full_resolution"]
287 )
288 if svg_bytes is None:
289 notify.warning(self, "コピーエラー", "コピーする画像がありません。")
290 return
291 mime_data = QMimeData()
292 mime_data.setData("image/svg+xml", QByteArray(svg_bytes))
293 # SVG だけでは Word や PowerPoint が貼り付けられないので、PNG も同じ QMimeData に載せる。
294 # その PNG は透過にしない(macOS のクリップボードはアルファを引き継がず、貼ると真っ黒になる)
295 png_fallback_bytes = self._render_full_figure_bytes(
296 width_in, height_in, options["dpi"], fmt='png', transparent=False,
297 full_resolution=options["full_resolution"]
298 )
299 if png_fallback_bytes is not None: 299 ↛ 304line 299 didn't jump to line 304 because the condition on line 299 was always true
300 fallback_pixmap = QPixmap()
301 fallback_pixmap.loadFromData(png_fallback_bytes)
302 if not fallback_pixmap.isNull(): 302 ↛ 304line 302 didn't jump to line 304 because the condition on line 302 was always true
303 mime_data.setImageData(fallback_pixmap.toImage())
304 QApplication.clipboard().setMimeData(mime_data)
305 self.main_window.statusBar().showMessage("プレビュー画像をSVG形式でクリップボードにコピーしました", 3000)
306 else:
307 # 上と同じ理由で透過にしない
308 png_bytes = self._render_full_figure_bytes(
309 width_in, height_in, options["dpi"], fmt='png', transparent=False,
310 full_resolution=options["full_resolution"]
311 )
312 if png_bytes is None:
313 notify.warning(self, "コピーエラー", "コピーする画像がありません。")
314 return
315 pixmap = QPixmap()
316 pixmap.loadFromData(png_bytes)
317 QApplication.clipboard().setPixmap(pixmap)
318 self.main_window.statusBar().showMessage("プレビュー画像をクリップボードにコピーしました", 3000)
320 def _on_save_clicked(self):
321 options = self.get_options()
322 width_in, height_in = self.main_window._calculate_size_in_inches(options)
324 layout_mode = getattr(self.main_window.project, 'layout_mode', 'grid')
325 if layout_mode == 'free':
326 if not self.main_window.project.all_plot_settings: 326 ↛ 336line 326 didn't jump to line 336 because the condition on line 326 was always true
327 notify.warning(self, "保存エラー", "有効なプロットがありません。")
328 return
329 else:
330 rows = self.main_window.subplot_rows_spinbox.value()
331 cols = self.main_window.subplot_cols_spinbox.value()
332 if rows * cols == 0:
333 notify.warning(self, "保存エラー", "有効なプロットがありません。")
334 return
335 # PDF・SVG でも、一時的な図をこの解像度で描いてから保存する
336 if not confirm_output_size(self, width_in, height_in, options["dpi"]):
337 return
339 mw = self.main_window
340 file_path, _ = dialog_dirs.get_save_file_name(
341 self, dialog_dirs.EXPORT, "プロットを保存", dialog_dirs.project_stem(mw),
342 "PNG (*.png);;PDF (*.pdf);;SVG (*.svg)", preferred_dir=dialog_dirs.output_dir(mw)
343 )
344 if not file_path:
345 return
347 file_ext = os.path.splitext(file_path)[1].lower().lstrip('.')
348 temp_canvas = self._make_temp_canvas_for_full_figure(
349 width_in, height_in, options["dpi"], full_resolution=options["full_resolution"]
350 )
351 if temp_canvas is None:
352 notify.warning(self, "保存エラー", "有効なプロットがありません。")
353 return
354 try:
355 save_kwargs = {'transparent': options["transparent"], 'bbox_inches': 'tight'}
356 if file_ext not in ('pdf', 'svg'):
357 save_kwargs['dpi'] = options["dpi"]
358 with mpl.rc_context(export_rc_params(file_ext, options.get("svg_text_as_path", False))):
359 temp_canvas.fig.savefig(file_path, **save_kwargs)
360 self.main_window.statusBar().showMessage(f"保存しました: {file_path}", 3000)
361 except Exception as e:
362 logger.exception("エクスポートプレビューパネルからの保存に失敗しました。")
363 notify.warning(self, "保存エラー", f"エクスポート中にエラーが発生しました:\n{e}")
364 finally:
365 temp_canvas.deleteLater()