graphica-plot 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. graphica/Graphica.ico +0 -0
  2. graphica/__init__.py +1 -0
  3. graphica/__main__.py +111 -0
  4. graphica/assets/__init__.py +0 -0
  5. graphica/assets/icons/__init__.py +0 -0
  6. graphica/assets/icons/arrow-right.svg +21 -0
  7. graphica/assets/icons/bold.svg +20 -0
  8. graphica/assets/icons/calculator.svg +26 -0
  9. graphica/assets/icons/chart-histogram.svg +24 -0
  10. graphica/assets/icons/chart-line.svg +20 -0
  11. graphica/assets/icons/chevron-down.svg +19 -0
  12. graphica/assets/icons/chevron-right.svg +19 -0
  13. graphica/assets/icons/color-swatch.svg +22 -0
  14. graphica/assets/icons/column-insert-right.svg +21 -0
  15. graphica/assets/icons/column-remove.svg +21 -0
  16. graphica/assets/icons/copy.svg +20 -0
  17. graphica/assets/icons/download.svg +21 -0
  18. graphica/assets/icons/edit.svg +21 -0
  19. graphica/assets/icons/eye-off.svg +21 -0
  20. graphica/assets/icons/eye.svg +20 -0
  21. graphica/assets/icons/file-plus.svg +22 -0
  22. graphica/assets/icons/folder-plus.svg +21 -0
  23. graphica/assets/icons/folder.svg +19 -0
  24. graphica/assets/icons/highlight.svg +22 -0
  25. graphica/assets/icons/history.svg +20 -0
  26. graphica/assets/icons/italic.svg +21 -0
  27. graphica/assets/icons/layout-grid.svg +22 -0
  28. graphica/assets/icons/math-function.svg +22 -0
  29. graphica/assets/icons/message-2.svg +21 -0
  30. graphica/assets/icons/mountain.svg +20 -0
  31. graphica/assets/icons/palette.svg +22 -0
  32. graphica/assets/icons/pointer.svg +19 -0
  33. graphica/assets/icons/refresh.svg +20 -0
  34. graphica/assets/icons/row-insert-bottom.svg +21 -0
  35. graphica/assets/icons/row-remove.svg +21 -0
  36. graphica/assets/icons/search.svg +20 -0
  37. graphica/assets/icons/select-all.svg +35 -0
  38. graphica/assets/icons/subscript.svg +20 -0
  39. graphica/assets/icons/superscript.svg +20 -0
  40. graphica/assets/icons/table.svg +21 -0
  41. graphica/assets/icons/trash.svg +23 -0
  42. graphica/assets/icons/typography.svg +23 -0
  43. graphica/assets/icons/x.svg +20 -0
  44. graphica/core/__init__.py +0 -0
  45. graphica/core/analysis.py +1278 -0
  46. graphica/core/app_paths.py +35 -0
  47. graphica/core/axis_settings.py +128 -0
  48. graphica/core/caption_export.py +47 -0
  49. graphica/core/color_palettes.py +56 -0
  50. graphica/core/commands.py +226 -0
  51. graphica/core/cvd_simulation.py +49 -0
  52. graphica/core/dataset.py +443 -0
  53. graphica/core/diagnostics.py +105 -0
  54. graphica/core/excel_utils.py +58 -0
  55. graphica/core/fit_models.py +379 -0
  56. graphica/core/grid_data.py +128 -0
  57. graphica/core/i18n.py +44 -0
  58. graphica/core/json_utils.py +27 -0
  59. graphica/core/label_utils.py +21 -0
  60. graphica/core/methods_text.py +115 -0
  61. graphica/core/named_colors.py +126 -0
  62. graphica/core/plugin_api.py +515 -0
  63. graphica/core/plugin_context.py +167 -0
  64. graphica/core/plugin_install.py +82 -0
  65. graphica/core/plugin_manifest.py +67 -0
  66. graphica/core/plugin_testing.py +213 -0
  67. graphica/core/plugin_types.py +118 -0
  68. graphica/core/provenance.py +19 -0
  69. graphica/core/report_export.py +51 -0
  70. graphica/core/safe_eval.py +194 -0
  71. graphica/core/script_export.py +272 -0
  72. graphica/core/translations_en.py +530 -0
  73. graphica/core/unit_conversion.py +51 -0
  74. graphica/core/update_check.py +43 -0
  75. graphica/core/version.py +14 -0
  76. graphica/gui/__init__.py +0 -0
  77. graphica/gui/app_settings.py +119 -0
  78. graphica/gui/axis_bindings.py +173 -0
  79. graphica/gui/binding.py +135 -0
  80. graphica/gui/builders/__init__.py +1 -0
  81. graphica/gui/builders/axis_panel.py +431 -0
  82. graphica/gui/builders/canvas_area.py +176 -0
  83. graphica/gui/builders/common.py +120 -0
  84. graphica/gui/builders/dataset_panel.py +433 -0
  85. graphica/gui/builders/property_sections.py +208 -0
  86. graphica/gui/canvas.py +519 -0
  87. graphica/gui/color_history.py +47 -0
  88. graphica/gui/color_picker_widget.py +198 -0
  89. graphica/gui/crash_handler.py +77 -0
  90. graphica/gui/cvd_preview.py +23 -0
  91. graphica/gui/data_editor.py +761 -0
  92. graphica/gui/data_import_flow.py +355 -0
  93. graphica/gui/dataset_bindings.py +107 -0
  94. graphica/gui/dataset_style_icon.py +80 -0
  95. graphica/gui/datasets/__init__.py +1 -0
  96. graphica/gui/datasets/actions_menu.py +114 -0
  97. graphica/gui/datasets/colors.py +218 -0
  98. graphica/gui/datasets/fitting.py +70 -0
  99. graphica/gui/datasets/host.py +162 -0
  100. graphica/gui/datasets/operations/__init__.py +1 -0
  101. graphica/gui/datasets/operations/fitting.py +468 -0
  102. graphica/gui/datasets/operations/peaks.py +109 -0
  103. graphica/gui/datasets/operations/processing.py +659 -0
  104. graphica/gui/datasets/operations/runner.py +148 -0
  105. graphica/gui/datasets/operations/transfer.py +227 -0
  106. graphica/gui/datasets/order.py +310 -0
  107. graphica/gui/datasets/overlays.py +74 -0
  108. graphica/gui/datasets/peaks.py +21 -0
  109. graphica/gui/datasets/plugin_runs.py +99 -0
  110. graphica/gui/datasets/processing.py +68 -0
  111. graphica/gui/datasets/property_panel.py +538 -0
  112. graphica/gui/datasets/transfer.py +36 -0
  113. graphica/gui/detached_canvas_window.py +22 -0
  114. graphica/gui/dialogs/__init__.py +137 -0
  115. graphica/gui/dialogs/analysis.py +1322 -0
  116. graphica/gui/dialogs/app.py +1015 -0
  117. graphica/gui/dialogs/appearance.py +851 -0
  118. graphica/gui/dialogs/data_edit.py +577 -0
  119. graphica/gui/dialogs/data_import.py +679 -0
  120. graphica/gui/dialogs/export.py +433 -0
  121. graphica/gui/dock_layout.py +176 -0
  122. graphica/gui/export_preview_panel.py +321 -0
  123. graphica/gui/export_settings.py +16 -0
  124. graphica/gui/file_association.py +97 -0
  125. graphica/gui/icon_utils.py +44 -0
  126. graphica/gui/main_app_window.py +210 -0
  127. graphica/gui/main_window.py +1151 -0
  128. graphica/gui/mathtext_preview.py +168 -0
  129. graphica/gui/menu_bar.py +320 -0
  130. graphica/gui/minimap_widget.py +151 -0
  131. graphica/gui/mixins/__init__.py +0 -0
  132. graphica/gui/mixins/export_mixin.py +520 -0
  133. graphica/gui/mixins/help_mixin.py +127 -0
  134. graphica/gui/mixins/project_io_mixin.py +289 -0
  135. graphica/gui/mixins/quick_access_mixin.py +194 -0
  136. graphica/gui/mixins/ui_setup_mixin.py +206 -0
  137. graphica/gui/notify.py +61 -0
  138. graphica/gui/panels/__init__.py +43 -0
  139. graphica/gui/panels/axis_settings.py +605 -0
  140. graphica/gui/panels/dataset_tree.py +424 -0
  141. graphica/gui/plot_type_drawers.py +131 -0
  142. graphica/gui/plugin_context.py +119 -0
  143. graphica/gui/project_files.py +361 -0
  144. graphica/gui/provenance_panel.py +65 -0
  145. graphica/gui/rendering/__init__.py +1 -0
  146. graphica/gui/rendering/annotations.py +177 -0
  147. graphica/gui/rendering/appearance.py +368 -0
  148. graphica/gui/rendering/common.py +316 -0
  149. graphica/gui/rendering/data_1d.py +376 -0
  150. graphica/gui/rendering/data_2d.py +66 -0
  151. graphica/gui/residual_panel.py +62 -0
  152. graphica/gui/resources.py +16 -0
  153. graphica/gui/single_instance.py +141 -0
  154. graphica/gui/splash.py +83 -0
  155. graphica/gui/task_runner.py +41 -0
  156. graphica/gui/theme.py +864 -0
  157. graphica/gui/tools/__init__.py +56 -0
  158. graphica/gui/tools/annotation.py +275 -0
  159. graphica/gui/tools/cursor.py +259 -0
  160. graphica/gui/tools/layout_edit.py +285 -0
  161. graphica/gui/tools/manager.py +78 -0
  162. graphica/gui/tools/peak_placement.py +132 -0
  163. graphica/gui/tools/range_select.py +191 -0
  164. graphica/gui/tools/region_highlight.py +222 -0
  165. graphica/gui/tools/slice_extraction.py +200 -0
  166. graphica/gui/widget_translation.py +46 -0
  167. graphica/gui/workers.py +280 -0
  168. graphica/models/__init__.py +0 -0
  169. graphica/models/project.py +197 -0
  170. graphica/plugin/__init__.py +20 -0
  171. graphica/plugin/testing.py +57 -0
  172. graphica/sample_data/__init__.py +0 -0
  173. graphica/sample_data/cooling_curve_sample.csv +42 -0
  174. graphica/ui_main_window.py +581 -0
  175. graphica_plot-2.0.0.dist-info/METADATA +317 -0
  176. graphica_plot-2.0.0.dist-info/RECORD +181 -0
  177. graphica_plot-2.0.0.dist-info/WHEEL +5 -0
  178. graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
  179. graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
  180. graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
  181. graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,520 @@
