Coverage for graphica/gui/dialogs/export.py: 98.3%
271 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"""書き出しと出力のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。"""
3import os
4from PySide6.QtWidgets import (
5 QApplication,
6 QCheckBox,
7 QComboBox,
8 QDialog,
9 QDialogButtonBox,
10 QDoubleSpinBox,
11 QFormLayout,
12 QHBoxLayout,
13 QLabel,
14 QLineEdit,
15 QListWidget,
16 QListWidgetItem,
17 QPlainTextEdit,
18 QPushButton,
19 QSpinBox,
20 QStackedWidget,
21 QVBoxLayout,
22 QWidget,
23)
24from PySide6.QtCore import Qt
25from PySide6.QtGui import QFont, QPixmap
26from graphica.core.i18n import tr
27from graphica.gui import dialog_dirs
28from graphica.gui.export_size import ExportSizeFields
29from graphica.gui.theme import apply_form_spacing
30from graphica.core.cvd_simulation import CVD_TYPE_LABELS
31from graphica.gui.cvd_preview import simulate_qimage
34class ExportDialog(QDialog):
35 """書き出しの大きさ・単位・解像度。プレビューを作るのは呼び出し側(PlotterApp._generate_preview)。"""
36 def __init__(self, parent=None):
37 super().__init__(parent)
38 self.setWindowTitle("プロットのエクスポート")
41 # 学術誌の段組の幅(単段 85mm / 1.5段 114mm / 2段 170mm)。高さは図ごとに違うので決めない
42 self.JOURNAL_PRESET_CUSTOM = "カスタム"
43 self.JOURNAL_PRESETS = {
44 "学術誌 単段 (85mm)": 85.0,
45 "学術誌 1.5段 (114mm)": 114.0,
46 "学術誌 2段 (170mm)": 170.0,
47 }
48 self.journal_preset_combo = QComboBox()
49 self.journal_preset_combo.addItem(self.JOURNAL_PRESET_CUSTOM)
50 self.journal_preset_combo.addItems(self.JOURNAL_PRESETS.keys())
51 self.journal_preset_combo.currentTextChanged.connect(self._on_journal_preset_changed)
53 self.width_spinbox = QDoubleSpinBox()
54 self.width_spinbox.setRange(1, 10000)
55 self.width_spinbox.setValue(800)
56 self.width_spinbox.setDecimals(1) # インチ指定のため小数を許す
58 self.height_spinbox = QDoubleSpinBox()
59 self.height_spinbox.setRange(1, 10000)
60 self.height_spinbox.setValue(600)
61 self.height_spinbox.setDecimals(1)
63 # 学術誌のプリセットを選ぶとミリメートルに切り替わる
64 self.unit_combo = QComboBox()
65 # 値は日本語の元の文字(書き出しの処理がそれで単位を見分ける)。表示だけを訳す
66 for unit in ("ピクセル (px)", "インチ (in)", "センチメートル (cm)", "ミリメートル (mm)"):
67 self.unit_combo.addItem(tr(unit), unit)
69 self.dpi_spinbox = QSpinBox()
70 self.dpi_spinbox.setRange(50, 1200)
71 self.dpi_spinbox.setValue(300)
72 self.dpi_spinbox.setSuffix(" dpi")
74 self.output_size_label = QLabel()
75 # 学術誌のプリセットの単位の切り替えも、同じ大きさのまま換算してから幅を入れる
76 self._size_fields = ExportSizeFields(
77 self.width_spinbox, self.height_spinbox, self.unit_combo, self.dpi_spinbox, self.output_size_label)
79 self.transparent_checkbox = QCheckBox("背景を透過")
80 self.transparent_checkbox.setChecked(True)
82 # 既定は文字のまま(検索や編集ができる)。フォントの無い環境で化けるのを避けたいときだけパスにする。SVG だけに効く
83 self.svg_text_as_path_checkbox = QCheckBox("文字をアウトライン化する(SVG)")
84 self.svg_text_as_path_checkbox.setToolTip(
85 "SVG出力時、目盛りの数字やラベルの文字をテキスト要素ではなく"
86 "パス(輪郭線)として出力します。フォントが無い環境でも見た目が"
87 "崩れませんが、テキストとしての検索・編集はできなくなります。"
88 )
90 # 表示用の間引き(線は 20,000 点超、2D マップは1軸 500 点超)をしない
91 self.full_resolution_checkbox = QCheckBox(tr("データを間引かずにエクスポート"))
92 self.full_resolution_checkbox.setToolTip(tr(
93 "点数の多い Line(折れ線)データセットや 2D マップ(ヒートマップ/等高線)は、画面表示と同じく間引いて描かれます"
94 "。チェックを入れると間引かず、全データ点でエクスポートします(処理が遅くなる場合があります)。画像の解像度(dpi)とは別の設定です。Scatter/Line+Scatter のマーカーは、この設定に関わらず常に全点描きます。"
95 ))
97 self.preview_button = QPushButton("プレビュー更新")
98 self.preview_button.setToolTip("現在の設定でプレビュー画像を生成します。")
100 self.preview_label = QLabel("プレビューがここに表示されます")
101 self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
102 self.preview_label.setFixedSize(400, 300)
103 self.preview_label.setFrameShape(QLabel.Shape.StyledPanel)
105 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save |
106 QDialogButtonBox.StandardButton.Cancel)
107 button_box.accepted.connect(self.accept)
108 button_box.rejected.connect(self.reject)
111 form_layout = QFormLayout()
112 form_layout.addRow("サイズプリセット", self.journal_preset_combo)
113 form_layout.addRow("幅", self.width_spinbox)
114 form_layout.addRow("高さ", self.height_spinbox)
115 form_layout.addRow("単位", self.unit_combo)
116 form_layout.addRow("解像度", self.dpi_spinbox)
117 form_layout.addRow(self.output_size_label)
118 form_layout.addRow(self.transparent_checkbox)
119 form_layout.addRow(self.svg_text_as_path_checkbox)
120 form_layout.addRow(self.full_resolution_checkbox)
122 main_layout = QVBoxLayout()
123 main_layout.addLayout(form_layout)
124 main_layout.addWidget(self.preview_button)
125 main_layout.addWidget(self.preview_label)
126 main_layout.addWidget(button_box)
128 self.setLayout(main_layout)
130 apply_form_spacing(self)
132 def _on_journal_preset_changed(self, preset_name):
133 """「カスタム」なら入力した値をそのまま残す。"""
134 if preset_name == self.JOURNAL_PRESET_CUSTOM:
135 return
136 self.unit_combo.setCurrentIndex(self.unit_combo.findData("ミリメートル (mm)"))
137 self.width_spinbox.setValue(self.JOURNAL_PRESETS[preset_name])
139 def get_options(self):
140 return {
141 "width": self.width_spinbox.value(),
142 "height": self.height_spinbox.value(),
143 "unit": self.unit_combo.currentData(),
144 "dpi": self.dpi_spinbox.value(),
145 "transparent": self.transparent_checkbox.isChecked(),
146 "svg_text_as_path": self.svg_text_as_path_checkbox.isChecked(),
147 "full_resolution": self.full_resolution_checkbox.isChecked(),
148 }
151class BatchExportDialog(QDialog):
152 """サブプロットごと、または複数のプロジェクトファイルをまとめて書き出す設定。書き出しは呼び出し側。"""
154 def __init__(self, subplot_count, parent=None, extra_formats=None):
155 """extra_formats はプラグインの書き出し形式の名前(PNG / PDF / SVG に足す)。"""
156 super().__init__(parent)
157 self.setWindowTitle("バッチエクスポート")
158 self.resize(480, 480)
160 layout = QVBoxLayout(self)
162 self.mode_combo = QComboBox()
163 self.mode_combo.addItems([
164 "現在のプロジェクトの各サブプロットを個別に書き出す",
165 "複数のプロジェクトファイルをまとめて書き出す",
166 ])
167 layout.addWidget(self.mode_combo)
169 self.stack = QStackedWidget()
170 layout.addWidget(self.stack, 1)
172 subplot_page = QWidget()
173 subplot_page_layout = QVBoxLayout(subplot_page)
174 subplot_page_layout.addWidget(QLabel("書き出すサブプロットを選択してください:"))
175 self.subplot_list = QListWidget()
176 self.subplot_list.setSelectionMode(QListWidget.SelectionMode.NoSelection)
177 for i in range(max(subplot_count, 1)):
178 item = QListWidgetItem(f"プロット {i + 1}")
179 item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
180 item.setCheckState(Qt.CheckState.Checked)
181 self.subplot_list.addItem(item)
182 subplot_page_layout.addWidget(self.subplot_list)
183 self.visible_range_checkbox = QCheckBox(tr("表示中の範囲で書き出す"))
184 self.visible_range_checkbox.setChecked(True)
185 self.visible_range_checkbox.setToolTip(tr("グラフの上でマウスで拡大・移動した範囲で書き出します。外すと、プロットのプロパティで決めた範囲(自動ならデータ全体)で書き出します。"))
186 subplot_page_layout.addWidget(self.visible_range_checkbox)
187 self.stack.addWidget(subplot_page)
189 files_page = QWidget()
190 files_page_layout = QVBoxLayout(files_page)
191 files_page_layout.addWidget(QLabel("書き出すプロジェクトファイル(.gra/.graphica)を追加してください:"))
192 self.project_files_list = QListWidget()
193 files_page_layout.addWidget(self.project_files_list)
194 files_button_row = QHBoxLayout()
195 add_files_button = QPushButton("追加...")
196 add_files_button.clicked.connect(self._on_add_project_files)
197 remove_files_button = QPushButton("削除")
198 remove_files_button.clicked.connect(self._on_remove_selected_project_files)
199 files_button_row.addWidget(add_files_button)
200 files_button_row.addWidget(remove_files_button)
201 files_button_row.addStretch()
202 files_page_layout.addLayout(files_button_row)
203 self.stack.addWidget(files_page)
205 self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
207 form = QFormLayout()
209 output_dir_row = QHBoxLayout()
210 self.output_dir_edit = QLineEdit()
211 self.output_dir_edit.setReadOnly(True)
212 browse_button = QPushButton("参照...")
213 browse_button.clicked.connect(self._on_browse_output_dir)
214 output_dir_row.addWidget(self.output_dir_edit)
215 output_dir_row.addWidget(browse_button)
216 form.addRow("出力先フォルダ", output_dir_row)
218 self.prefix_edit = QLineEdit("export")
219 form.addRow("ファイル名の接頭辞", self.prefix_edit)
221 self.format_combo = QComboBox()
222 self.format_combo.addItems(["PNG", "PDF", "SVG"])
223 if extra_formats:
224 self.format_combo.addItems(extra_formats)
225 form.addRow("形式", self.format_combo)
227 self.dpi_spinbox = QSpinBox()
228 self.dpi_spinbox.setRange(50, 1200)
229 self.dpi_spinbox.setValue(150)
230 self.dpi_spinbox.setSuffix(" dpi")
231 form.addRow("解像度(ラスター形式時)", self.dpi_spinbox)
233 self.transparent_checkbox = QCheckBox("背景を透過")
234 self.transparent_checkbox.setChecked(True)
235 form.addRow(self.transparent_checkbox)
237 self.svg_text_as_path_checkbox = QCheckBox("文字をアウトライン化する(SVG)")
238 self.svg_text_as_path_checkbox.setToolTip(
239 "SVG出力時、目盛りの数字やラベルの文字をパス(輪郭線)として出力します。"
240 "PNG/PDF形式には影響しません。"
241 )
242 form.addRow(self.svg_text_as_path_checkbox)
244 self.full_resolution_checkbox = QCheckBox(tr("データを間引かずにエクスポート"))
245 self.full_resolution_checkbox.setToolTip(tr(
246 "点数の多い Line(折れ線)データセットや 2D マップ(ヒートマップ/等高線)は、画面表示と同じく間引いて描かれます"
247 "。チェックを入れると間引かず、全データ点でエクスポートします(処理が遅くなる場合があります)。画像の解像度(dpi)とは別の設定です。Scatter/Line+Scatter のマーカーは、この設定に関わらず常に全点描きます。"
248 ))
249 form.addRow(self.full_resolution_checkbox)
251 layout.addLayout(form)
253 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
254 QDialogButtonBox.StandardButton.Cancel)
255 button_box.button(QDialogButtonBox.StandardButton.Ok).setText("実行")
256 button_box.accepted.connect(self.accept)
257 button_box.rejected.connect(self.reject)
258 layout.addWidget(button_box)
260 apply_form_spacing(self)
262 def _on_add_project_files(self):
263 paths, _ = dialog_dirs.get_open_file_names(
264 self, dialog_dirs.PROJECT, "プロジェクトファイルを選択", "Graphica Project (*.gra *.graphica)"
265 )
266 for path in paths:
267 self.project_files_list.addItem(path)
269 def _on_remove_selected_project_files(self):
270 for item in self.project_files_list.selectedItems():
271 self.project_files_list.takeItem(self.project_files_list.row(item))
273 def _on_browse_output_dir(self):
274 directory = dialog_dirs.get_existing_directory(
275 self, dialog_dirs.EXPORT, "出力先フォルダを選択", preferred_dir=self.output_dir_edit.text())
276 if directory:
277 self.output_dir_edit.setText(directory)
279 def get_mode(self):
280 """'subplots' か 'project_files'。"""
281 return "subplots" if self.mode_combo.currentIndex() == 0 else "project_files"
283 def get_selected_subplot_indices(self):
284 return [
285 i for i in range(self.subplot_list.count())
286 if self.subplot_list.item(i).checkState() == Qt.CheckState.Checked
287 ]
289 def get_project_file_paths(self):
290 return [self.project_files_list.item(i).text() for i in range(self.project_files_list.count())]
292 def get_common_options(self):
293 return {
294 'output_dir': self.output_dir_edit.text(),
295 'prefix': self.prefix_edit.text().strip() or "export",
296 'format': self.format_combo.currentText().lower(),
297 'dpi': self.dpi_spinbox.value(),
298 'transparent': self.transparent_checkbox.isChecked(),
299 'svg_text_as_path': self.svg_text_as_path_checkbox.isChecked(),
300 'full_resolution': self.full_resolution_checkbox.isChecked(),
301 }
304class CaptionGeneratorDialog(QDialog):
305 """論文用の LaTeX の \\includegraphics 一式と Word 用のキャプションを作ってコピーする。画像ファイル名は自由入力。"""
307 WIDTH_OPTIONS = [r'\linewidth', r'0.8\linewidth', r'0.5\linewidth', r'\textwidth']
309 def __init__(self, default_caption="", default_label="", parent=None):
310 super().__init__(parent)
311 self.setWindowTitle("LaTeX/Word用キャプションを生成")
312 self.resize(480, 420)
314 layout = QVBoxLayout(self)
316 form = QFormLayout()
317 self.caption_edit = QLineEdit(default_caption)
318 form.addRow("キャプション", self.caption_edit)
320 image_layout = QHBoxLayout()
321 self.image_name_edit = QLineEdit()
322 self.image_name_edit.setPlaceholderText("例: figure1.pdf(空欄可、後から書き換えてください)")
323 browse_button = QPushButton("参照...")
324 browse_button.clicked.connect(self._on_browse_image)
325 image_layout.addWidget(self.image_name_edit, 1)
326 image_layout.addWidget(browse_button)
327 form.addRow("画像ファイル名", image_layout)
329 self.label_edit = QLineEdit(default_label)
330 form.addRow("LaTeXラベル", self.label_edit)
332 self.width_combo = QComboBox()
333 self.width_combo.addItems(self.WIDTH_OPTIONS)
334 form.addRow("幅(\\includegraphics)", self.width_combo)
335 layout.addLayout(form)
337 layout.addWidget(QLabel("LaTeXプレビュー"))
338 self.latex_preview = QPlainTextEdit()
339 self.latex_preview.setReadOnly(True)
340 self.latex_preview.setFont(QFont("Consolas", 9))
341 layout.addWidget(self.latex_preview, 1)
343 button_layout = QHBoxLayout()
344 copy_latex_button = QPushButton("LaTeXをコピー")
345 copy_latex_button.clicked.connect(self._on_copy_latex)
346 copy_caption_button = QPushButton("キャプション文をコピー(Word用)")
347 copy_caption_button.clicked.connect(self._on_copy_caption)
348 button_layout.addWidget(copy_latex_button)
349 button_layout.addWidget(copy_caption_button)
350 button_layout.addStretch()
351 layout.addLayout(button_layout)
353 close_button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
354 close_button_box.rejected.connect(self.reject)
355 close_button_box.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.reject)
356 layout.addWidget(close_button_box)
358 for widget in (self.caption_edit, self.image_name_edit, self.label_edit):
359 widget.textChanged.connect(self._update_preview)
360 self.width_combo.currentTextChanged.connect(self._update_preview)
361 self._update_preview()
363 apply_form_spacing(self)
365 def _on_browse_image(self):
366 file_path, _ = dialog_dirs.get_open_file_name(
367 self, dialog_dirs.EXPORT, "画像ファイルを選択", "画像ファイル (*.pdf *.svg *.png *.eps)"
368 )
369 if file_path:
370 self.image_name_edit.setText(os.path.basename(file_path))
372 def _update_preview(self):
373 from graphica.core.caption_export import generate_latex_figure
374 latex_code = generate_latex_figure(
375 self.image_name_edit.text().strip(), self.caption_edit.text(),
376 self.label_edit.text().strip(), self.width_combo.currentText(),
377 )
378 self.latex_preview.setPlainText(latex_code)
380 def _on_copy_latex(self):
381 QApplication.clipboard().setText(self.latex_preview.toPlainText())
383 def _on_copy_caption(self):
384 QApplication.clipboard().setText(self.caption_edit.text())
387class CVDSimulationDialog(QDialog):
388 """色覚シミュレーションのプレビュー。呼び出し側が1回撮った画像を、モードごとに変換して見せる(グラフは描き直さない)。"""
390 MODE_NORMAL = "通常(変換なし)"
392 def __init__(self, source_image, parent=None):
393 super().__init__(parent)
394 self.setWindowTitle("色覚シミュレーションプレビュー")
395 self.resize(700, 560)
396 self._source_image = source_image
398 layout = QVBoxLayout(self)
400 form = QFormLayout()
401 self.mode_combo = QComboBox()
402 self.mode_combo.addItem(self.MODE_NORMAL, None)
403 for key, label in CVD_TYPE_LABELS.items():
404 self.mode_combo.addItem(label, key)
405 self.mode_combo.currentIndexChanged.connect(self._update_preview)
406 form.addRow("表示モード", self.mode_combo)
407 layout.addLayout(form)
409 self.preview_label = QLabel()
410 self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
411 self.preview_label.setMinimumSize(400, 300)
412 self.preview_label.setFrameShape(QLabel.Shape.StyledPanel)
413 layout.addWidget(self.preview_label, 1)
415 info_label = QLabel(
416 "sRGB値への直接変換による簡易的な近似シミュレーションです。"
417 "実際の見え方を完全に再現するものではありません。"
418 )
419 info_label.setWordWrap(True)
420 layout.addWidget(info_label)
422 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
423 button_box.rejected.connect(self.reject)
424 button_box.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.reject)
425 layout.addWidget(button_box)
427 self._update_preview()
429 def _update_preview(self):
430 cvd_type = self.mode_combo.currentData()
431 image = self._source_image if cvd_type is None else simulate_qimage(self._source_image, cvd_type)
432 pixmap = QPixmap.fromImage(image)
433 target_width = max(self.preview_label.width(), 400)
434 target_height = max(self.preview_label.height(), 300)
435 scaled = pixmap.scaled(
436 target_width, target_height,
437 Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation,
438 )
439 self.preview_label.setPixmap(scaled)