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

1"""書き出しと出力のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。""" 

2 

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 

32 

33 

34class ExportDialog(QDialog): 

35 """書き出しの大きさ・単位・解像度。プレビューを作るのは呼び出し側(PlotterApp._generate_preview)。""" 

36 def __init__(self, parent=None): 

37 super().__init__(parent) 

38 self.setWindowTitle("プロットのエクスポート") 

39 

40 

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) 

52 

53 self.width_spinbox = QDoubleSpinBox() 

54 self.width_spinbox.setRange(1, 10000) 

55 self.width_spinbox.setValue(800) 

56 self.width_spinbox.setDecimals(1) # インチ指定のため小数を許す 

57 

58 self.height_spinbox = QDoubleSpinBox() 

59 self.height_spinbox.setRange(1, 10000) 

60 self.height_spinbox.setValue(600) 

61 self.height_spinbox.setDecimals(1) 

62 

63 # 学術誌のプリセットを選ぶとミリメートルに切り替わる 

64 self.unit_combo = QComboBox() 

65 # 値は日本語の元の文字(書き出しの処理がそれで単位を見分ける)。表示だけを訳す 

66 for unit in ("ピクセル (px)", "インチ (in)", "センチメートル (cm)", "ミリメートル (mm)"): 

67 self.unit_combo.addItem(tr(unit), unit) 

68 

69 self.dpi_spinbox = QSpinBox() 

70 self.dpi_spinbox.setRange(50, 1200) 

71 self.dpi_spinbox.setValue(300) 

72 self.dpi_spinbox.setSuffix(" dpi") 

73 

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) 

78 

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

80 self.transparent_checkbox.setChecked(True) 

81 

82 # 既定は文字のまま(検索や編集ができる)。フォントの無い環境で化けるのを避けたいときだけパスにする。SVG だけに効く 

83 self.svg_text_as_path_checkbox = QCheckBox("文字をアウトライン化する(SVG)") 

84 self.svg_text_as_path_checkbox.setToolTip( 

85 "SVG出力時、目盛りの数字やラベルの文字をテキスト要素ではなく" 

86 "パス(輪郭線)として出力します。フォントが無い環境でも見た目が" 

87 "崩れませんが、テキストとしての検索・編集はできなくなります。" 

88 ) 

89 

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

96 

97 self.preview_button = QPushButton("プレビュー更新") 

98 self.preview_button.setToolTip("現在の設定でプレビュー画像を生成します。") 

99 

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) 

104 

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) 

109 

110 

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) 

121 

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) 

127 

128 self.setLayout(main_layout) 

129 

130 apply_form_spacing(self) 

131 

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

138 

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 } 

149 

150 

151class BatchExportDialog(QDialog): 

152 """サブプロットごと、または複数のプロジェクトファイルをまとめて書き出す設定。書き出しは呼び出し側。""" 

153 

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) 

159 

160 layout = QVBoxLayout(self) 

161 

162 self.mode_combo = QComboBox() 

163 self.mode_combo.addItems([ 

164 "現在のプロジェクトの各サブプロットを個別に書き出す", 

165 "複数のプロジェクトファイルをまとめて書き出す", 

166 ]) 

167 layout.addWidget(self.mode_combo) 

168 

169 self.stack = QStackedWidget() 

170 layout.addWidget(self.stack, 1) 

171 

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) 

188 

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) 

204 

205 self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) 

206 

207 form = QFormLayout() 

208 

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) 

217 

218 self.prefix_edit = QLineEdit("export") 

219 form.addRow("ファイル名の接頭辞", self.prefix_edit) 

220 

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) 

226 

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) 

232 

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

234 self.transparent_checkbox.setChecked(True) 

235 form.addRow(self.transparent_checkbox) 

236 

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) 

243 

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) 

250 

251 layout.addLayout(form) 

252 

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) 

259 

260 apply_form_spacing(self) 

261 

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) 

268 

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

272 

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) 

278 

279 def get_mode(self): 

280 """'subplots' か 'project_files'。""" 

281 return "subplots" if self.mode_combo.currentIndex() == 0 else "project_files" 

282 

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 ] 

288 

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

291 

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 } 

302 

303 

304class CaptionGeneratorDialog(QDialog): 

305 """論文用の LaTeX の \\includegraphics 一式と Word 用のキャプションを作ってコピーする。画像ファイル名は自由入力。""" 

306 

307 WIDTH_OPTIONS = [r'\linewidth', r'0.8\linewidth', r'0.5\linewidth', r'\textwidth'] 

308 

309 def __init__(self, default_caption="", default_label="", parent=None): 

310 super().__init__(parent) 

311 self.setWindowTitle("LaTeX/Word用キャプションを生成") 

312 self.resize(480, 420) 

313 

314 layout = QVBoxLayout(self) 

315 

316 form = QFormLayout() 

317 self.caption_edit = QLineEdit(default_caption) 

318 form.addRow("キャプション", self.caption_edit) 

319 

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) 

328 

329 self.label_edit = QLineEdit(default_label) 

330 form.addRow("LaTeXラベル", self.label_edit) 

331 

332 self.width_combo = QComboBox() 

333 self.width_combo.addItems(self.WIDTH_OPTIONS) 

334 form.addRow("幅(\\includegraphics)", self.width_combo) 

335 layout.addLayout(form) 

336 

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) 

342 

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) 

352 

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) 

357 

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

362 

363 apply_form_spacing(self) 

364 

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

371 

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) 

379 

380 def _on_copy_latex(self): 

381 QApplication.clipboard().setText(self.latex_preview.toPlainText()) 

382 

383 def _on_copy_caption(self): 

384 QApplication.clipboard().setText(self.caption_edit.text()) 

385 

386 

387class CVDSimulationDialog(QDialog): 

388 """色覚シミュレーションのプレビュー。呼び出し側が1回撮った画像を、モードごとに変換して見せる(グラフは描き直さない)。""" 

389 

390 MODE_NORMAL = "通常(変換なし)" 

391 

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 

397 

398 layout = QVBoxLayout(self) 

399 

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) 

408 

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) 

414 

415 info_label = QLabel( 

416 "sRGB値への直接変換による簡易的な近似シミュレーションです。" 

417 "実際の見え方を完全に再現するものではありません。" 

418 ) 

419 info_label.setWordWrap(True) 

420 layout.addWidget(info_label) 

421 

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) 

426 

427 self._update_preview() 

428 

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)