1
+ """画像・PDF・SVG への書き出し、印刷、クリップボード、書き出しのプレビュー。"""
2
+ import datetime
3
+ import io
4
+ import os
5
+ import dataclasses
6
+ import logging
7
+ import matplotlib as mpl
8
+ from PySide6.QtCore import Qt
9
+ from PySide6.QtGui import QPixmap, QPainter, QImage
10
+ from PySide6.QtWidgets import QApplication, QDialog, QProgressDialog
11
+ from PySide6.QtPrintSupport import QPrinter, QPrintDialog
12
+ from matplotlib.figure import Figure
13
+ from matplotlib.backends.backend_pdf import PdfPages
14
+
15
+ from graphica.gui import notify
16
+ from graphica.core.axis_settings import axis_setting
17
+ from graphica.gui.dialogs import ExportDialog, BatchExportDialog, CaptionGeneratorDialog, CVDSimulationDialog
18
+ from graphica.gui.canvas import _HeadlessRenderCanvas, fit_tight_layout
19
+ from graphica.gui.export_settings import export_rc_params
20
+ from graphica.gui.task_runner import TaskRunner
21
+ from graphica.models.project import ProjectModel
22
+ from graphica.core.plugin_api import get_plugin_api, get_registered_exporters
23
+ from graphica.core.plugin_types import PluginExecutionError
24
+ from graphica.core.script_export import generate_python_script
25
+ from graphica.core.caption_export import sanitize_label
26
+ from graphica.core.report_export import collect_methods_sections, generate_html_report
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ def _project_has_raster_gradient_fill(project):
31
+ """グラデーションの塗り(imshow で描くのでベクター形式でもラスタになる)を使う系列があるか。"""
32
+ return any(
33
+ ds.plot_type == 'Area' and ds.gradient_enabled and ds.gradient_target in ('fill', 'both')
34
+ for ds in project.datasets
35
+ )
36
+
37
+
38
+ CLIPBOARD_COPY_DPI = 150
39
+
40
+ PRINT_RENDER_DPI = 200
41
+
42
+ BATCH_EXPORT_FIGSIZE = (8, 6)
43
+
44
+
45
+ class ExportMixin:
46
+ def _on_copy_plot_to_clipboard(self):
47
+ buf = io.BytesIO()
48
+ try:
49
+ self.canvas.fig.savefig(buf, format='png', dpi=CLIPBOARD_COPY_DPI, bbox_inches='tight')
50
+ except Exception as e:
51
+ logger.exception("グラフのクリップボードコピーに失敗しました。")
52
+ notify.warning(self, "コピーエラー", f"グラフのコピー中にエラーが発生しました:\n{e}")
53
+ return
54
+ buf.seek(0)
55
+
56
+ pixmap = QPixmap()
57
+ pixmap.loadFromData(buf.read())
58
+ buf.close()
59
+
60
+ QApplication.clipboard().setPixmap(pixmap)
61
+
62
+ def _on_show_cvd_simulation(self):
63
+ """今のグラフを1回だけ PNG にし、ダイアログはその画像を変換して見せる(グラフは描き直さない)。"""
64
+ buf = io.BytesIO()
65
+ try:
66
+ self.canvas.fig.savefig(buf, format='png', dpi=CLIPBOARD_COPY_DPI, bbox_inches='tight')
67
+ except Exception as e:
68
+ logger.exception("色覚シミュレーションプレビュー用の画像生成に失敗しました。")
69
+ notify.warning(self, "プレビューエラー", f"プレビュー画像の生成中にエラーが発生しました:\n{e}")
70
+ return
71
+ buf.seek(0)
72
+ image = QImage()
73
+ image.loadFromData(buf.read())
74
+ buf.close()
75
+
76
+ dialog = CVDSimulationDialog(image, self)
77
+ dialog.exec()
78
+
79
+ def _on_print_plot(self):
80
+ """PNG に描いてから QPrinter に貼る(書き出しと同じ描画結果になる)。"""
81
+ printer = QPrinter(QPrinter.PrinterMode.HighResolution)
82
+ print_dialog = QPrintDialog(printer, self)
83
+ if print_dialog.exec() != QDialog.DialogCode.Accepted:
84
+ return
85
+
86
+ buf = io.BytesIO()
87
+ try:
88
+ self.canvas.fig.savefig(buf, format='png', dpi=PRINT_RENDER_DPI, bbox_inches='tight')
89
+ except Exception as e:
90
+ logger.exception("印刷用画像の生成に失敗しました。")
91
+ notify.warning(self, "印刷エラー", f"印刷用の画像生成中にエラーが発生しました:\n{e}")
92
+ return
93
+ buf.seek(0)
94
+
95
+ pixmap = QPixmap()
96
+ pixmap.loadFromData(buf.read())
97
+ buf.close()
98
+
99
+ if pixmap.isNull():
100
+ notify.warning(self, "印刷エラー", "印刷用の画像生成に失敗しました。")
101
+ return
102
+
103
+ painter = QPainter()
104
+ if not painter.begin(printer):
105
+ notify.warning(self, "印刷エラー", "プリンターへの描画を開始できませんでした。")
106
+ return
107
+ try:
108
+ page_rect = printer.pageRect(QPrinter.Unit.DevicePixel).toRect()
109
+ scaled = pixmap.scaled(
110
+ page_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
111
+ )
112
+ x = page_rect.x() + (page_rect.width() - scaled.width()) // 2
113
+ y = page_rect.y() + (page_rect.height() - scaled.height()) // 2
114
+ painter.drawPixmap(x, y, scaled)
115
+ finally:
116
+ painter.end()
117
+
118
+ self.statusBar().showMessage("印刷を実行しました", 3000)
119
+
120
+ def _on_batch_export(self):
121
+ """サブプロットごと、または複数のプロジェクトファイルをまとめて書き出す。別スレッドで1本の TaskRunner で行う。
122
+
123
+ rc_context はプロセス全体の rcParams を書き換えるので、並列にはしない。
124
+ """
125
+ if self._batch_export_task_runner is not None:
126
+ notify.information(self, "実行中", "別のバッチエクスポート処理が実行中です。完了までお待ちください。")
127
+ return
128
+
129
+ extra_formats = [exp.format_name for exp in get_registered_exporters()]
130
+ dialog = BatchExportDialog(len(self.project.all_plot_settings), self, extra_formats=extra_formats)
131
+ if dialog.exec() != QDialog.DialogCode.Accepted:
132
+ return
133
+
134
+ options = dialog.get_common_options()
135
+ if not options['output_dir']:
136
+ notify.warning(self, "バッチエクスポート", "出力先フォルダを指定してください。")
137
+ return
138
+
139
+ mode = dialog.get_mode()
140
+ if mode == "subplots":
141
+ indices = dialog.get_selected_subplot_indices()
142
+ if not indices:
143
+ notify.warning(self, "バッチエクスポート", "書き出すサブプロットを選択してください。")
144
+ return
145
+ items = indices
146
+ worker_fn = self._batch_export_subplots
147
+ else:
148
+ paths = dialog.get_project_file_paths()
149
+ if not paths:
150
+ notify.warning(self, "バッチエクスポート", "プロジェクトファイルを追加してください。")
151
+ return
152
+ items = paths
153
+ worker_fn = self._batch_export_project_files
154
+
155
+ progress_dialog = QProgressDialog("バッチエクスポートを実行中...", "キャンセル", 0, len(items), self)
156
+ progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
157
+ progress_dialog.setMinimumDuration(0)
158
+ progress_dialog.setValue(0)
159
+
160
+ runner = TaskRunner(worker_fn, items, options)
161
+ runner.progress.connect(lambda done, total, message: progress_dialog.setValue(done))
162
+ progress_dialog.canceled.connect(runner.requestInterruption)
163
+ runner.succeeded.connect(lambda results: self._on_batch_export_succeeded(results, progress_dialog))
164
+ runner.failed.connect(lambda msg: self._on_batch_export_failed(msg, progress_dialog))
165
+ self._batch_export_task_runner = runner
166
+ runner.start()
167
+
168
+ def _cleanup_batch_export_task_runner(self):
169
+ if self._batch_export_task_runner is not None:
170
+ self._batch_export_task_runner.wait()
171
+ self._batch_export_task_runner.deleteLater()
172
+ self._batch_export_task_runner = None
173
+
174
+ def _on_batch_export_failed(self, error_message, progress_dialog):
175
+ self._cleanup_batch_export_task_runner()
176
+ progress_dialog.close()
177
+ notify.warning(self, "バッチエクスポート", f"バッチエクスポート処理に失敗しました:\n{error_message}")
178
+
179
+ def _on_batch_export_succeeded(self, results, progress_dialog):
180
+ """結果は [(出力ファイル名, エラーか None), ...]。キャンセルなら終わった分だけ。"""
181
+ self._cleanup_batch_export_task_runner()
182
+ progress_dialog.close()
183
+
184
+ succeeded = [name for name, error in results if error is None]
185
+ failed = [(name, error) for name, error in results if error is not None]
186
+
187
+ if not succeeded and not failed:
188
+ return # 1件も終わらないうちにキャンセルされた
189
+
190
+ message = f"{len(succeeded)}件を書き出しました。"
191
+ if failed:
192
+ message += "\n\n失敗:\n" + "\n".join(f"{name}: {error}" for name, error in failed)
193
+ notify.information(self, "バッチエクスポート完了", message)
194
+
195
+ def _save_figure_with_options(self, fig, out_path, options):
196
+ """bbox_inches='tight' で保存する。rc の設定は export_rc_params(SVG の文字の扱い、PDF の TrueType 埋め込み)。
197
+
198
+ 形式がプラグインの書き出しの名前なら、プラグインの writer を呼ぶ。
199
+ """
200
+ api = get_plugin_api()
201
+ exporter = api.get_exporter(options['format']) if api is not None else None
202
+ if exporter is not None:
203
+ try:
204
+ exporter.writer(fig, out_path)
205
+ except Exception as e:
206
+ raise PluginExecutionError(exporter.name, f"「{out_path}」への書き出しに失敗しました: {e}") from e
207
+ return
208
+
209
+ save_kwargs = {'transparent': options.get('transparent', True), 'bbox_inches': 'tight'}
210
+ if options['format'] not in ('pdf', 'svg'):
211
+ save_kwargs['dpi'] = options['dpi']
212
+ with mpl.rc_context(export_rc_params(options['format'], options.get('svg_text_as_path', False))):
213
+ fig.savefig(out_path, **save_kwargs)
214
+
215
+ def _batch_export_subplots(self, indices, options, report_progress=None, is_cancelled=None):
216
+ """サブプロットを1枚ずつ書き出す。1x1 の一時キャンバスなので、subplot_target を 0 にした写しを渡す。
217
+
218
+ 別スレッドで呼ばれるので Qt には触れない(_HeadlessRenderCanvas は QWidget ではない)。
219
+ キャンセルは1件ごとにしか見ない(1件の描画と保存は中断できない)。
220
+ """
221
+ results = []
222
+ total = len(indices)
223
+ for i, idx in enumerate(indices):
224
+ if is_cancelled is not None and is_cancelled():
225
+ break
226
+ if report_progress is not None:
227
+ report_progress(i, total, f"P{idx + 1}")
228
+ out_name = f"{options['prefix']}_P{idx + 1}.{options['format']}"
229
+ out_path = os.path.join(options['output_dir'], out_name)
230
+ try:
231
+ settings = self.project.all_plot_settings[idx]
232
+ datasets_for_axis = [
233
+ dataclasses.replace(ds, subplot_target=0)
234
+ for ds in self.project.datasets if ds.subplot_target == idx
235
+ ]
236
+ temp_canvas = _HeadlessRenderCanvas(width=BATCH_EXPORT_FIGSIZE[0], height=BATCH_EXPORT_FIGSIZE[1], dpi=options['dpi'])
237
+ temp_canvas.dark_mode = self.canvas.dark_mode
238
+ temp_canvas.redraw_all(datasets_for_axis, 1, 1, [settings], full_resolution=options.get('full_resolution', False))
239
+ self._save_figure_with_options(temp_canvas.fig, out_path, options)
240
+ results.append((out_name, None))
241
+ except Exception as e:
242
+ logger.exception("バッチエクスポート(サブプロット)に失敗しました: %s", out_name)
243
+ results.append((out_name, str(e)))
244
+ return results
245
+
246
+ def _batch_export_project_files(self, paths, options, report_progress=None, is_cancelled=None):
247
+ """プロジェクトファイルを1つずつ読み、その図を書き出す。開いているプロジェクトには触れない。別スレッドで呼ばれる。"""
248
+ results = []
249
+ total = len(paths)
250
+ for i, path in enumerate(paths):
251
+ if is_cancelled is not None and is_cancelled():
252
+ break
253
+ if report_progress is not None:
254
+ report_progress(i, total, os.path.basename(path))
255
+ base_name = os.path.splitext(os.path.basename(path))[0]
256
+ out_name = f"{options['prefix']}_{base_name}.{options['format']}"
257
+ out_path = os.path.join(options['output_dir'], out_name)
258
+ try:
259
+ temp_project = ProjectModel()
260
+ temp_project.load_project(path)
261
+ layout_mode = getattr(temp_project, 'layout_mode', 'grid')
262
+ if layout_mode == 'free':
263
+ # 自由配置には行×列が無いので、サブプロットの数に応じた大きさにする
264
+ if not temp_project.all_plot_settings:
265
+ raise ValueError("有効なプロット設定が見つかりません")
266
+ rows, cols = 0, 0
267
+ fig_width, fig_height = BATCH_EXPORT_FIGSIZE[0] * 2, BATCH_EXPORT_FIGSIZE[1] * 2
268
+ else:
269
+ rows, cols = temp_project.layout_rows, temp_project.layout_cols
270
+ if rows * cols == 0 or not temp_project.all_plot_settings:
271
+ raise ValueError("有効なプロット設定が見つかりません")
272
+ fig_width, fig_height = BATCH_EXPORT_FIGSIZE[0] * cols, BATCH_EXPORT_FIGSIZE[1] * rows
273
+
274
+ temp_canvas = _HeadlessRenderCanvas(width=fig_width, height=fig_height, dpi=options['dpi'])
275
+ temp_canvas.dark_mode = self.canvas.dark_mode
276
+ temp_canvas.redraw_all(
277
+ temp_project.datasets, rows, cols, temp_project.all_plot_settings, layout_mode=layout_mode,
278
+ panel_labels_enabled=temp_project.panel_labels_enabled,
279
+ full_resolution=options.get('full_resolution', False),
280
+ )
281
+ self._save_figure_with_options(temp_canvas.fig, out_path, options)
282
+ results.append((out_name, None))
283
+ except Exception as e:
284
+ logger.exception("バッチエクスポート(プロジェクトファイル)に失敗しました: %s", path)
285
+ results.append((os.path.basename(path), str(e)))
286
+ return results
287
+
288
+ def _on_export_python_script(self):
289
+ """matplotlib だけで図を再現するスクリプトを書き出す(生成は core/script_export.py)。"""
290
+ file_path, _ = notify.get_save_file_name(
291
+ self, "Pythonスクリプトとしてエクスポート", "", "Python Files (*.py)"
292
+ )
293
+ if not file_path:
294
+ return
295
+ if not file_path.endswith('.py'):
296
+ file_path += '.py'
297
+
298
+ # サブプロットの行数と列数は画面にしか無いことがある(保存の前)。古い値のままだと 2 つ目以降の軸が抜ける
299
+ self._sync_project_from_ui()
300
+ try:
301
+ script_text = generate_python_script(self.project)
302
+ with open(file_path, 'w', encoding='utf-8') as f:
303
+ f.write(script_text)
304
+ except Exception as e:
305
+ notify.warning(self, "保存エラー", f"スクリプトの書き出し中にエラーが発生しました:\n{e}")
306
+ logger.exception("Pythonスクリプトの書き出し中にエラー")
307
+ return
308
+
309
+ self.statusBar().showMessage(f"Pythonスクリプトを書き出しました: {file_path}", 3000)
310
+
311
+ def _on_generate_caption(self):
312
+ """今の軸のタイトルを初期値にキャプションのダイアログを出す(生成は core/caption_export.py)。"""
313
+ settings = {}
314
+ if 0 <= self.project.active_axis_index < len(self.project.all_plot_settings):
315
+ settings = self.project.all_plot_settings[self.project.active_axis_index]
316
+ default_caption = axis_setting(settings, 'title') or ''
317
+ default_label = sanitize_label(default_caption)
318
+
319
+ dialog = CaptionGeneratorDialog(default_caption, default_label, self)
320
+ dialog.exec()
321
+
322
+ def _on_generate_report(self):
323
+ """グラフの画像と、履歴のある全データセットの方法の文を1つのレポートにする。形式は拡張子(.html / .pdf)で決まる。"""
324
+ settings = {}
325
+ if 0 <= self.project.active_axis_index < len(self.project.all_plot_settings):
326
+ settings = self.project.all_plot_settings[self.project.active_axis_index]
327
+ title = axis_setting(settings, 'title') or "実験レポート"
328
+
329
+ file_path, _ = notify.get_save_file_name(
330
+ self, "実験レポートを生成", "", "HTML Files (*.html);;PDF Files (*.pdf)"
331
+ )
332
+ if not file_path:
333
+ return
334
+ file_ext = os.path.splitext(file_path)[1].lower()
335
+ if file_ext not in ('.html', '.pdf'):
336
+ file_ext = '.html'
337
+ file_path += file_ext
338
+
339
+ methods_sections = collect_methods_sections(self.project)
340
+
341
+ try:
342
+ if file_ext == '.pdf':
343
+ self._write_pdf_report(file_path, title, methods_sections)
344
+ else:
345
+ buf = io.BytesIO()
346
+ self.canvas.fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
347
+ html_text = generate_html_report(buf.getvalue(), methods_sections, title=title)
348
+ with open(file_path, 'w', encoding='utf-8') as f:
349
+ f.write(html_text)
350
+ except Exception as e:
351
+ logger.exception("実験レポートの生成中にエラーが発生しました。")
352
+ notify.warning(self, "保存エラー", f"実験レポートの生成中にエラーが発生しました:\n{e}")
353
+ return
354
+
355
+ self.statusBar().showMessage(f"実験レポートを書き出しました: {file_path}", 3000)
356
+
357
+ def _write_pdf_report(self, file_path, title, methods_sections):
358
+ """1ページ目にグラフ、2ページ目にタイトルと方法の文。"""
359
+ from graphica.gui.mathtext_preview import JP_CAPABLE_FONT_FAMILIES
360
+
361
+ with mpl.rc_context(export_rc_params('pdf')), PdfPages(file_path) as pdf:
362
+ pdf.savefig(self.canvas.fig, bbox_inches='tight')
363
+
364
+ text_fig = Figure(figsize=(8.27, 11.69)) # A4 縦
365
+ text_fig.text(0.08, 0.95, title, fontsize=16, fontweight='bold', va='top',
366
+ family=JP_CAPABLE_FONT_FAMILIES)
367
+ body_lines = [f"生成日時: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}", "", "方法:"]
368
+ if methods_sections:
369
+ for name, text in methods_sections:
370
+ body_lines.append(f"・{name}: {text}")
371
+ else:
372
+ body_lines.append("(処理履歴を持つデータセットはありません)")
373
+ text_fig.text(0.08, 0.88, "\n".join(body_lines), fontsize=10, va='top', wrap=True,
374
+ family=JP_CAPABLE_FONT_FAMILIES)
375
+ pdf.savefig(text_fig)
376
+
377
+ def _on_export_plot(self):
378
+ dialog = ExportDialog(self)
379
+
380
+ dialog.preview_button.clicked.connect(
381
+ lambda: self._generate_preview(dialog)
382
+ )
383
+
384
+ if dialog.exec() == QDialog.DialogCode.Accepted:
385
+ options = dialog.get_options()
386
+
387
+ filter_parts = ["PNG (*.png)", "PDF (*.pdf)", "SVG (*.svg)"]
388
+ for exp in get_registered_exporters():
389
+ filter_parts.append(f"{exp.format_name} (*{exp.extension})")
390
+ file_path, _ = notify.get_save_file_name(
391
+ self, "プロットを保存", "", ";;".join(filter_parts)
392
+ )
393
+ if not file_path:
394
+ return
395
+
396
+ # グラデーションの塗りはベクター形式でもラスタで埋め込まれる。書き出しは続け、知らせるだけ
397
+ export_ext = os.path.splitext(file_path)[1].lower()
398
+ if export_ext in ('.svg', '.pdf') and _project_has_raster_gradient_fill(self.project):
399
+ notify.warning(
400
+ self, "ベクター出力時の注意",
401
+ "グラデーション塗りが有効なデータセットが含まれています。\n"
402
+ "この部分は画像(ラスタ)として埋め込まれるため、拡大すると"
403
+ "他の要素のようにはくっきり表示されません。\n\n"
404
+ "エクスポートは続行します。"
405
+ )
406
+
407
+ width_in, height_in = self._calculate_size_in_inches(options)
408
+
409
+ # フル解像度なら間引かずに描き直してから保存する(finally で画面用に戻す)
410
+ full_resolution = options.get('full_resolution', False)
411
+ if full_resolution:
412
+ self._update_plot(full_resolution=True)
413
+
414
+ original_size = self.canvas.fig.get_size_inches()
415
+
416
+ self.canvas.fig.set_size_inches(width_in, height_in)
417
+
418
+ try:
419
+ file_ext = os.path.splitext(file_path)[1].lower()
420
+
421
+ api = get_plugin_api()
422
+ exporter = api.get_exporter_for_extension(file_ext) if api is not None else None
423
+ if exporter is not None:
424
+ try:
425
+ exporter.writer(self.canvas.fig, file_path)
426
+ except Exception as e:
427
+ raise PluginExecutionError(
428
+ exporter.name, f"「{file_path}」への書き出しに失敗しました: {e}"
429
+ ) from e
430
+ return
431
+
432
+ save_kwargs = {'transparent': options.get('transparent', True)}
433
+ save_kwargs['bbox_inches'] = 'tight'
434
+
435
+ if file_ext in ['.pdf', '.svg']:
436
+ pass
437
+ else:
438
+ save_kwargs['dpi'] = options["dpi"]
439
+
440
+ with mpl.rc_context(export_rc_params(file_ext, options.get('svg_text_as_path', False))):
441
+ self.canvas.fig.savefig(file_path, **save_kwargs)
442
+ except Exception as e:
443
+ logger.exception("エクスポートに失敗しました")
444
+ notify.warning(self, "保存エラー", f"エクスポート中にエラーが発生しました:\n{e}")
445
+ finally:
446
+ # 失敗しても画面の大きさに戻す
447
+ self.canvas.fig.set_size_inches(original_size)
448
+ if full_resolution:
449
+ self._update_plot(full_resolution=False)
450
+ self.canvas.draw_idle()
451
+
452
+ def _generate_preview(self, dialog):
453
+ """今の軸だけを、書き出しの大きさの一時的な Figure に描いて見せる。"""
454
+
455
+ options = dialog.get_options()
456
+
457
+ width_in, height_in = self._calculate_size_in_inches(options)
458
+
459
+ temp_fig = Figure(figsize=(width_in, height_in), dpi=100)
460
+ temp_ax = temp_fig.add_subplot(111)
461
+
462
+ active_index = self.project.active_axis_index
463
+ if active_index >= len(self.project.all_plot_settings):
464
+ logger.warning("プレビュー生成時、アクティブな軸設定が見つかりません。")
465
+ return
466
+
467
+ active_settings = self.project.all_plot_settings[active_index]
468
+
469
+ # _draw_data は第2Y軸をこの一時的な Figure に作って all_secondary_axes に入れる。
470
+ # 失敗しても必ず戻さないと、画面の第2Y軸がプレビューの軸を指したままになる。
471
+ original_secondary = self.canvas.all_secondary_axes.copy()
472
+ try:
473
+ while len(self.canvas.all_secondary_axes) <= active_index:
474
+ self.canvas.all_secondary_axes.append(None)
475
+
476
+ self.canvas._draw_data(
477
+ temp_ax, active_index, self.project.datasets,
478
+ full_resolution=options.get('full_resolution', False),
479
+ )
480
+ self.canvas._apply_appearance(temp_ax, active_index, active_settings)
481
+ except Exception:
482
+ logger.exception("エクスポートのプレビューを描画できませんでした")
483
+ finally:
484
+ self.canvas.all_secondary_axes = original_secondary
485
+
486
+ try:
487
+ fit_tight_layout(temp_fig)
488
+ except ValueError:
489
+ pass
490
+
491
+ buf = io.BytesIO()
492
+ temp_fig.savefig(buf, format='png', dpi=100)
493
+ buf.seek(0)
494
+
495
+ pixmap = QPixmap()
496
+ pixmap.loadFromData(buf.read())
497
+
498
+ dialog.preview_label.setPixmap(
499
+ pixmap.scaled(dialog.preview_label.size(),
500
+ Qt.AspectRatioMode.KeepAspectRatio,
501
+ Qt.TransformationMode.SmoothTransformation)
502
+ )
503
+
504
+ buf.close()
505
+ del temp_fig
506
+
507
+ def _calculate_size_in_inches(self, options):
508
+ """ダイアログの幅と高さをインチにする。"""
509
+ width, height, unit, dpi = options["width"], options["height"], options["unit"], options["dpi"]
510
+
511
+ if "インチ" in unit:
512
+ return width, height
513
+ elif "ミリメートル" in unit:
514
+ return width / 25.4, height / 25.4
515
+ elif "センチメートル" in unit:
516
+ return width / 2.54, height / 2.54
517
+ elif "ピクセル" in unit:
518
+ return width / dpi, height / dpi
519
+
520
+ return 8, 6
@@ -0,0 +1,127 @@
1
+ """ヘルプメニュー(リファレンス、診断情報、アップデートの確認)。"""
2
+ import logging
3
+ import webbrowser
4
+ from datetime import datetime
5
+
6
+ from PySide6.QtWidgets import QMessageBox
7
+
8
+ from graphica.gui import notify
9
+ from graphica.gui.dialogs import HelpDialog, CalcHelpDialog, AboutDialog, ShortcutsDialog
10
+ from graphica.gui.task_runner import TaskRunner
11
+ from graphica.core.diagnostics import build_diagnostic_bundle
12
+ from graphica.core.update_check import check_for_update
13
+ from graphica.core.version import __version__
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class HelpMixin:
19
+ def _on_show_about(self):
20
+ dialog = AboutDialog(self)
21
+ dialog.exec()
22
+
23
+ def _on_show_help(self):
24
+ """プロットを操作しながら見られるよう、非モーダルで開く。"""
25
+ if getattr(self, 'help_dialog', None) is not None:
26
+ self.help_dialog.close()
27
+ # close() だけでは C++ のオブジェクトが残ってリークする
28
+ self.help_dialog.deleteLater()
29
+ self.help_dialog = HelpDialog(self)
30
+ self.help_dialog.show()
31
+ self.help_dialog.raise_()
32
+ self.help_dialog.activateWindow()
33
+
34
+ def _on_show_calc_help(self):
35
+ """プロットを操作しながら見られるよう、非モーダルで開く。"""
36
+ if getattr(self, 'calc_help_dialog', None) is not None:
37
+ self.calc_help_dialog.close()
38
+ self.calc_help_dialog.deleteLater()
39
+ self.calc_help_dialog = CalcHelpDialog(self)
40
+ self.calc_help_dialog.show()
41
+ self.calc_help_dialog.raise_()
42
+ self.calc_help_dialog.activateWindow()
43
+
44
+ def _on_show_shortcuts(self):
45
+ dialog = ShortcutsDialog(self._collect_menu_actions, self)
46
+ dialog.exec()
47
+
48
+ def _on_export_diagnostic_bundle(self):
49
+ """ログ・環境・設定・プラグインの状態を1つの zip にする(不具合の報告用)。"""
50
+ default_name = f"graphica_diagnostics_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
51
+ file_path, _ = notify.get_save_file_name(
52
+ self, "診断情報をエクスポート", default_name, "Zip Files (*.zip)"
53
+ )
54
+ if not file_path:
55
+ return
56
+ if not file_path.lower().endswith('.zip'):
57
+ file_path += '.zip'
58
+
59
+ settings_dict = {key: self.settings.value(key) for key in self.settings.allKeys()}
60
+ try:
61
+ build_diagnostic_bundle(file_path, settings_dict=settings_dict)
62
+ except Exception as e:
63
+ logger.exception("診断情報のエクスポートに失敗しました。")
64
+ notify.warning(self, "エクスポートエラー", f"診断情報のエクスポート中にエラーが発生しました:\n{e}")
65
+ return
66
+
67
+ notify.information(self, "エクスポート完了", f"診断情報を書き出しました:\n{file_path}")
68
+
69
+ # アップデートの確認は公開 API への匿名の GET 1回だけで、何も送らない。起動時は失敗しても黙り、手動では知らせる
70
+
71
+ def _start_startup_update_check(self):
72
+ """新しい版が無いか失敗したら何も出さない。"""
73
+ if self._update_check_task_runner is not None:
74
+ return # 手動の確認が動いている
75
+ runner = TaskRunner(check_for_update, __version__, parent=self)
76
+ runner.succeeded.connect(self._on_startup_update_check_succeeded)
77
+ runner.failed.connect(self._on_startup_update_check_failed)
78
+ self._update_check_task_runner = runner
79
+ runner.start()
80
+
81
+ def _on_startup_update_check_succeeded(self, update_info):
82
+ self._update_check_task_runner = None
83
+ if update_info is not None:
84
+ self._show_update_available_dialog(update_info)
85
+
86
+ def _on_startup_update_check_failed(self, _message):
87
+ logger.info("起動時のアップデート確認に失敗しました(オフライン等の可能性): %s", _message)
88
+ self._update_check_task_runner = None
89
+
90
+ def _on_check_for_update(self):
91
+ """「アップデートを確認...」メニューの処理。手動確認は失敗時もエラーを表示する。"""
92
+ if self._update_check_task_runner is not None:
93
+ notify.information(self, "アップデートの確認", "確認中です。完了までお待ちください。")
94
+ return
95
+ runner = TaskRunner(check_for_update, __version__, parent=self)
96
+ runner.succeeded.connect(self._on_manual_update_check_succeeded)
97
+ runner.failed.connect(self._on_manual_update_check_failed)
98
+ self._update_check_task_runner = runner
99
+ runner.start()
100
+
101
+ def _on_manual_update_check_succeeded(self, update_info):
102
+ self._update_check_task_runner = None
103
+ if update_info is not None:
104
+ self._show_update_available_dialog(update_info)
105
+ else:
106
+ notify.information(self, "アップデートの確認", f"お使いのバージョン({__version__})は最新です。")
107
+
108
+ def _on_manual_update_check_failed(self, message):
109
+ self._update_check_task_runner = None
110
+ logger.warning("アップデート確認に失敗しました: %s", message)
111
+ notify.warning(
112
+ self, "アップデートの確認",
113
+ f"アップデート情報の取得に失敗しました。ネットワーク接続を確認してください。\n\n{message}"
114
+ )
115
+
116
+ def _show_update_available_dialog(self, update_info):
117
+ """新しいバージョンが見つかった場合の通知(起動時自動確認・手動確認で共通)。"""
118
+ tag = update_info.get('tag_name', '')
119
+ html_url = update_info.get('html_url', '')
120
+ reply = notify.information(
121
+ self, "新しいバージョンがあります",
122
+ f"新しいバージョン({tag})が公開されています。現在のバージョン: {__version__}\n\n"
123
+ "ダウンロードページを開きますか?",
124
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
125
+ )
126
+ if reply == QMessageBox.StandardButton.Yes:
127
+ webbrowser.open(html_url)