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

1"""書き出しのプレビューのドック。設定を変えるたびに全サブプロットの完成形を描き直し、ここから保存とコピーもできる。 

2 

3一時的な MplCanvas に描くので、画面のキャンバスには触れない。 

4""" 

5import copy 

6import io 

7import os 

8import logging 

9import matplotlib as mpl 

10 

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) 

16 

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 

24 

25logger = logging.getLogger(__name__) 

26 

27# 矢印の連打などで毎回重い描き直しが走らないよう、まとめるまでの待ち(ms) 

28PREVIEW_DEBOUNCE_MS = 150 

29 

30 

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 

36 

37 layout = QVBoxLayout(self) 

38 

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) 

45 

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) 

51 

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) 

57 

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) 

63 

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) 

69 

70 self.transparent_checkbox = QCheckBox("背景を透過") 

71 self.transparent_checkbox.setChecked(True) 

72 form.addRow(self.transparent_checkbox) 

73 

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) 

79 

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) 

85 

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) 

94 

95 layout.addLayout(form) 

96 apply_form_spacing(self) 

97 

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) 

104 

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) 

111 

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) 

116 

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) 

121 

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) 

127 

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) 

132 

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 } 

145 

146 def refresh_preview(self): 

147 """少し待ってまとめて描く。隠れている間は描かない(表示したときに refresh_preview が呼ばれる)。""" 

148 if not self.isVisible(): 

149 return 

150 self._refresh_timer.start() 

151 

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() 

157 

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 

163 

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 ) 

184 

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() 

197 

198 def _make_temp_canvas_for_full_figure(self, width_in, height_in, dpi, full_resolution=False): 

199 """全サブプロットを描いた一時的な MplCanvas。描けるものが無ければ None。 

200 

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 

214 

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 

226 

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 

249 

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 

273 

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 

282 

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) 

319 

320 def _on_save_clicked(self): 

321 options = self.get_options() 

322 width_in, height_in = self.main_window._calculate_size_in_inches(options) 

323 

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 

338 

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 

346 

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()