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,82 @@
1
+ """プラグインの zip を展開してインストールする(取得はしない。信頼できる配布元のものだけを入れること)。"""
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import uuid
6
+ import zipfile
7
+
8
+ from graphica.core.app_paths import get_user_plugins_dir
9
+
10
+
11
+ class PluginInstallError(Exception):
12
+ """メッセージはそのまま利用者に見せる。"""
13
+
14
+
15
+ def _reject_unsafe_members(zf: zipfile.ZipFile) -> None:
16
+ # zip-slip: 正規化して .. で始まるか絶対パスなら、target_dir の外に書くので拒否する
17
+ for member in zf.namelist():
18
+ normalized = os.path.normpath(member)
19
+ if normalized.startswith("..") or os.path.isabs(normalized):
20
+ raise PluginInstallError(
21
+ f"安全でないパスを含むzipファイルです(不正なエントリ: '{member}')。"
22
+ )
23
+
24
+
25
+ def _find_plugin_root(staging_dir: str, zip_path: str) -> tuple[str, str]:
26
+ """(プラグイン本体のフォルダの絶対パス, 採用するフォルダ名)"""
27
+ if os.path.exists(os.path.join(staging_dir, "__init__.py")):
28
+ # __init__.py が zip の直下にある
29
+ name = os.path.splitext(os.path.basename(zip_path))[0]
30
+ return staging_dir, name
31
+
32
+ entries = [e for e in os.listdir(staging_dir) if os.path.isdir(os.path.join(staging_dir, e))]
33
+ if len(entries) == 1:
34
+ candidate = os.path.join(staging_dir, entries[0])
35
+ if os.path.exists(os.path.join(candidate, "__init__.py")):
36
+ # 1つのフォルダの中に __init__.py がある
37
+ return candidate, entries[0]
38
+
39
+ raise PluginInstallError(
40
+ "zip内に __init__.py を持つプラグインが見つかりませんでした。"
41
+ "プラグインフォルダそのもの、またはそのフォルダを1つだけ含むzipを指定してください。"
42
+ )
43
+
44
+
45
+ def install_plugin_zip(zip_path: str, target_dir: str | None = None) -> str:
46
+ """target_dir(省略時は get_user_plugins_dir())に入れ、フォルダ名を返す。失敗は PluginInstallError。"""
47
+ if target_dir is None:
48
+ target_dir = get_user_plugins_dir()
49
+
50
+ if not zipfile.is_zipfile(zip_path):
51
+ raise PluginInstallError(f"'{zip_path}' は有効なzipファイルではありません。")
52
+
53
+ # 展開先は target_dir と同じボリュームに作る(最後の os.replace() を原子的な改名にするため)。
54
+ # 展開先の直下には __init__.py が無いので、プラグインとして誤って見つかることはない
55
+ staging_dir = tempfile.mkdtemp(prefix=".tmp_install_", dir=target_dir)
56
+ try:
57
+ try:
58
+ with zipfile.ZipFile(zip_path) as zf:
59
+ _reject_unsafe_members(zf)
60
+ zf.extractall(staging_dir)
61
+ except zipfile.BadZipFile as e:
62
+ raise PluginInstallError(f"zipファイルの展開に失敗しました: {e}") from e
63
+
64
+ plugin_root, plugin_name = _find_plugin_root(staging_dir, zip_path)
65
+ final_path = os.path.join(target_dir, plugin_name)
66
+
67
+ stale_path = None
68
+ if os.path.exists(final_path):
69
+ # 上書きのときは、既存のものを先に退避して最後に消す
70
+ stale_path = f"{final_path}.old-{uuid.uuid4().hex}"
71
+ os.replace(final_path, stale_path)
72
+
73
+ # zip の直下に __init__.py があった場合、改名までの一瞬だけ展開先が半端なプラグインに見える。
74
+ # 1つのプロセスで入れる前提なので、そのままにしている
75
+ os.replace(plugin_root, final_path)
76
+
77
+ if stale_path is not None:
78
+ shutil.rmtree(stale_path, ignore_errors=True)
79
+
80
+ return plugin_name
81
+ finally:
82
+ shutil.rmtree(staging_dir, ignore_errors=True)
@@ -0,0 +1,67 @@
1
+ """
2
+ プラグインのマニフェスト(plugin.json)の読み込みと検証。
3
+
4
+ プラグイン API の版は "主番号.小番号"。壊す変更で主番号を、互換のある追加で小番号を上げる。
5
+ plugin.json の api_version は、主番号が本体と同じで小番号が本体以下なら読み込む
6
+ (使う機能がそろっている)。合わないプラグインは import する前に弾く。
7
+ entry_point キーは予約済みで未使用(常にパッケージの __init__.py の register(api) を呼ぶ)。
8
+ """
9
+ import json
10
+ import os
11
+ from typing import Any
12
+
13
+ PLUGIN_API_VERSION = "2.0"
14
+ PLUGIN_MANIFEST_FILENAME = "plugin.json"
15
+
16
+ _REQUIRED_MANIFEST_KEYS = ("name", "version", "api_version")
17
+
18
+
19
+ class PluginManifestError(Exception):
20
+ """plugin.json が無い・壊れている・api_version が合わない(呼び出し側はそのプラグインを飛ばす)。"""
21
+
22
+
23
+ def load_plugin_manifest(plugin_dir: str) -> dict[str, Any]:
24
+ """plugin_dir/plugin.json を辞書で返す。不正なら PluginManifestError。"""
25
+ manifest_path = os.path.join(plugin_dir, PLUGIN_MANIFEST_FILENAME)
26
+ if not os.path.exists(manifest_path):
27
+ raise PluginManifestError(
28
+ f"{PLUGIN_MANIFEST_FILENAME} が見つかりません(プラグインディレクトリ直下に必須です)。"
29
+ )
30
+
31
+ try:
32
+ with open(manifest_path, encoding='utf-8') as f:
33
+ manifest = json.load(f)
34
+ except (json.JSONDecodeError, OSError) as e:
35
+ raise PluginManifestError(f"{PLUGIN_MANIFEST_FILENAME} の読み込みに失敗しました: {e}") from e
36
+
37
+ if not isinstance(manifest, dict):
38
+ raise PluginManifestError(f"{PLUGIN_MANIFEST_FILENAME} はJSONオブジェクトである必要があります。")
39
+
40
+ missing_keys = [k for k in _REQUIRED_MANIFEST_KEYS if k not in manifest]
41
+ if missing_keys:
42
+ raise PluginManifestError(
43
+ f"{PLUGIN_MANIFEST_FILENAME} に必須キーがありません: {', '.join(missing_keys)}"
44
+ )
45
+
46
+ if not is_compatible_api_version(manifest["api_version"]):
47
+ raise PluginManifestError(
48
+ f"api_version '{manifest['api_version']}' はサポート対象外です"
49
+ f"(このGraphicaのプラグインAPIは '{PLUGIN_API_VERSION}'。主番号が同じで、"
50
+ f"小番号がこれ以下のプラグインを読み込めます)。"
51
+ )
52
+
53
+ return manifest
54
+
55
+
56
+ def parse_api_version(text: object) -> tuple[int, int] | None:
57
+ """ "2.1" -> (2, 1)。形が違えば None。"""
58
+ parts = str(text).split(".")
59
+ if len(parts) != 2 or not all(p.isdigit() for p in parts):
60
+ return None
61
+ return int(parts[0]), int(parts[1])
62
+
63
+
64
+ def is_compatible_api_version(plugin_api_version: object, app_api_version: str = PLUGIN_API_VERSION) -> bool:
65
+ plugin = parse_api_version(plugin_api_version)
66
+ app = parse_api_version(app_api_version)
67
+ return plugin is not None and app is not None and plugin[0] == app[0] and plugin[1] <= app[1]
@@ -0,0 +1,213 @@
1
+ """
2
+ プラグイン作者向けのテスト用の偽物。本体(Qt・QApplication)を起動せずにテストできる。
3
+
4
+ api = FakeGraphicaPluginAPI()
5
+ my_plugin.register(api)
6
+ ctx = FakePluginContext(datasets=[ds])
7
+ api.menu_actions[0].callback(ctx)
8
+ assert ctx.messages == [("info", "My Plugin", "3 点")]
9
+
10
+ FakeGraphicaPluginAPI は本物の GraphicaPluginAPI と同じ公開メソッドを持ち、本物が拒む
11
+ 重複登録は同じく ValueError にする(偽物だけ通るとテストが通っても本体で失敗する)。
12
+ tests/test_plugin_api_contract.py が両者のシグネチャの一致を確かめている。
13
+ """
14
+ import tempfile
15
+
16
+ from graphica.core.color_palettes import normalize_palettes
17
+ from graphica.core.named_colors import add_named_color
18
+ from graphica.core.plugin_context import PluginContext
19
+ from graphica.core.plugin_types import PluginMenuAction
20
+ from typing import TYPE_CHECKING, Any, Callable
21
+ if TYPE_CHECKING:
22
+ from graphica.core.dataset import Dataset
23
+
24
+
25
+ class FakeGraphicaPluginAPI:
26
+ """
27
+ 登録内容を記録するだけの GraphicaPluginAPI。
28
+
29
+ Attributes:
30
+ menu_actions (list[PluginMenuAction]): 本物と同じ形。callback に FakePluginContext を渡して試せる。
31
+ panels (dict): 名前 -> {"widget_factory", "area"}。
32
+ そのほか fit_functions / importers / exporters / processors / analyzers / plot_types /
33
+ render_backends は、名前(または拡張子)をキーにした登録内容の辞書。
34
+ """
35
+
36
+ def __init__(self, plugin_name: str = "test_plugin") -> None:
37
+ self.plugin_name = plugin_name
38
+ self.fit_functions: dict[str, dict[str, Any]] = {}
39
+ self.menu_actions: list[PluginMenuAction] = []
40
+ self.importers: dict[str, dict[str, Any]] = {}
41
+ self.exporters: dict[str, dict[str, Any]] = {}
42
+ self.processors: dict[str, dict[str, Any]] = {}
43
+ self.analyzers: dict[str, dict[str, Any]] = {}
44
+ self.panels: dict[str, dict[str, Any]] = {}
45
+ self.plot_types: dict[str, dict[str, Any]] = {}
46
+ self.render_backends: dict[str, Any] = {}
47
+
48
+ def register_fit_function(self, name: str, func: Callable[..., Any], param_names: list[str],
49
+ p0: list[float] | Callable[..., Any] | None = None) -> None:
50
+ if name in self.fit_functions:
51
+ raise ValueError(f"フィット関数 '{name}' は既に登録されています。")
52
+ self.fit_functions[name] = {"func": func, "param_names": param_names, "p0": p0}
53
+
54
+ def register_menu_action(self, text: str, callback: Callable[..., Any], shortcut: str | None = None) -> None:
55
+ self.menu_actions.append(PluginMenuAction(
56
+ text=text, callback=callback, shortcut=shortcut, plugin_name=self.plugin_name,
57
+ ))
58
+
59
+ def register_importer(self, extensions: list[str], loader: Callable[[str], Any], *, name: str | None = None,
60
+ priority: int = 0) -> None:
61
+ # 本物は拡張子ごとに複数を優先度順で持つが、使える拡張子を確かめる用途にはこれで足りる。
62
+ for ext in extensions:
63
+ ext = ext.lower()
64
+ if not ext.startswith('.'):
65
+ ext = '.' + ext
66
+ self.importers[ext] = {"loader": loader, "name": name, "priority": priority}
67
+
68
+ def register_exporter(self, format_name: str, extension: str, writer: Callable[[Any, str], Any], *,
69
+ name: str | None = None) -> None:
70
+ self.exporters[format_name.lower()] = {"extension": extension, "writer": writer, "name": name}
71
+
72
+ def register_processor(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, category: str = "general",
73
+ param_schema: list[dict[str, Any]] | None = None) -> None:
74
+ if name in self.processors:
75
+ raise ValueError(f"データ処理 '{name}' は既に登録されています。")
76
+ self.processors[name] = {"fn": fn, "category": category, "param_schema": list(param_schema or [])}
77
+
78
+ def register_analyzer(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, output_kind: str = "table",
79
+ param_schema: list[dict[str, Any]] | None = None) -> None:
80
+ if name in self.analyzers:
81
+ raise ValueError(f"解析 '{name}' は既に登録されています。")
82
+ self.analyzers[name] = {"fn": fn, "output_kind": output_kind, "param_schema": list(param_schema or [])}
83
+
84
+ def register_panel(self, name: str, widget_factory: Callable[..., Any], *, area: str = "right") -> None:
85
+ if name in self.panels:
86
+ raise ValueError(f"パネル '{name}' は既に登録されています。")
87
+ self.panels[name] = {"widget_factory": widget_factory, "area": area}
88
+
89
+ def register_plot_type(self, type_name: str, drawer: Callable[..., Any], *, requires_2d: bool = False) -> None:
90
+ if type_name in self.plot_types:
91
+ raise ValueError(f"プロット種別 '{type_name}' は既に登録されています。")
92
+ self.plot_types[type_name] = {"drawer": drawer, "requires_2d": requires_2d}
93
+
94
+ def register_render_backend(self, name: str, backend: Any) -> None:
95
+ if name in self.render_backends:
96
+ raise ValueError(f"描画バックエンド '{name}' は既に登録されています。")
97
+ self.render_backends[name] = {"backend": backend}
98
+
99
+
100
+ class FakePluginContext(PluginContext):
101
+ """
102
+ メモリ上だけで動く PluginContext。本物と同じ検証をし、行った操作を記録する。
103
+
104
+ Args:
105
+ datasets (list[Dataset] | None): タブにあるデータセット。
106
+ current (Dataset | None): 選ばれているデータセット。
107
+ selected (list[Dataset] | None): 選択中のデータセット。省略時は current だけ。
108
+ plugin_name (str): show_message の既定のタイトル。
109
+ data_dir (str | None): 省略時は一時フォルダを作る。
110
+ named_colors (list[dict] | None) / color_palettes (dict | None) / color_cycle (list[str] | None):
111
+ 色の初期値。
112
+
113
+ Attributes:
114
+ messages (list[tuple]): 表示したメッセージ ("info" または "error", タイトル, 本文)。
115
+ undo_descriptions (list[str]): Undo 履歴に積まれた操作の説明。
116
+ redraw_count (int): redraw() が呼ばれた回数。
117
+ """
118
+
119
+ def __init__(self, datasets: "list[Dataset] | None" = None, current: "Dataset | None" = None,
120
+ selected: "list[Dataset] | None" = None, plugin_name: str = "test_plugin",
121
+ data_dir: str | None = None, named_colors: list[dict[str, str]] | None = None,
122
+ color_palettes: dict[str, list[str]] | None = None, color_cycle: list[str] | None = None) -> None:
123
+ self._datasets = list(datasets or [])
124
+ self._current = current
125
+ self._selected = list(selected) if selected is not None else ([current] if current else [])
126
+ self._plugin_name = plugin_name
127
+ self._data_dir = data_dir
128
+ self._named_colors = [dict(e) for e in (named_colors or [])]
129
+ self._palettes = normalize_palettes(color_palettes or {})
130
+ self._color_cycle = list(color_cycle or ["#1f77b4", "#ff7f0e", "#2ca02c"])
131
+ self._datasets_changed_callbacks: list[Callable[[], Any]] = []
132
+ self._selection_changed_callbacks: list[Callable[[Dataset | None], Any]] = []
133
+ self.messages: list[tuple[str, str, str]] = [] # ("info" / "error", タイトル, 本文)
134
+ self.undo_descriptions: list[str | None] = []
135
+ self.redraw_count = 0
136
+
137
+ def datasets(self) -> "list[Dataset]":
138
+ return list(self._datasets)
139
+
140
+ def current_dataset(self) -> "Dataset | None":
141
+ return self._current
142
+
143
+ def selected_datasets(self) -> "list[Dataset]":
144
+ return list(self._selected)
145
+
146
+ def add_dataset(self, dataset: "Dataset", description: str | None = None) -> None:
147
+ self._datasets.append(dataset)
148
+ self.undo_descriptions.append(description or f"[{self._plugin_name}] データセットの追加")
149
+ self.fire_datasets_changed()
150
+
151
+ def set_dataset_properties(self, dataset: "Dataset", values: dict[str, Any], description: str | None = None) -> None:
152
+ unknown = sorted(k for k in values if not hasattr(dataset, k))
153
+ if unknown:
154
+ raise AttributeError(f"Dataset に無い属性です: {', '.join(unknown)}")
155
+ for key, value in values.items():
156
+ setattr(dataset, key, value)
157
+ self.undo_descriptions.append(description or f"[{self._plugin_name}] プロパティの変更")
158
+ self.fire_datasets_changed()
159
+
160
+ def redraw(self) -> None:
161
+ self.redraw_count += 1
162
+
163
+ def on_datasets_changed(self, callback: Callable[[], Any]) -> None:
164
+ self._datasets_changed_callbacks.append(callback)
165
+
166
+ def on_selection_changed(self, callback: Callable[["Dataset | None"], Any]) -> None:
167
+ self._selection_changed_callbacks.append(callback)
168
+
169
+ def fire_datasets_changed(self) -> None:
170
+ """テスト用: データセットが変わったときの通知を送る。"""
171
+ for callback in list(self._datasets_changed_callbacks):
172
+ callback()
173
+
174
+ def select(self, dataset: "Dataset | None") -> None:
175
+ """テスト用: データセット一覧で dataset を選んだことにし、通知を送る。"""
176
+ self._current = dataset
177
+ self._selected = [dataset] if dataset is not None else []
178
+ for callback in list(self._selection_changed_callbacks):
179
+ callback(dataset)
180
+
181
+ @property
182
+ def parent_widget(self) -> Any:
183
+ return None
184
+
185
+ def show_message(self, text: str, title: str | None = None) -> None:
186
+ self.messages.append(("info", title or self._plugin_name, text))
187
+
188
+ def show_error(self, text: str, title: str | None = None) -> None:
189
+ self.messages.append(("error", title or self._plugin_name, text))
190
+
191
+ @property
192
+ def data_dir(self) -> str:
193
+ if self._data_dir is None:
194
+ self._data_dir = tempfile.mkdtemp(prefix=f"graphica_{self._plugin_name}_")
195
+ return self._data_dir
196
+
197
+ def named_colors(self) -> list[dict[str, str]]:
198
+ return [dict(e) for e in self._named_colors]
199
+
200
+ def set_named_colors(self, entries: list[dict[str, str]]) -> None:
201
+ validated: list[dict[str, str]] = []
202
+ for entry in entries:
203
+ validated = add_named_color(validated, entry.get("name"), entry.get("color"))
204
+ self._named_colors = validated
205
+
206
+ def color_palettes(self) -> dict[str, list[str]]:
207
+ return {name: list(colors) for name, colors in self._palettes.items()}
208
+
209
+ def set_color_palettes(self, palettes: dict[str, list[str]]) -> None:
210
+ self._palettes = normalize_palettes(palettes)
211
+
212
+ def active_color_cycle(self) -> list[str]:
213
+ return list(self._color_cycle)
@@ -0,0 +1,118 @@
1
+ """プラグイン API で共有する型。登録されたフックの記録と、失敗の表し方。"""
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Any, Callable
5
+
6
+
7
+ class PluginHookKind(Enum):
8
+ IMPORTER = "importer"
9
+ EXPORTER = "exporter"
10
+ PROCESSOR = "processor"
11
+ ANALYZER = "analyzer"
12
+ PANEL = "panel"
13
+ PLOT_TYPE = "plot_type"
14
+ FIT_FUNCTION = "fit_function"
15
+ MENU_ACTION = "menu_action"
16
+ RENDER_BACKEND = "render_backend"
17
+
18
+
19
+ @dataclass
20
+ class PluginRegistrationError:
21
+ """register_xxx の1回の呼び出しが失敗したこと。"""
22
+ plugin_name: str
23
+ hook_kind: PluginHookKind
24
+ message: str
25
+ exception: Exception | None = None
26
+
27
+
28
+ @dataclass
29
+ class PluginMenuAction:
30
+ text: str
31
+ callback: Callable[..., Any] # (PluginContext) -> None
32
+ shortcut: str | None
33
+ plugin_name: str
34
+
35
+
36
+ @dataclass
37
+ class PluginImporter:
38
+ extension: str # ピリオド付きの小文字(例: ".jdx")
39
+ loader: Callable[[str], Any] # (パス) -> pandas.DataFrame
40
+ name: str # エラー表示用(通常はプラグイン名)
41
+ priority: int = 0
42
+
43
+
44
+ @dataclass
45
+ class PluginExporter:
46
+ format_name: str
47
+ extension: str # ピリオド付きの小文字
48
+ writer: Callable[[Any, str], Any] # (matplotlib の Figure, 出力パス) -> None
49
+ name: str
50
+
51
+
52
+ @dataclass
53
+ class PluginProcessor:
54
+ name: str
55
+ fn: Callable[[Any, dict[str, Any]], Any] # (Dataset, 入力値) -> Dataset。元の Dataset は変えない
56
+ category: str
57
+ param_schema: list[dict[str, Any]] # 入力フォームの定義(register_processor の説明を参照)
58
+ plugin_name: str
59
+
60
+
61
+ @dataclass
62
+ class AnalysisResult:
63
+ """
64
+ register_analyzer の fn が返す解析結果。どれも省略できる。
65
+
66
+ Attributes:
67
+ table (pandas.DataFrame | None): 結果の表。CSV に保存できる形で表示する。
68
+ annotations (list[dict] | None): 現在の軸に追加する注釈(Undo できる)。
69
+ new_datasets (list[Dataset] | None): 追加するデータセット(Undo できる)。
70
+ """
71
+ table: Any = None
72
+ annotations: list[dict[str, Any]] | None = None
73
+ new_datasets: list[Any] | None = None
74
+
75
+
76
+ @dataclass
77
+ class PluginAnalyzer:
78
+ name: str
79
+ fn: Callable[[Any, dict[str, Any]], Any] # (Dataset, 入力値) -> AnalysisResult
80
+ output_kind: str # 表示上の分類だけ
81
+ param_schema: list[dict[str, Any]]
82
+ plugin_name: str
83
+
84
+
85
+ @dataclass
86
+ class PluginPanel:
87
+ name: str
88
+ widget_factory: Callable[..., Any] # (PluginContext) -> QWidget。タブごとに1回呼ぶ
89
+ area: str # "right"/"left"/"top"/"bottom"。Qt への対応付けは GUI 側(core は Qt に依存しない)
90
+ plugin_name: str
91
+
92
+
93
+ @dataclass
94
+ class PluginPlotType:
95
+ type_name: str
96
+ drawer: Callable[..., Any] # (Dataset, Axes, x, y) -> Artist | None
97
+ requires_2d: bool # 予約(動作に影響しない)
98
+ plugin_name: str
99
+
100
+
101
+ class RenderBackend:
102
+ """描画バックエンドの型の予約。描画には未接続で、持つべきメソッドも未定。"""
103
+
104
+
105
+ @dataclass
106
+ class PluginRenderBackend:
107
+ name: str
108
+ backend: "RenderBackend"
109
+ plugin_name: str
110
+
111
+
112
+ class PluginExecutionError(Exception):
113
+ """プラグインのフックの実行時の失敗。文字列にするとプラグイン名が付くので、そのまま表示に使える。"""
114
+
115
+ def __init__(self, plugin_name: str, message: str) -> None:
116
+ self.plugin_name = plugin_name
117
+ self.message = message
118
+ super().__init__(f"[{plugin_name}] {message}")
@@ -0,0 +1,19 @@
1
+ """派生データセットの由来(Dataset.provenance)。core/methods_text.py がここから「方法」文を作る。"""
2
+ from datetime import datetime, timezone
3
+ from typing import Any
4
+
5
+
6
+ def build_provenance(operation: str, params: dict[str, Any], source_datasets: list[Any]) -> dict[str, Any]:
7
+ """
8
+ Args:
9
+ operation (str): 操作の名前(methods_text の説明の引き当てに使う)。
10
+ params (dict): 操作の設定。pickle と JSON の両方で往復できるよう素の Python 型だけにする。
11
+ source_datasets (list[Dataset]): 元になったデータセット(複数なら合成)。
12
+ """
13
+ return {
14
+ 'operation': operation,
15
+ 'params': params,
16
+ 'source_dataset_ids': [ds.dataset_id for ds in source_datasets],
17
+ 'source_dataset_names': [ds.name for ds in source_datasets],
18
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
19
+ }
@@ -0,0 +1,51 @@
1
+ """グラフの画像と処理の「方法」の文をまとめたレポート(HTML)。GUI には依存しない。"""
2
+ import base64
3
+ import datetime
4
+ import html
5
+
6
+ from graphica.core.methods_text import generate_methods_text
7
+ from typing import TYPE_CHECKING
8
+ if TYPE_CHECKING:
9
+ from graphica.models.project import ProjectModel
10
+
11
+
12
+ def collect_methods_sections(project: "ProjectModel") -> list[tuple[str, str]]:
13
+ """履歴を持つデータセットごとの (名前, 方法の文)。元データは含めない。"""
14
+ return [
15
+ (ds.name, generate_methods_text(ds, project))
16
+ for ds in project.datasets
17
+ if ds.provenance
18
+ ]
19
+
20
+
21
+ def generate_html_report(image_png_bytes: bytes, methods_sections: list[tuple[str, str]], title: str = "") -> str:
22
+ """画像を base64 で埋め込んだ1ファイルで完結する HTML。title が空なら既定のタイトル。"""
23
+ title_text = title.strip() or "実験レポート"
24
+ title_html = html.escape(title_text)
25
+ generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
26
+ image_b64 = base64.b64encode(image_png_bytes).decode('ascii')
27
+
28
+ if methods_sections:
29
+ items = "".join(
30
+ f"<li><b>{html.escape(name)}</b>: {html.escape(text)}</li>"
31
+ for name, text in methods_sections
32
+ )
33
+ methods_html = f"<ul>{items}</ul>"
34
+ else:
35
+ methods_html = "<p>(処理履歴を持つデータセットはありません)</p>"
36
+
37
+ return (
38
+ "<!doctype html>\n"
39
+ "<html><head><meta charset=\"utf-8\">"
40
+ f"<title>{title_html}</title>\n"
41
+ "<style>\n"
42
+ "body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }\n"
43
+ "img { max-width: 100%; border: 1px solid #ccc; }\n"
44
+ "</style></head><body>\n"
45
+ f"<h1>{title_html}</h1>\n"
46
+ f"<p>生成日時: {generated_at}</p>\n"
47
+ f"<img src=\"data:image/png;base64,{image_b64}\" alt=\"plot\">\n"
48
+ "<h2>方法</h2>\n"
49
+ f"{methods_html}\n"
50
+ "</body></html>\n"
51
+ )