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,321 @@
1
+ """書き出しのプレビューのドック。設定を変えるたびに全サブプロットの完成形を描き直し、ここから保存とコピーもできる。
2
+
3
+ 一時的な MplCanvas に描くので、画面のキャンバスには触れない。
4
+ """
5
+ import io
6
+ import os
7
+ import logging
8
+ import matplotlib as mpl
9
+
10
+ from PySide6.QtCore import Qt, QTimer, QByteArray, QMimeData
11
+ from PySide6.QtGui import QPixmap
12
+ from PySide6.QtWidgets import (QWidget, QVBoxLayout, QFormLayout, QHBoxLayout,
13
+ QLabel, QComboBox, QCheckBox, QDoubleSpinBox, QSpinBox,
14
+ QPushButton, QApplication)
15
+
16
+ from graphica.core.i18n import tr
17
+ from graphica.gui import notify
18
+ from graphica.gui.canvas import MplCanvas
19
+ from graphica.gui.export_settings import export_rc_params
20
+ from graphica.gui.theme import apply_form_spacing
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # 矢印の連打などで毎回重い描き直しが走らないよう、まとめるまでの待ち(ms)
25
+ PREVIEW_DEBOUNCE_MS = 150
26
+
27
+
28
+ class ExportPreviewPanel(QWidget):
29
+ def __init__(self, main_window, parent=None):
30
+ super().__init__(parent)
31
+ self.main_window = main_window
32
+ self._current_pixmap = None
33
+
34
+ layout = QVBoxLayout(self)
35
+
36
+ form = QFormLayout()
37
+ self.width_spinbox = QDoubleSpinBox()
38
+ self.width_spinbox.setRange(1, 10000)
39
+ self.width_spinbox.setDecimals(1)
40
+ self.width_spinbox.setValue(800)
41
+ form.addRow("幅", self.width_spinbox)
42
+
43
+ self.height_spinbox = QDoubleSpinBox()
44
+ self.height_spinbox.setRange(1, 10000)
45
+ self.height_spinbox.setDecimals(1)
46
+ self.height_spinbox.setValue(600)
47
+ form.addRow("高さ", self.height_spinbox)
48
+
49
+ self.unit_combo = QComboBox()
50
+ # 値は日本語の元の文字(書き出しの処理がそれで単位を見分ける)。表示だけを訳す
51
+ for unit in ("ピクセル (px)", "インチ (in)", "センチメートル (cm)"):
52
+ self.unit_combo.addItem(tr(unit), unit)
53
+ form.addRow("単位", self.unit_combo)
54
+
55
+ self.dpi_spinbox = QSpinBox()
56
+ self.dpi_spinbox.setRange(50, 1200)
57
+ self.dpi_spinbox.setValue(150)
58
+ self.dpi_spinbox.setSuffix(" dpi")
59
+ form.addRow("解像度", self.dpi_spinbox)
60
+
61
+ self.transparent_checkbox = QCheckBox("背景を透過")
62
+ self.transparent_checkbox.setChecked(True)
63
+ form.addRow(self.transparent_checkbox)
64
+
65
+ self.svg_text_as_path_checkbox = QCheckBox("文字をアウトライン化する(SVG)")
66
+ self.svg_text_as_path_checkbox.setToolTip(
67
+ "SVG保存/コピー時、目盛りの数字やラベルの文字をパス(輪郭線)として出力します。"
68
+ )
69
+ form.addRow(self.svg_text_as_path_checkbox)
70
+
71
+ # プレビューは応答を優先して常に間引いて描く。このチェックは保存とコピーだけに効く
72
+ self.full_resolution_checkbox = QCheckBox("フル解像度で保存/コピー(間引きなし)")
73
+ self.full_resolution_checkbox.setToolTip(
74
+ "「名前を付けて保存」「コピー」の出力にのみ適用されます(常時更新される"
75
+ "プレビュー自体は応答性のため常に間引いたまま表示します)。点数の多い"
76
+ "Line(折れ線)データセットや2Dマップ(ヒートマップ/等高線)の間引きを"
77
+ "無効化し、常に全データ点/全解像度で保存/コピーします。"
78
+ )
79
+ form.addRow(self.full_resolution_checkbox)
80
+
81
+ layout.addLayout(form)
82
+ apply_form_spacing(self)
83
+
84
+ self.preview_label = QLabel("プレビューがここに表示されます")
85
+ self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
86
+ self.preview_label.setMinimumSize(200, 150)
87
+ self.preview_label.setFrameShape(QLabel.Shape.StyledPanel)
88
+ self.preview_label.setWordWrap(True)
89
+ layout.addWidget(self.preview_label, 1)
90
+
91
+ button_row = QHBoxLayout()
92
+ # SVG はベクター画像を受け付けるアプリ(Illustrator、Inkscape など)に貼れる
93
+ self.copy_format_combo = QComboBox()
94
+ self.copy_format_combo.addItems(["PNG", "SVG"])
95
+ self.copy_format_combo.setToolTip("クリップボードにコピーする形式を選択します")
96
+ button_row.addWidget(self.copy_format_combo)
97
+
98
+ self.copy_button = QPushButton("コピー")
99
+ self.copy_button.setToolTip("現在の設定でレンダリングした画像をクリップボードにコピーします")
100
+ self.copy_button.clicked.connect(self._on_copy_clicked)
101
+ button_row.addWidget(self.copy_button)
102
+
103
+ self.save_button = QPushButton("名前を付けて保存...")
104
+ self.save_button.clicked.connect(self._on_save_clicked)
105
+ button_row.addWidget(self.save_button)
106
+ layout.addLayout(button_row)
107
+
108
+ self.width_spinbox.valueChanged.connect(self.refresh_preview)
109
+ self.height_spinbox.valueChanged.connect(self.refresh_preview)
110
+ self.unit_combo.currentIndexChanged.connect(self.refresh_preview)
111
+ self.dpi_spinbox.valueChanged.connect(self.refresh_preview)
112
+
113
+ self._refresh_timer = QTimer(self)
114
+ self._refresh_timer.setSingleShot(True)
115
+ self._refresh_timer.setInterval(PREVIEW_DEBOUNCE_MS)
116
+ self._refresh_timer.timeout.connect(self._render_preview)
117
+
118
+ def get_options(self):
119
+ """ExportDialog と同じ形の設定(_calculate_size_in_inches で使える)。"""
120
+ return {
121
+ "width": self.width_spinbox.value(),
122
+ "height": self.height_spinbox.value(),
123
+ "unit": self.unit_combo.currentData(),
124
+ "dpi": self.dpi_spinbox.value(),
125
+ "transparent": self.transparent_checkbox.isChecked(),
126
+ "svg_text_as_path": self.svg_text_as_path_checkbox.isChecked(),
127
+ "full_resolution": self.full_resolution_checkbox.isChecked(),
128
+ }
129
+
130
+ def refresh_preview(self):
131
+ """少し待ってまとめて描く。隠れている間は描かない(表示したときに refresh_preview が呼ばれる)。"""
132
+ if not self.isVisible():
133
+ return
134
+ self._refresh_timer.start()
135
+
136
+ def _render_preview(self):
137
+ options = self.get_options()
138
+ width_in, height_in = self.main_window._calculate_size_in_inches(options)
139
+ if width_in <= 0 or height_in <= 0:
140
+ return
141
+
142
+ pixmap = self._render_full_figure_pixmap(width_in, height_in, options["dpi"])
143
+ self._current_pixmap = pixmap
144
+ if pixmap is None or pixmap.isNull():
145
+ self.preview_label.setPixmap(QPixmap())
146
+ self.preview_label.setText("プレビューを生成できませんでした\n(データセットがないか、設定を確認してください)")
147
+ return
148
+ self.preview_label.setText("")
149
+ self.preview_label.setPixmap(
150
+ pixmap.scaled(self.preview_label.size(),
151
+ Qt.AspectRatioMode.KeepAspectRatio,
152
+ Qt.TransformationMode.SmoothTransformation)
153
+ )
154
+
155
+ def resizeEvent(self, event):
156
+ super().resizeEvent(event)
157
+ if self._current_pixmap is not None and not self._current_pixmap.isNull():
158
+ self.preview_label.setPixmap(
159
+ self._current_pixmap.scaled(self.preview_label.size(),
160
+ Qt.AspectRatioMode.KeepAspectRatio,
161
+ Qt.TransformationMode.SmoothTransformation)
162
+ )
163
+
164
+ def _make_temp_canvas_for_full_figure(self, width_in, height_in, dpi, full_resolution=False):
165
+ """全サブプロットを描いた一時的な MplCanvas。描けるものが無ければ None。
166
+
167
+ full_resolution=True は間引かない。常に描き直すプレビューでは False、保存とコピーでだけ設定の値を渡す。
168
+ """
169
+ mw = self.main_window
170
+ layout_mode = getattr(mw.project, 'layout_mode', 'grid')
171
+ if layout_mode == 'free':
172
+ rows, cols = 0, 0
173
+ if not mw.project.all_plot_settings:
174
+ return None
175
+ else:
176
+ rows = mw.subplot_rows_spinbox.value()
177
+ cols = mw.subplot_cols_spinbox.value()
178
+ if rows * cols == 0:
179
+ return None
180
+
181
+ temp_canvas = MplCanvas(width=width_in, height=height_in, dpi=dpi)
182
+ temp_canvas.dark_mode = mw.canvas.dark_mode
183
+ temp_canvas.redraw_all(
184
+ mw.project.datasets, rows, cols, mw.project.all_plot_settings, layout_mode=layout_mode,
185
+ panel_labels_enabled=mw.project.panel_labels_enabled,
186
+ full_resolution=full_resolution,
187
+ )
188
+ return temp_canvas
189
+
190
+ def _render_full_figure_pixmap(self, width_in, height_in, dpi):
191
+ """画面のプレビュー用。常に不透明で描く(透過は保存とコピーのときだけ選べる)。"""
192
+ try:
193
+ temp_canvas = self._make_temp_canvas_for_full_figure(width_in, height_in, dpi)
194
+ if temp_canvas is None:
195
+ return None
196
+ try:
197
+ buf = io.BytesIO()
198
+ temp_canvas.fig.savefig(
199
+ buf, format='png', dpi=dpi, bbox_inches='tight',
200
+ facecolor=temp_canvas.fig.get_facecolor()
201
+ )
202
+ buf.seek(0)
203
+ pixmap = QPixmap()
204
+ pixmap.loadFromData(buf.read())
205
+ buf.close()
206
+ return pixmap
207
+ finally:
208
+ temp_canvas.deleteLater()
209
+ except Exception:
210
+ logger.exception("エクスポートプレビューの生成に失敗しました。")
211
+ return None
212
+
213
+ def _render_full_figure_bytes(self, width_in, height_in, dpi, fmt, transparent, svg_text_as_path=False,
214
+ full_resolution=False):
215
+ """'png' か 'svg' で書いたバイト列(保存とコピーで共有)。"""
216
+ try:
217
+ temp_canvas = self._make_temp_canvas_for_full_figure(width_in, height_in, dpi, full_resolution=full_resolution)
218
+ if temp_canvas is None:
219
+ return None
220
+ try:
221
+ buf = io.BytesIO()
222
+ save_kwargs = {'format': fmt, 'bbox_inches': 'tight', 'transparent': transparent}
223
+ if fmt != 'svg':
224
+ save_kwargs['dpi'] = dpi
225
+ with mpl.rc_context(export_rc_params(fmt, svg_text_as_path)):
226
+ temp_canvas.fig.savefig(buf, **save_kwargs)
227
+ buf.seek(0)
228
+ data = buf.read()
229
+ buf.close()
230
+ return data
231
+ finally:
232
+ temp_canvas.deleteLater()
233
+ except Exception:
234
+ logger.exception("エクスポートプレビューのレンダリングに失敗しました(形式: %s)。", fmt)
235
+ return None
236
+
237
+ def _on_copy_clicked(self):
238
+ options = self.get_options()
239
+ width_in, height_in = self.main_window._calculate_size_in_inches(options)
240
+ if width_in <= 0 or height_in <= 0:
241
+ notify.warning(self, "コピーエラー", "コピーする画像がありません。")
242
+ return
243
+
244
+ if self.copy_format_combo.currentText() == "SVG":
245
+ svg_bytes = self._render_full_figure_bytes(
246
+ width_in, height_in, options["dpi"], fmt='svg', transparent=options["transparent"],
247
+ svg_text_as_path=options["svg_text_as_path"], full_resolution=options["full_resolution"]
248
+ )
249
+ if svg_bytes is None:
250
+ notify.warning(self, "コピーエラー", "コピーする画像がありません。")
251
+ return
252
+ mime_data = QMimeData()
253
+ mime_data.setData("image/svg+xml", QByteArray(svg_bytes))
254
+ # SVG だけでは Word や PowerPoint が貼り付けられないので、PNG も同じ QMimeData に載せる。
255
+ # その PNG は透過にしない(macOS のクリップボードはアルファを引き継がず、貼ると真っ黒になる)
256
+ png_fallback_bytes = self._render_full_figure_bytes(
257
+ width_in, height_in, options["dpi"], fmt='png', transparent=False,
258
+ full_resolution=options["full_resolution"]
259
+ )
260
+ if png_fallback_bytes is not None:
261
+ fallback_pixmap = QPixmap()
262
+ fallback_pixmap.loadFromData(png_fallback_bytes)
263
+ if not fallback_pixmap.isNull():
264
+ mime_data.setImageData(fallback_pixmap.toImage())
265
+ QApplication.clipboard().setMimeData(mime_data)
266
+ self.main_window.statusBar().showMessage("プレビュー画像をSVG形式でクリップボードにコピーしました", 3000)
267
+ else:
268
+ # 上と同じ理由で透過にしない
269
+ png_bytes = self._render_full_figure_bytes(
270
+ width_in, height_in, options["dpi"], fmt='png', transparent=False,
271
+ full_resolution=options["full_resolution"]
272
+ )
273
+ if png_bytes is None:
274
+ notify.warning(self, "コピーエラー", "コピーする画像がありません。")
275
+ return
276
+ pixmap = QPixmap()
277
+ pixmap.loadFromData(png_bytes)
278
+ QApplication.clipboard().setPixmap(pixmap)
279
+ self.main_window.statusBar().showMessage("プレビュー画像をクリップボードにコピーしました", 3000)
280
+
281
+ def _on_save_clicked(self):
282
+ options = self.get_options()
283
+ width_in, height_in = self.main_window._calculate_size_in_inches(options)
284
+
285
+ layout_mode = getattr(self.main_window.project, 'layout_mode', 'grid')
286
+ if layout_mode == 'free':
287
+ if not self.main_window.project.all_plot_settings:
288
+ notify.warning(self, "保存エラー", "有効なプロットがありません。")
289
+ return
290
+ else:
291
+ rows = self.main_window.subplot_rows_spinbox.value()
292
+ cols = self.main_window.subplot_cols_spinbox.value()
293
+ if rows * cols == 0:
294
+ notify.warning(self, "保存エラー", "有効なプロットがありません。")
295
+ return
296
+
297
+ file_path, _ = notify.get_save_file_name(
298
+ self, "プロットを保存", "", "PNG (*.png);;PDF (*.pdf);;SVG (*.svg)"
299
+ )
300
+ if not file_path:
301
+ return
302
+
303
+ file_ext = os.path.splitext(file_path)[1].lower().lstrip('.')
304
+ temp_canvas = self._make_temp_canvas_for_full_figure(
305
+ width_in, height_in, options["dpi"], full_resolution=options["full_resolution"]
306
+ )
307
+ if temp_canvas is None:
308
+ notify.warning(self, "保存エラー", "有効なプロットがありません。")
309
+ return
310
+ try:
311
+ save_kwargs = {'transparent': options["transparent"], 'bbox_inches': 'tight'}
312
+ if file_ext not in ('pdf', 'svg'):
313
+ save_kwargs['dpi'] = options["dpi"]
314
+ with mpl.rc_context(export_rc_params(file_ext, options.get("svg_text_as_path", False))):
315
+ temp_canvas.fig.savefig(file_path, **save_kwargs)
316
+ self.main_window.statusBar().showMessage(f"保存しました: {file_path}", 3000)
317
+ except Exception as e:
318
+ logger.exception("エクスポートプレビューパネルからの保存に失敗しました。")
319
+ notify.warning(self, "保存エラー", f"エクスポート中にエラーが発生しました:\n{e}")
320
+ finally:
321
+ temp_canvas.deleteLater()
@@ -0,0 +1,16 @@
1
+ """書き出すときだけ当てる matplotlib の rcParams。書き出しの経路(4つ)はすべてこれを使うこと。"""
2
+
3
+
4
+ def export_rc_params(fmt, svg_text_as_path=False):
5
+ """mpl.rc_context() に渡す辞書。
6
+
7
+ SVG は文字をテキストのまま残すか、パスにするか。PDF はフォントを TrueType(Type 42)で埋め込む
8
+ (既定の Type 3 だとベクター編集ソフトで文字を編集できない)。ラスタは何もしない。
9
+ fmt の先頭のドットと大文字小文字は無視する。
10
+ """
11
+ fmt = (fmt or '').lower().lstrip('.')
12
+ if fmt == 'svg':
13
+ return {'svg.fonttype': 'path' if svg_text_as_path else 'none'}
14
+ if fmt in ('pdf', 'ps', 'eps'):
15
+ return {'pdf.fonttype': 42, 'ps.fonttype': 42}
16
+ return {}
@@ -0,0 +1,97 @@
1
+ """Windows で .gra と .graphica を Graphica に結び付ける(利用者の範囲 HKCU\\Software\\Classes だけを触る)。
2
+
3
+ インストーラーを使わず exe だけを置いた人のため、環境設定のボタンから登録・解除する。インストーラーは同じ名前で自分で登録する。
4
+ ソースや pip から動かしているときは、ダブルクリックで起動する exe が無いので登録しない。
5
+ """
6
+ import os
7
+ import sys
8
+
9
+ PROG_ID = "Graphica.Project"
10
+ PROG_ID_DESCRIPTION = "Graphica プロジェクト"
11
+ ASSOCIATED_EXTENSIONS = (".gra", ".graphica")
12
+ _CLASSES_ROOT = r"Software\Classes"
13
+
14
+
15
+ def _winreg():
16
+ import winreg
17
+ return winreg
18
+
19
+
20
+ def is_supported():
21
+ """登録できるのは、Windows の exe(PyInstaller で固めたもの)から動かしているときだけ。"""
22
+ return sys.platform == "win32" and bool(getattr(sys, "frozen", False))
23
+
24
+
25
+ def open_command(executable=None):
26
+ return f'"{executable or sys.executable}" "%1"'
27
+
28
+
29
+ def _set_default(winreg, path, value):
30
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_WRITE) as key:
31
+ winreg.SetValueEx(key, "", 0, winreg.REG_SZ, value)
32
+
33
+
34
+ def _read_default(winreg, path):
35
+ try:
36
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as key:
37
+ return winreg.QueryValueEx(key, "")[0]
38
+ except OSError:
39
+ return None
40
+
41
+
42
+ def _delete_tree(winreg, path):
43
+ try:
44
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_ALL_ACCESS) as key:
45
+ while True:
46
+ try:
47
+ child = winreg.EnumKey(key, 0)
48
+ except OSError:
49
+ break
50
+ _delete_tree(winreg, path + "\\" + child)
51
+ winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path)
52
+ except FileNotFoundError:
53
+ pass
54
+
55
+
56
+ def _notify_shell():
57
+ """エクスプローラーにアイコンと関連付けを読み直させる。失敗しても登録自体は済んでいる。"""
58
+ try:
59
+ import ctypes
60
+ shcne_assocchanged, shcnf_idlist = 0x08000000, 0x0000
61
+ ctypes.windll.shell32.SHChangeNotify(shcne_assocchanged, shcnf_idlist, None, None)
62
+ except (AttributeError, OSError):
63
+ pass
64
+
65
+
66
+ def register(executable=None, winreg=None, notify_shell=True):
67
+ winreg = winreg or _winreg()
68
+ executable = executable or sys.executable
69
+ prog_id_path = _CLASSES_ROOT + "\\" + PROG_ID
70
+ _set_default(winreg, prog_id_path, PROG_ID_DESCRIPTION)
71
+ _set_default(winreg, prog_id_path + r"\DefaultIcon", f'"{executable}",0')
72
+ _set_default(winreg, prog_id_path + r"\shell\open\command", open_command(executable))
73
+ for ext in ASSOCIATED_EXTENSIONS:
74
+ _set_default(winreg, _CLASSES_ROOT + "\\" + ext, PROG_ID)
75
+ if notify_shell:
76
+ _notify_shell()
77
+
78
+
79
+ def unregister(winreg=None, notify_shell=True):
80
+ """自分が結び付けた拡張子だけを外す(ほかのソフトに変えられていれば触らない)。"""
81
+ winreg = winreg or _winreg()
82
+ for ext in ASSOCIATED_EXTENSIONS:
83
+ path = _CLASSES_ROOT + "\\" + ext
84
+ if _read_default(winreg, path) == PROG_ID:
85
+ _delete_tree(winreg, path)
86
+ _delete_tree(winreg, _CLASSES_ROOT + "\\" + PROG_ID)
87
+ if notify_shell:
88
+ _notify_shell()
89
+
90
+
91
+ def is_registered(executable=None, winreg=None):
92
+ """このアプリの exe で開くよう登録されているか。"""
93
+ winreg = winreg or _winreg()
94
+ command = _read_default(winreg, _CLASSES_ROOT + "\\" + PROG_ID + r"\shell\open\command")
95
+ if command is None or os.path.normcase(command) != os.path.normcase(open_command(executable)):
96
+ return False
97
+ return all(_read_default(winreg, _CLASSES_ROOT + "\\" + ext) == PROG_ID for ext in ASSOCIATED_EXTENSIONS)
@@ -0,0 +1,44 @@
1
+ """Tabler Icons(MIT)の SVG を QIcon にする。stroke="currentColor" を色に置き換えて描く。"""
2
+ import os
3
+ from functools import lru_cache
4
+
5
+ from PySide6.QtCore import QByteArray, QSize, Qt
6
+ from PySide6.QtGui import QIcon, QPixmap, QPainter
7
+ from PySide6.QtSvg import QSvgRenderer
8
+
9
+ from graphica.gui.resources import resource_path
10
+
11
+ ICONS_DIR = os.path.join("assets", "icons")
12
+
13
+
14
+ def icon(name, color=None, size=16):
15
+ """assets/icons/{name}.svg。color を省くとテーマの text_secondary(呼ぶたびに決まる)。"""
16
+ if color is None:
17
+ from graphica.gui import theme
18
+ color = theme.current_tokens()["text_secondary"]
19
+ # exe では同梱のアイコンが sys._MEIPASS の下にあるので、このファイルの場所ではなく resource_path() で探す
20
+ svg_path = resource_path(os.path.join(ICONS_DIR, f"{name}.svg"))
21
+ return load_svg_icon(svg_path, color=color, size=size)
22
+
23
+
24
+ @lru_cache(maxsize=None)
25
+ def _load_svg_text(svg_path):
26
+ with open(svg_path, "r", encoding="utf-8") as f:
27
+ return f.read()
28
+
29
+
30
+ def load_svg_icon(svg_path, color="#4B5157", size=24):
31
+ """ファイルが無ければ空の QIcon(アイコンが無くてもアプリを落とさない)。"""
32
+ if not os.path.exists(svg_path):
33
+ return QIcon()
34
+
35
+ svg_text = _load_svg_text(svg_path).replace("currentColor", color)
36
+ renderer = QSvgRenderer(QByteArray(svg_text.encode("utf-8")))
37
+
38
+ pixmap = QPixmap(QSize(size, size))
39
+ pixmap.fill(Qt.GlobalColor.transparent)
40
+ painter = QPainter(pixmap)
41
+ renderer.render(painter)
42
+ painter.end()
43
+
44
+ return QIcon(pixmap)