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,280 @@
1
+ """データファイルの読み込み(TaskRunner で別スレッドで動かす関数)と、文字コード・区切り文字の判定。"""
2
+ import csv
3
+ import dataclasses
4
+ import re
5
+ import warnings
6
+
7
+ import pandas as pd
8
+
9
+ # 順に試す文字コード。utf-8-sig は BOM の有無どちらの UTF-8 も読める。latin-1 は必ず読める最後の手段。
10
+ # UTF-16 は入れない(cp932 や latin-1 が例外を出さずに化けた結果を返す)。BOM で別に見分ける。
11
+ CSV_ENCODING_FALLBACKS = ['utf-8-sig', 'cp932', 'latin-1']
12
+
13
+ # 組み込みで読める拡張子。開くダイアログ、ドラッグ&ドロップ、フォルダ一括取り込み、プレビューがすべてここを見る。
14
+ # 区切り文字付きテキストは CSV と同じく文字コードと区切りを判定する。.xlsx は openpyxl、.xls は xlrd。
15
+ DELIMITED_TEXT_EXTENSIONS = ('.csv', '.txt')
16
+ EXCEL_EXTENSIONS = ('.xlsx', '.xls')
17
+ BUILTIN_DATA_FILE_EXTENSIONS = DELIMITED_TEXT_EXTENSIONS + EXCEL_EXTENSIONS
18
+
19
+
20
+ def is_delimited_text_file(file_path):
21
+ return bool(file_path) and file_path.lower().endswith(DELIMITED_TEXT_EXTENSIONS)
22
+
23
+
24
+ def is_excel_file(file_path):
25
+ return bool(file_path) and file_path.lower().endswith(EXCEL_EXTENSIONS)
26
+
27
+
28
+ def excel_engine_for(file_path):
29
+ """openpyxl は .xls を読めないので、拡張子で xlrd と使い分ける。"""
30
+ return 'xlrd' if file_path.lower().endswith('.xls') else 'openpyxl'
31
+
32
+
33
+ def pandas_separator(delimiter):
34
+ """(sep, engine)。空白は空白の連続とみなす(桁揃えの空白で空の列が大量にできないように)。正規表現は python エンジン。"""
35
+ if delimiter == ' ':
36
+ return r'\s+', 'python'
37
+ if len(delimiter) == 1:
38
+ return delimiter, 'c'
39
+ return delimiter, 'python'
40
+
41
+
42
+ def _detect_bom_encoding(file_path):
43
+ """BOM から文字コードを判定する。無ければ None。"""
44
+ with open(file_path, 'rb') as f:
45
+ head = f.read(4)
46
+ if head.startswith(b'\xff\xfe') or head.startswith(b'\xfe\xff'):
47
+ return 'utf-16'
48
+ if head.startswith(b'\xef\xbb\xbf'):
49
+ return 'utf-8-sig'
50
+ return None
51
+
52
+
53
+ def _csv_encoding_candidates(file_path):
54
+ """BOM の判定を先頭に、CSV_ENCODING_FALLBACKS を並べた候補(読み込みと判定で順序を共有する)。"""
55
+ bom_encoding = _detect_bom_encoding(file_path)
56
+ candidates = [bom_encoding] if bom_encoding else []
57
+ candidates += [e for e in CSV_ENCODING_FALLBACKS if e not in candidates]
58
+ return candidates
59
+
60
+
61
+ def detect_csv_encoding(file_path):
62
+ """テキストとしてデコードできる最初の文字コード。DataFrame にはしないので軽い。"""
63
+ last_error = None
64
+ for encoding in _csv_encoding_candidates(file_path):
65
+ try:
66
+ with open(file_path, encoding=encoding) as f:
67
+ f.read()
68
+ return encoding
69
+ except UnicodeDecodeError as e:
70
+ last_error = e
71
+ continue
72
+ raise ValueError(
73
+ f"CSVファイルの文字コードを判定できませんでした "
74
+ f"(試行: {', '.join(CSV_ENCODING_FALLBACKS)})。詳細: {last_error}"
75
+ )
76
+
77
+
78
+ def _sniff_delimiter_from_text(sample_text):
79
+ """カンマ・タブ・セミコロン・空白のどれかを推測する。判定できなければカンマ。"""
80
+ if not sample_text.strip():
81
+ return ','
82
+ try:
83
+ dialect = csv.Sniffer().sniff(sample_text, delimiters=',\t; ')
84
+ return dialect.delimiter
85
+ except csv.Error:
86
+ return ','
87
+
88
+
89
+ def detect_csv_delimiter(file_path, encoding, sample_lines=50):
90
+ lines = []
91
+ try:
92
+ with open(file_path, encoding=encoding, errors='replace') as f:
93
+ for _ in range(sample_lines):
94
+ line = f.readline()
95
+ if not line:
96
+ break
97
+ lines.append(line)
98
+ except OSError:
99
+ return ','
100
+ return _sniff_delimiter_from_text(''.join(lines))
101
+
102
+
103
+ def detect_clipboard_delimiter(text, sample_lines=50):
104
+ """クリップボードの区切り文字(Excel からのコピーはタブ、CSV の文字列ならカンマなど)。"""
105
+ sample = ''.join(text.splitlines(keepends=True)[:sample_lines])
106
+ return _sniff_delimiter_from_text(sample)
107
+
108
+
109
+ # 測定装置の書き出し(JASCO の TXT など)は、数値の表の前後に測定条件の行が付く。そういうファイルでだけ数値の表を探す
110
+ NUMERIC_TABLE_DELIMITERS = ('\t', ',', ';', ' ')
111
+ MIN_NUMERIC_TABLE_ROWS = 3
112
+
113
+
114
+ @dataclasses.dataclass
115
+ class NumericTable:
116
+ first_line: int # 0 始まり、表の最初の数値の行
117
+ stop_line: int # 表の最後の行の次
118
+ delimiter: str
119
+ header: list | None # 表の直前の行が同じ列数の文字なら列名
120
+
121
+
122
+ def _split_numeric_row(line, delimiter):
123
+ """2 列以上がすべて数値として読めれば、その列の文字列。読めなければ None。"""
124
+ text = line.strip()
125
+ fields = text.split() if delimiter == ' ' else [f.strip() for f in text.split(delimiter)]
126
+ if len(fields) < 2:
127
+ return None
128
+ try:
129
+ for field in fields:
130
+ float(field)
131
+ except ValueError:
132
+ return None
133
+ return fields
134
+
135
+
136
+ def find_numeric_table(lines):
137
+ """列数のそろった数値の行が最も長く続くところ。MIN_NUMERIC_TABLE_ROWS 行に満たなければ None。"""
138
+ best = None
139
+ for delimiter in NUMERIC_TABLE_DELIMITERS:
140
+ start, columns = None, 0
141
+ for index, line in enumerate([*lines, '']):
142
+ fields = _split_numeric_row(line, delimiter)
143
+ if fields is not None and start is not None and len(fields) == columns:
144
+ continue
145
+ if start is not None and (best is None or index - start > best.stop_line - best.first_line):
146
+ best = NumericTable(start, index, delimiter, None)
147
+ start, columns = (index, len(fields)) if fields is not None else (None, 0)
148
+ if best is None or best.stop_line - best.first_line < MIN_NUMERIC_TABLE_ROWS:
149
+ return None
150
+ if best.first_line > 0:
151
+ above = lines[best.first_line - 1].strip()
152
+ names = above.split() if best.delimiter == ' ' else [f.strip() for f in above.split(best.delimiter)]
153
+ column_count = len(_split_numeric_row(lines[best.first_line], best.delimiter))
154
+ if above and len(names) == column_count and _split_numeric_row(above, best.delimiter) is None:
155
+ best.header = names
156
+ return best
157
+
158
+
159
+ def read_numeric_table(file_path, encoding):
160
+ """前後の説明の行を除いた数値の表を DataFrame にする。(DataFrame, NumericTable)、見つからなければ None。"""
161
+ import io
162
+
163
+ with open(file_path, encoding=encoding) as f:
164
+ lines = f.read().splitlines()
165
+ table = find_numeric_table(lines)
166
+ if table is None:
167
+ return None
168
+ sep, engine = pandas_separator(table.delimiter)
169
+ body = '\n'.join(line.strip() for line in lines[table.first_line:table.stop_line])
170
+ df = pd.read_csv(io.StringIO(body), sep=sep, engine=engine, header=None)
171
+ names = table.header or [f"列{i + 1}" for i in range(df.shape[1])]
172
+ # 同じ列名は read_csv と同じく .1, .2 を付けて分ける
173
+ seen = {}
174
+ unique = []
175
+ for name in names:
176
+ count = seen.get(name, 0)
177
+ unique.append(name if count == 0 else f"{name}.{count}")
178
+ seen[name] = count + 1
179
+ df.columns = unique
180
+ return df, table
181
+
182
+
183
+ def has_numeric_column(df):
184
+ return any(pd.api.types.is_numeric_dtype(df[column]) for column in df.columns)
185
+
186
+
187
+ # 年(4 桁の数字)を含む値だけを日付とみなす。"12:30" のような時刻だけの文字は、読んだ日の日付が補われてしまう
188
+ _YEAR_PATTERN = re.compile(r"\d{4}")
189
+
190
+
191
+ def parse_date_columns(df):
192
+ """文字の列のうち、空でない値がすべて日付として読める列を日付型にする(Excel の日付の列と同じ扱い)。"""
193
+ for column in df.columns:
194
+ series = df[column]
195
+ if series.dtype != object:
196
+ continue
197
+ text = series.dropna().astype(str).str.strip()
198
+ text = text[text != ""]
199
+ if text.empty or not text.map(lambda value: _YEAR_PATTERN.search(value) is not None).all():
200
+ continue
201
+ with warnings.catch_warnings():
202
+ # 書式を推測できない警告と、タイムゾーンが混ざった列の将来の仕様変更の警告(その列は下で日付にしない)
203
+ warnings.simplefilter("ignore", UserWarning)
204
+ warnings.simplefilter("ignore", FutureWarning)
205
+ try:
206
+ # 書式は値ごとに読む(先頭の値の書式に合わせると、時刻のある値と無い値が混ざった列を読めない)
207
+ parsed = pd.to_datetime(text, errors="coerce", format="mixed")
208
+ except (ValueError, TypeError):
209
+ continue
210
+ # タイムゾーンが混ざると日付型にならない(object のまま)
211
+ if parsed.isna().any() or not pd.api.types.is_datetime64_any_dtype(parsed):
212
+ continue
213
+ converted = pd.Series(pd.NaT, index=series.index, dtype=parsed.dtype)
214
+ converted.loc[parsed.index] = parsed
215
+ df[column] = converted
216
+ return df
217
+
218
+
219
+ def read_data_file(file_path):
220
+ """データファイルを DataFrame にする。プラグインがその拡張子を登録していればそちらを使う。"""
221
+ ext = file_path.lower().split('.')[-1]
222
+
223
+ from graphica.core.plugin_api import get_plugin_api
224
+ from graphica.core.plugin_types import PluginExecutionError
225
+ api = get_plugin_api()
226
+ importer = api.get_importer_for_extension(ext) if api is not None else None
227
+ if importer is not None:
228
+ try:
229
+ result = importer.loader(file_path)
230
+ except Exception as e:
231
+ raise PluginExecutionError(importer.name, f"「{file_path}」の読み込みに失敗しました: {e}") from e
232
+ if not isinstance(result, pd.DataFrame):
233
+ raise PluginExecutionError(
234
+ importer.name,
235
+ "現在サポートされているのは単一のDataFrameを返すインポーターのみです"
236
+ "(複数シートを返す形式は未対応です)。"
237
+ )
238
+ return result
239
+
240
+ if is_delimited_text_file(file_path):
241
+ # 区切り文字もここで推測する(カンマで読むとタブ区切りが1列に潰れ、「2列以上必要」で弾かれる)
242
+ last_error = None
243
+ for encoding in _csv_encoding_candidates(file_path):
244
+ try:
245
+ sep, engine = pandas_separator(detect_csv_delimiter(file_path, encoding))
246
+ df = pd.read_csv(file_path, header=0, encoding=encoding, sep=sep, engine=engine)
247
+ except UnicodeDecodeError as e:
248
+ last_error = e
249
+ continue
250
+ except pd.errors.ParserError as e:
251
+ try:
252
+ found = read_numeric_table(file_path, encoding)
253
+ except UnicodeDecodeError:
254
+ found = None
255
+ if found is not None:
256
+ return parse_date_columns(found[0])
257
+ last_error = e
258
+ continue
259
+ # 普通に読めて数値の列があるファイルは今までどおり。どの列も数値にならないときだけ、前後の説明の行を疑う
260
+ if not has_numeric_column(df):
261
+ found = read_numeric_table(file_path, encoding)
262
+ if found is not None:
263
+ return parse_date_columns(found[0])
264
+ return parse_date_columns(df)
265
+ raise ValueError(
266
+ f"テキストファイルの文字コードを判定できませんでした "
267
+ f"(試行: {', '.join(CSV_ENCODING_FALLBACKS)})。詳細: {last_error}"
268
+ )
269
+ elif is_excel_file(file_path):
270
+ return pd.read_excel(file_path, engine=excel_engine_for(file_path))
271
+ else:
272
+ raise ValueError(f"未対応のファイル形式です: {ext}")
273
+
274
+
275
+ def load_data_file_task(file_path, report_progress=None, is_cancelled=None):
276
+ """TaskRunner 用。読み込みは中断できないので、report_progress と is_cancelled は受け取るだけ。"""
277
+ df = read_data_file(file_path)
278
+ if len(df.columns) < 2:
279
+ raise ValueError("データには少なくとも2列必要です。")
280
+ return df
File without changes
@@ -0,0 +1,197 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import logging
5
+
6
+ from PySide6.QtCore import QObject, Signal
7
+
8
+ from graphica.core.dataset import Dataset
9
+ from graphica.core.json_utils import GraphicaJSONEncoder
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # 保存は .gra。.graphica(以前の拡張子)も中身は同じ JSON なので読み書きできる
14
+ PROJECT_FILE_EXTENSION = '.gra'
15
+ PROJECT_FILE_EXTENSIONS = ('.gra', '.graphica')
16
+ LEGACY_PICKLE_EXTENSION = '.pkl'
17
+
18
+
19
+ # format_version が無いファイルは 0。構造を変えたら上げて、前の版からの変換を _MIGRATIONS に足す
20
+ CURRENT_FORMAT_VERSION = 1
21
+
22
+
23
+ def _migrate_v0_to_v1(data):
24
+ """0 から 1 は format_version を足しただけで、構造は同じ。"""
25
+ return data
26
+
27
+
28
+ # from_version -> (from_version + 1) の形にする関数
29
+ _MIGRATIONS = {
30
+ 0: _migrate_v0_to_v1,
31
+ }
32
+
33
+
34
+ def _migrate_project_data(data):
35
+ """CURRENT_FORMAT_VERSION まで順に変換する。新しい版のファイルはエラーにする(黙って半分だけ読まない)。"""
36
+ version = data.get('format_version', 0)
37
+ if version > CURRENT_FORMAT_VERSION:
38
+ raise ValueError(
39
+ f"このプロジェクトファイルはバージョン{version}で保存されていますが、"
40
+ f"このアプリケーションが対応しているのはバージョン{CURRENT_FORMAT_VERSION}までです。"
41
+ "アプリケーションを最新版に更新してください。"
42
+ )
43
+ while version < CURRENT_FORMAT_VERSION:
44
+ migrate = _MIGRATIONS.get(version)
45
+ if migrate is None:
46
+ raise ValueError(f"バージョン{version}からの移行手順が見つかりません。")
47
+ data = migrate(data)
48
+ version += 1
49
+ return data
50
+
51
+
52
+ class ProjectModel(QObject):
53
+ """1つの文書(データセット、軸ごとの設定、レイアウト)。
54
+
55
+ 既存の変更箇所は PlotterApp._update_plot() を直接呼ぶ。changed は、そのメソッドを知らない新しい経路が
56
+ 再描画を頼むためのもので、今は誰も発行しない。
57
+ """
58
+
59
+ changed = Signal()
60
+
61
+ def __init__(self):
62
+ super().__init__()
63
+ # 最後に保存・読み込みしたパス(オートセーブでも変わる。上書き保存先は PlotterApp._current_project_path)
64
+ self.current_filepath = ""
65
+
66
+ self.datasets = []
67
+ # {'name', 'children': [...]} の入れ子。子はフォルダか {'dataset': Dataset}。name='' のルートは表示しない
68
+ self.dataset_group_tree = {'name': '', 'children': []}
69
+ self.all_plot_settings = [] # 軸ごとの設定(core/axis_settings.py)
70
+ self.active_axis_index = 0
71
+
72
+ self.layout_rows = 1
73
+ self.layout_cols = 1
74
+ # 'grid' か 'free'。'free' では all_plot_settings の数がサブプロットの数で、各設定の
75
+ # 'free_rect' に (left, bottom, width, height) を 0〜1 で持つ
76
+ self.layout_mode = 'grid'
77
+
78
+ # (a)(b)(c)… は描くときに並びから決めるので、文字は保存しない
79
+ self.panel_labels_enabled = False
80
+
81
+ # 同じ行(sharex)・同じ列(sharey)のサブプロットの軸を束ね、内側の目盛りラベルを隠す。グリッドのときだけ
82
+ self.share_x_axis = False
83
+ self.share_y_axis = False
84
+
85
+ def notify_changed(self):
86
+ self.changed.emit()
87
+
88
+ def save_project(self, filepath):
89
+ """JSON で保存する(開いてもコードが実行されない)。拡張子は .gra か .graphica。"""
90
+ ext = os.path.splitext(filepath)[1].lower()
91
+ if ext not in PROJECT_FILE_EXTENSIONS:
92
+ raise ValueError(f"サポートされていない拡張子です: {ext}")
93
+ self._save_project_json(filepath)
94
+ self.current_filepath = filepath
95
+
96
+ def load_project(self, filepath):
97
+ if not os.path.exists(filepath):
98
+ raise FileNotFoundError(f"ファイルが見つかりません: {filepath}")
99
+
100
+ ext = os.path.splitext(filepath)[1].lower()
101
+ if ext == LEGACY_PICKLE_EXTENSION:
102
+ raise ValueError("旧形式(.pkl)のプロジェクトには対応していません。")
103
+ if ext not in PROJECT_FILE_EXTENSIONS:
104
+ raise ValueError(f"サポートされていない拡張子です: {ext}")
105
+ self._load_project_json(filepath)
106
+ self.current_filepath = filepath
107
+
108
+ @staticmethod
109
+ def _tree_to_json(node):
110
+ """Dataset の参照を dataset_id の文字列にする(JSON にするため)。"""
111
+ if 'dataset' in node:
112
+ return {'dataset_id': node['dataset'].dataset_id}
113
+ return {
114
+ 'name': node.get('name', ''),
115
+ 'children': [ProjectModel._tree_to_json(child) for child in node.get('children', [])],
116
+ }
117
+
118
+ @staticmethod
119
+ def _tree_from_json(node, dataset_map):
120
+ """_tree_to_json() の逆。見つからない ID は警告して除く(壊れたファイルでも読めるように)。"""
121
+ if 'dataset_id' in node:
122
+ ds = dataset_map.get(node['dataset_id'])
123
+ if ds is None:
124
+ logger.warning(
125
+ "dataset_group_tree内に存在しないdataset_idがあるため、"
126
+ "このリーフをスキップします: %s", node['dataset_id']
127
+ )
128
+ return None
129
+ return {'dataset': ds}
130
+
131
+ children = []
132
+ for child in node.get('children', []):
133
+ converted = ProjectModel._tree_from_json(child, dataset_map)
134
+ if converted is not None:
135
+ children.append(converted)
136
+ return {'name': node.get('name', ''), 'children': children}
137
+
138
+ def content_fingerprint(self):
139
+ """保存すると書き出される内容のハッシュ(未保存の変更の判定)。
140
+
141
+ Undo の clean 状態では判定しない(軸の設定・列の計算・フォルダ操作は Undo の対象外)。
142
+ 呼ぶ前に UI にしか無い状態を反映しておくこと(PlotterApp._sync_project_from_ui)。
143
+ """
144
+ payload = self._json_payload()
145
+ # 編集対象のサブプロットを切り替えただけで保存の確認が出ないように
146
+ payload.pop('active_axis_index', None)
147
+ text = json.dumps(payload, cls=GraphicaJSONEncoder, sort_keys=True, ensure_ascii=False)
148
+ return hashlib.sha256(text.encode('utf-8')).hexdigest()
149
+
150
+ def _json_payload(self):
151
+ """.graphica に書き出す辞書(保存と content_fingerprint で共有)。"""
152
+ return {
153
+ 'format_version': CURRENT_FORMAT_VERSION,
154
+ 'datasets': [ds.to_dict() for ds in self.datasets],
155
+ 'dataset_group_tree': self._tree_to_json(self.dataset_group_tree),
156
+ 'all_plot_settings': self.all_plot_settings,
157
+ 'active_axis_index': self.active_axis_index,
158
+ 'layout_rows': self.layout_rows,
159
+ 'layout_cols': self.layout_cols,
160
+ 'layout_mode': self.layout_mode,
161
+ 'panel_labels_enabled': self.panel_labels_enabled,
162
+ 'share_x_axis': self.share_x_axis,
163
+ 'share_y_axis': self.share_y_axis,
164
+ }
165
+
166
+ def _save_project_json(self, filepath):
167
+ data = self._json_payload()
168
+ # 名前に日本語が多いので \uXXXX にせず読める形で残す
169
+ with open(filepath, 'w', encoding='utf-8') as f:
170
+ json.dump(data, f, cls=GraphicaJSONEncoder, indent=2, ensure_ascii=False)
171
+
172
+ def _load_project_json(self, filepath):
173
+ with open(filepath, 'r', encoding='utf-8') as f:
174
+ data = json.load(f)
175
+
176
+ data = _migrate_project_data(data)
177
+
178
+ self.datasets = [Dataset.from_dict(d) for d in data.get('datasets', [])]
179
+ dataset_map = {ds.dataset_id: ds for ds in self.datasets}
180
+
181
+ tree_data = data.get('dataset_group_tree')
182
+ if tree_data:
183
+ self.dataset_group_tree = self._tree_from_json(tree_data, dataset_map)
184
+ else:
185
+ # キーが無ければ、.pkl と同じく全部をルートに置く
186
+ self.dataset_group_tree = {
187
+ 'name': '', 'children': [{'dataset': ds} for ds in self.datasets]
188
+ }
189
+
190
+ self.all_plot_settings = data.get('all_plot_settings', [])
191
+ self.active_axis_index = data.get('active_axis_index', 0)
192
+ self.layout_rows = data.get('layout_rows', 1)
193
+ self.layout_cols = data.get('layout_cols', 1)
194
+ self.layout_mode = data.get('layout_mode', 'grid')
195
+ self.panel_labels_enabled = data.get('panel_labels_enabled', False)
196
+ self.share_x_axis = data.get('share_x_axis', False)
197
+ self.share_y_axis = data.get('share_y_axis', False)
@@ -0,0 +1,20 @@
1
+ """
2
+ プラグイン向けの公開の入口(プラグイン API 2.0)。
3
+
4
+ プラグインが本体から import してよいのは、ここと graphica.plugin.testing だけ。
5
+ 本体の内部(core / gui / models)は予告なく動くが、ここの名前はプラグイン API の版で守る。
6
+ """
7
+ from graphica.core.dataset import Dataset
8
+ from graphica.core.plugin_api import GraphicaPluginAPI
9
+ from graphica.core.plugin_context import PluginContext
10
+ from graphica.core.plugin_manifest import PLUGIN_API_VERSION
11
+ from graphica.core.plugin_types import AnalysisResult, PluginExecutionError
12
+
13
+ __all__ = [
14
+ "PLUGIN_API_VERSION",
15
+ "AnalysisResult",
16
+ "Dataset",
17
+ "GraphicaPluginAPI",
18
+ "PluginContext",
19
+ "PluginExecutionError",
20
+ ]
@@ -0,0 +1,57 @@
1
+ """
2
+ プラグインのテスト用の公開の入口。Graphica を起動せずに試せる。
3
+
4
+ from graphica.plugin.testing import FakeGraphicaPluginAPI, FakePluginContext, load_plugin_like_graphica
5
+ """
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+
10
+ from graphica.core.plugin_api import GraphicaPluginAPI, PluginManager
11
+ from graphica.core.plugin_install import PluginInstallError, install_plugin_zip
12
+ from graphica.core.plugin_testing import FakeGraphicaPluginAPI, FakePluginContext
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "FakeGraphicaPluginAPI",
17
+ "FakePluginContext",
18
+ "PluginInstallError",
19
+ "install_zip_like_graphica",
20
+ "load_plugin_like_graphica",
21
+ ]
22
+
23
+
24
+ def load_plugin_like_graphica(plugin_folder: str, work_dir: str | None = None) -> tuple[GraphicaPluginAPI, dict[str, Any]]:
25
+ """
26
+ 本体と同じ経路(manifest の検証 → import → register(api))でプラグインを読み込む。
27
+ 相対 import の誤りや api_version の不一致など、偽物の API では見つからない問題を確かめる。
28
+
29
+ Args:
30
+ plugin_folder (str): plugin.json と __init__.py のあるフォルダ。
31
+ work_dir (str | None): 写しを置く場所(pytest の tmp_path など)。省略時は一時フォルダ。
32
+ Returns:
33
+ tuple[GraphicaPluginAPI, dict]: 登録を受けた API と、読み込み結果
34
+ ({"name", "info", "error", "disabled"}。成功なら error は None)。
35
+ フック単位の失敗は api.registration_errors にある。
36
+ """
37
+ plugin_folder = os.path.normpath(plugin_folder)
38
+ name = os.path.basename(plugin_folder)
39
+ # 本体は探索フォルダの中のサブフォルダをすべてプラグインとみなすので、対象だけを写した場所で読む。
40
+ plugins_dir = tempfile.mkdtemp(prefix="graphica_plugins_", dir=work_dir)
41
+ shutil.copytree(plugin_folder, os.path.join(plugins_dir, name),
42
+ ignore=shutil.ignore_patterns("__pycache__"))
43
+ api = GraphicaPluginAPI()
44
+ records = PluginManager(plugins_dir).load_all(api)
45
+ return api, records[0]
46
+
47
+
48
+ def install_zip_like_graphica(zip_path: str, target_dir: str) -> str:
49
+ """
50
+ 本体の「プラグインをインストール」と同じ処理で zip を target_dir に展開する。
51
+
52
+ Returns:
53
+ str: インストールされたプラグインのフォルダ名。
54
+ Raises:
55
+ PluginInstallError: zip の中身が本体の期待する形でない。
56
+ """
57
+ return install_plugin_zip(zip_path, target_dir=target_dir)
File without changes
@@ -0,0 +1,42 @@
1
+ 時間_s,温度_degC
2
+ 0.0,90.18
3
+ 0.5,83.78
4
+ 1.0,79.74
5
+ 1.5,75.18
6
+ 2.0,69.18
7
+ 2.5,65.66
8
+ 3.0,62.96
9
+ 3.5,59.43
10
+ 4.0,56.63
11
+ 4.5,53.4
12
+ 5.0,51.95
13
+ 5.5,49.62
14
+ 6.0,47.11
15
+ 6.5,45.85
16
+ 7.0,43.72
17
+ 7.5,41.34
18
+ 8.0,40.62
19
+ 8.5,38.5
20
+ 9.0,38.39
21
+ 9.5,36.73
22
+ 10.0,35.63
23
+ 10.5,34.41
24
+ 11.0,34.71
25
+ 11.5,33.11
26
+ 12.0,32.24
27
+ 12.5,31.64
28
+ 13.0,31.58
29
+ 13.5,30.94
30
+ 14.0,30.48
31
+ 14.5,30.04
32
+ 15.0,30.65
33
+ 15.5,28.75
34
+ 16.0,28.34
35
+ 16.5,27.85
36
+ 17.0,28.42
37
+ 17.5,28.46
38
+ 18.0,27.48
39
+ 18.5,26.82
40
+ 19.0,26.63
41
+ 19.5,27.33
42
+ 20.0,27.22