diff --git a/AGENTS.md b/AGENTS.md
index 45186fd..800add7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# matplotlib GUI 引き継ぎ資料(AI エージェント向け)
要件の正は `docs/REQUIREMENTS.md`。このファイルと食い違う場合は REQUIREMENTS.md を優先する。
-この資料は Phase 1(基盤の刷新と不具合修正)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。
+この資料は Phase 2(描画経路のコード生成化と Pythonコードタブの刷新)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。
## 1. ゴールと前提
@@ -11,6 +11,7 @@
- 読み込んだデータは外部に送信しない。外部への通信はライブラリとフォントの取得だけ。
- 列の指定は列番号(`__idx__N`)で行う。同名の列があっても区別できる。
- 自動読込・自動描画を保つ(「読み込む」「描画する」ボタンは置かない)。
+- 図は、設定から生成した Python スクリプトの実行だけで描く(描画経路は1本)。画面の図と、書き出した `.py` の結果は一致する。
- データ確認タブは、描画から除外される先頭行(`skipRows`)をグレー(`.skipped-row`)で表示する。
- CDN の URL はすべてバージョン固定にする。`tests/unit/test_cdn_pinning.py` が `index.html`・`js/`・`css/`・`py/` の全 URL を走査して強制する。
@@ -23,22 +24,27 @@
| `css/style.css` | スタイル |
| `js/main.js` | エントリ。UI を設定オブジェクトに結び付ける |
| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`)、色パレット(UD カラー) |
+| `js/code-state.js` | Pythonコードタブの状態(モード `sync` / `edit`、直近に生成したコードとその世代)。設定オブジェクトには入れない |
| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`subscribe`、`toJson`) |
| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存の制御、世代管理、フォント取得の起動 |
| `js/startup-progress.js` | Python 起動中の段階表示。Resource Timing(`PerformanceObserver`、取得済みエントリを含む)から Pyodide 本体(`pyodide.asm.wasm`・`python_stdlib.zip`)→ パッケージ wheel(pandas / matplotlib)→ 初期化(`/py/main.py` の取得)を推定する |
| `js/font-cache.js` | 日本語フォントの取得と Cache Storage への保存 |
-| `js/ui/*.js` | 画面部品(`colorPicker`、`customCode`、`dataPreview`、`fileInfo`、`forms`、`notify`、`plotView`、`progress`、`saveFormat`、`series`、`tabs`) |
-| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録、Excel 用ライブラリの遅延導入) |
+| `js/ui/codeTab.js` | Pythonコードタブ(行番号、モード別のボタン、コピー、.py 保存、Ctrl/Cmd+Enter) |
+| `js/ui/codeOutput.js` | 「実行結果」欄(`#codeOutput`)。print の出力とトレースバックを表示する |
+| `js/ui/*.js`(上記以外) | 画面部品(`colorPicker`、`dataPreview`、`fileInfo`(選択中のファイル名と文字コードの表示)、`forms`、`notify`、`plotView`(画像と `data-summary`)、`progress`、`saveFormat`、`series`、`tabs`) |
+| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録、Excel 用ライブラリの遅延導入、作業フォルダ `api.SESSION.workdir` の設定) |
| `py/mplgui/settings.py` | 設定スキーマ、既定値、検証、日本語エラー(凍結 dataclass へ変換) |
-| `py/mplgui/loader.py` | バイト列から DataFrame(文字コード判定、区切り文字、プレビュー) |
-| `py/mplgui/plotting.py` | 描画本体(暫定。Phase 2 で codegen に置き換える) |
-| `py/mplgui/runner.py` | Figure の生成と、画像(PNG/JPG/SVG/PDF)への書き出し、カスタムコードの実行 |
-| `py/mplgui/fonts.py` | 日本語フォントの登録と rcParams |
+| `py/mplgui/loader.py` | バイト列から DataFrame(文字コード判定、区切り文字、プレビュー)。読込結果に `SourceInfo`(ファイル名、種別、文字コード、区切り文字、ヘッダの有無、xlsx のシート名)を付ける。codegen が生成スクリプトの読込部に使う |
+| `py/mplgui/dataprep.py` | DataFrame を見て決める処理。`plan_plot` が描画計画 `PlotPlan` を作る(Y 列の自動割り当て、X の数値変換の要否、描ける点の有無、A7 の警告、データ由来の日本語 `UserError`) |
+| `py/mplgui/codegen.py` | 設定・`SourceInfo`・`PlotPlan` から matplotlib スクリプトを生成する(`generate_script`)。純粋な関数 |
+| `py/mplgui/formats.py` | 保存形式の表(`FORMATS`)、保存解像度(`SAVE_DPI` = 120)、`build_filename`、`savefig_kwargs`。runner と codegen が共有する |
+| `py/mplgui/runner.py` | スクリプトの実行(`run_script` / `run_to_image`)、Figure → 画像(PNG/JPG/SVG/PDF)、図の要約(`figure_summary`)、実行時エラーの整形 |
+| `py/mplgui/fonts.py` | 日本語フォントの登録と rcParams。フォント一覧 `SANS_SERIF_PRIORITY` が唯一の定義で、生成スクリプトの rcParams もこれを使う |
| `py/mplgui/errors.py` | `UserError`(利用者が直せるエラー) |
-| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数 |
-| `py/mplgui/runtime.py` | 警告フィルタ |
+| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数。読込済みデータ(`SESSION`)と、アップロードしたファイルの作業フォルダへの書き出しを持つ |
+| `py/mplgui/runtime.py` | 警告フィルタ(起動時) |
| `tests/unit/` | 単体テスト(CPython、Agg) |
-| `tests/e2e/` | E2E テスト(pytest-playwright、Chromium) |
+| `tests/e2e/` | E2E テスト(pytest-playwright、Chromium)。`run_script_summary.py` は、生成スクリプトを CPython で実行して図の要約を出す補助(一致確認用) |
| `tests/fixtures/` | 合成テストデータ(`make_fixtures.py` が生成) |
| `tests/perf/bench.py` | 起動・描画の計測。結果は `docs/PERFORMANCE.md` |
| `.github/workflows/tests.yml` | CI |
@@ -53,7 +59,7 @@
- JS の既定値(`js/defaults.js`)と Python の既定値(`settings.default_settings()`)は一致させる。`tests/unit/test_defaults_parity.py` が比較する(node が無いと skip)。
- 主な構造: `load`(`delimiter`、`hasHeader`)、`plot`(`type`、`skipRows`、`xColumn`、`title`、`fontSize`、`figure`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`)、`series[]`、`save`(`filename`、`format`、`transparent`)。
- 系列: `id`、`x`、`y`、`color`、`lineWidth`、`lineStyle`、`markerSize`、`label`、`secondaryAxis`。`markerSize: null` は「自動」(line は 0、scatter は 24)。ユーザーが数値を入れたら自動切替の対象外。
-- カスタムコードの本文と「使用する」は設定オブジェクトには入れず、`render` / `save` の別引数で渡す。
+- Pythonコードタブの状態(モード `sync` / `edit`、生成したコード)は設定オブジェクトに入れず、`js/code-state.js` に持つ。スキーマは version 1 のまま。編集モードのコード本文は、`render` / `save` の別引数 `code` で渡す。「使用する」(`useCustomCode`)は廃止した。
## 4. Python ⇄ JS の API(`window.mplgui`)
@@ -63,17 +69,48 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数
|---|---|---|
| `ensureExcel()` | なし(非同期) | `{ok}`。初回の .xlsx 読込前に openpyxl を導入する |
| `loadFile(name, bytes, loadJson)` | ファイル名、内容、`{version, load}` | `{encoding, columns, preview, warnings}`(.xlsx は `encoding: null`) |
-| `render(settingsJson, customCode)` | 設定 JSON、コード文字列または null | `{image, seriesCount, skipRows, warnings}`(`image` は PNG の data URI) |
-| `save(settingsJson, customCode)` | 同上 | `{filename, mime, dataUri}` |
+| `render(settingsJson, code)` | 設定 JSON、`code`(null または文字列) | `code` が null: `{image, code, output, summary, seriesCount, skipRows, warnings}`。文字列: `{image, output, summary}` |
+| `save(settingsJson, code)` | 同上 | `{filename, mime, dataUri, output}` |
+| `scriptFilename(saveFilename)` | 保存ファイル名の入力値 | 「.py で保存」のファイル名(文字列)。保存ファイル名と同じ規則(`formats.build_filename`)で拡張子を `.py` にする |
| `registerFont(bytes)` | フォントのバイト列 | `{registered}` |
| `fontStatus()` | なし | `{registered}` |
-| `defaultCustomCode()` | なし | 文字列(JSON ではない) |
+`defaultCustomCode()` は廃止した(Phase 2)。
+
+- `code` が null(GUI 同期): 設定から生成したスクリプトの「自動描画用」(§4.1)を、読込済みの DataFrame(`df`)を渡して実行する。`image` は PNG の data URI、`code` は表示用の完全なスクリプト、`output` は実行中の print / stderr、`summary` は `runner.figure_summary` の軸ごとの要約。読込済みデータが必要。`save` は保存設定の形式・背景透過・`SAVE_DPI`(120)で書き出す(`render` のプレビューは dpi 100)。
+- `code` が文字列(編集モード): そのスクリプトを丸ごと、アップロードしたファイルのある作業フォルダ(`SESSION.workdir`)で実行する。`render` は設定を読まない(設定が不正でも、読込済みデータが無くても動く)。`save` は保存設定(`save.*`)だけを `settings.parse_save_settings` で検証して使う(描画設定が不正でも、編集したコードの図は保存できる)。GUI の設定をコードの結果に上書き適用することはしない(A8)。
+- `loadFile` は、読み込んだファイルを元のファイル名(フォルダ部分は除く)で作業フォルダに書き出す(前のファイルは消す。失敗したら読込済みデータと書き出したファイルを破棄する)。こうして、表示中のスクリプトの `pd.read_csv("ファイル名", ...)` がそのまま動く(P5)。
- `warnings` は `{series, message}` の配列(読込時は文字列配列の場合もある。JS の `warningTexts` が吸収する)。
-- 失敗: `{ok: false, error: {message, field?, detail?}}`。
+- 失敗: `{ok: false, error: {message, field?, detail?, traceback?, line?}, output?}`。
- 利用者が直せる原因は `UserError`。`message` は日本語で、どの項目が悪いかを含める(`field`)。
- それ以外の例外は内部エラー。短い日本語メッセージ(`INTERNAL_ERROR_MESSAGE`)に、`detail` として traceback を付ける(画面の「内部エラー詳細」に出る)。
-- `loadFile` が失敗したら読込済みデータを破棄する。`render` は読込済みデータが無いと「先にファイルを読み込んでください。」を返す。
+ - スクリプトの実行時エラーは `runner.ScriptError`。`message` は日本語の要約(編集モードは「N行目、エラーの型: 日本語の説明」入り)、`traceback` は `plot.py` の行だけのトレースバック、`line` は最後の `plot.py` の行番号、`detail` は完全な traceback。トップレベルの `output` は、失敗するまでの print の出力(`output` 属性を持つ例外だけ)。
+ - GUI 同期で生成コードの実行が失敗したときは、行番号から失敗した手順(`GeneratedScript.step_for_line`)を引き、手順ごとの日本語メッセージ(`field` 付き)に置き換える(`api._step_error`)。手順に当たらなければ `GENERIC_ERROR_MESSAGE`。
+- `render` は読込済みデータが無いと(GUI 同期のとき)「先にファイルを読み込んでください。」を返す。
+
+## 4.1 コード生成(Phase 2)
+
+描画経路は1本: 設定 → `dataprep.plan_plot`(データを見て決める処理と検査)→ `codegen.generate_script`(スクリプトの文字列)→ `runner`(実行して Figure を得て画像にする)。GUI の図と、書き出す `.py` は同じコードから作る。
+
+- `dataprep.plan_plot(df, settings)`: `skipRows` の適用、Y 列の自動割り当て、X の数値変換の要否、描ける点があるか、数値に変換できず除外した値の警告(A7)を決める。データ由来の問題は日本語の `UserError`(列が範囲外、除外行数が多すぎる、描ける数値データが無い、など)にする。結果は凍結 dataclass の `PlotPlan` / `SeriesPlan`。
+- `codegen.generate_script(settings, source, plan)` は `GeneratedScript`(`text`、`load_lines`、`output_lines`、`steps`)を返す。純粋な関数で、データには触れない。
+- スクリプトの構成: ヘッダ(使い方、`import`、日本語フォントの rcParams、`FONT_SIZE`)→ `1. データの読み込み`(`DATA_FILE`、`pd.read_csv` / `pd.read_excel`。文字コード、区切り文字、ヘッダ、シート名を明記)→ `2. 描画に使う行`(`skipRows`)→ `3. 図と軸`(`fig, ax`)→ `4. 系列` → `5. 軸(ラベル・スケール・範囲・目盛)` → `6. グリッドと凡例` → `7. 余白` → `8. 保存と表示`(`fig.savefig(...)` と `plt.show()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。
+- 変数名は、`df`(データ)、`fig` / `ax` / `ax2`(第2Y軸)、系列ごとの `x` / `y` / `ok`(欠けた行を除くマスク)、棒グラフ(複数系列)の `bar_data` / `positions` / `x_labels` / `width`。列は常に `df.iloc[:, N]`(列番号)で参照し、GUI で選んだ列名と番号はコメントに書く(同名の列を区別できる)。列名はコードに入れない。
+- 自動描画用の変種(`GeneratedScript.auto_render_text`): 「データの読み込み」と「保存と表示」の行を空行にしたもの。行番号は表示中のスクリプトと同じなので、トレースバックの行番号がそのまま使える。GUI の自動描画は、これに読込済みの `df` を渡して実行する(ファイルを読み直さない)。
+- 手順(`Step`): 失敗しうる行の範囲と日本語メッセージ(`field` 付きのものもある)の対応表。系列の描画(`GENERIC_ERROR_MESSAGE`)、軸のスケール・範囲(X / Y / 第2Y)、`tight_layout`、`subplots_adjust`(余白)を登録している。
+- **安全性(必須)**: 列名・タイトル・ラベル・色・ファイル名・文字コードなどは信頼できない入力で、実行されるコードに入る。値は必ず `literal()`(str / int / 有限の float / bool / None だけを `repr` で出す。他の型は例外)を通して書き出し、コメントに入れる文字列は必ず `comment_text()`(改行・制御文字を空白にする)を通す。f 文字列などで、これ以外の方法で信頼できない文字列をコードに埋め込まないこと。`tests/unit/test_codegen.py` の注入テストが検査する。
+- GUI の設定を、ユーザーが編集したコードの結果に上書き適用しない(A8)。編集モードのコードは、設定を使わずそのまま実行する。
+- 機能追加(Phase 3〜5)の手順: 描画の種類は `SERIES_EMITTERS`(プロット種別 → 系列の出力関数)に追加する。出力関数は `_Builder` に行を足し、失敗しうる行は `with b.step(メッセージ, field)` で包む。軸・グリッド・余白などは対応する `_emit_*` 関数に足す。データを見ないと決められないこと(列の割り当て、変換の要否、警告、データ由来のエラー)は `dataprep` の `PlotPlan` / `SeriesPlan` に項目を足して渡す。設定項目を足すときは JS の `defaults.js` と `settings.py` の両方に足す。生成するコードは pandas / matplotlib / numpy だけを使う(ローカルの Python でそのまま動かすため)。新しい行を足したら、単体テストで「全体を実行した図」と「自動描画用の図」が一致することも確かめる。
+- 生成スクリプトの保存ファイル名(`savefig` の引数)は `formats.build_filename` で決める。保存形式と解像度は `formats.savefig_kwargs` / `SAVE_DPI` で、GUI の「保存」と同じにする。
+
+### runner(スクリプトの実行)
+
+- `run_script(code, injected=, cwd=)` はコンテキストマネージャ。`matplotlib.rc_context()` の中で実行し、スクリプトが変えた rcParams を次の実行に持ち越さない。`cwd` を指定すると実行中だけ作業フォルダを移す(終了後に戻す)。
+- stdout と stderr は1つのバッファに集めて `output` として返す。
+- 図の選び方: 名前空間の `fig`(`Figure` のとき)を優先し、無ければ実行中に新しく作られた最後の図。どちらも無ければ「図が作られませんでした。」(`UserError`)。
+- 実行中に作られた図は、`with` を抜けるときにすべて閉じる(呼び出し側は `with` の中で画像にする)。`linecache` の登録も消す。
+- 失敗: `compile` エラーも実行時の例外も `SystemExit`(`exit()`)も `ScriptError` にする。トレースバックは `plot.py` のフレームだけを残し、メッセージには日本語の説明(`japanese_hint`。インデント、文法、`NameError`、`FileNotFoundError`、`ImportError` など)を付ける。
+- `plt.show()` は Agg では何もせず警告を出すので、スクリプトの実行中だけ、この警告(`FigureCanvasAgg is non-interactive, and thus cannot be shown`)を無視する(§8)。
## 5. 描画パイプライン
@@ -81,7 +118,12 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数
- 起動中の `#progress`(`aria-live="polite"`)は段階表示: 「Python 実行環境を読み込み中…」→「ライブラリを読み込み中…(pandas ✓, matplotlib …)」→「ライブラリを初期化中…」→ 準備完了で消える(フォント取得の表示が続く場合あり)。`` の `data-startup-stage`(`runtime` / `packages` / `init` / `ready`)に現在の段階が入る。新しい id は無い。読込失敗の案内(`data-load-failed`)は上書きしない。
- 描画要求には世代番号を付ける。Python の描画は同時に最大1つ。実行中に新しい要求が来たら「やり直し」の印だけ付け、終了後に最新の設定で1回だけ描く(合流)。古い世代の結果は画像にもステータスにも反映しない。
- Python の起動前に選んだファイルや変えた設定は保持し、起動後に最新の内容で1回読み込んで描く(起動キュー)。
-- E2E が使う状態は `` の data 属性: `data-app-state`(`starting` / `ready` / `failed`)、`data-data-state`(`none` / `loading` / `ready` / `error`)、`data-render-state`(`idle` / `pending` / `rendering`)、`data-render-generation`、`data-font-state`(`idle` / `loading` / `ready` / `failed`)、`data-load-count`。表示中の画像は `#plotArea img` の `data-generation` を持つ。ステータスは `#status` の `data-kind`。
+- Pythonコードタブのモード(`js/code-state.js`):
+ - `sync`: GUI の設定から生成したスクリプトを表示し、描画に成功するたびに更新する。
+ - `edit`: 利用者が書き換えたコードを「実行」(Ctrl/Cmd+Enter)したときだけ描く。GUI の変更でも、ファイルの読込でも自動では描かず、コードも更新しない(ステータスに warning で「編集中のため反映されません」と出す)。読込は行い、ファイルは作業フォルダに置かれる。フォント取得後の描き直しも、実行したことがあるときだけ行う。
+ - 「編集」で `edit`、「GUI から再生成」で `sync` に切り替える。切り替えのたびに、予約中・実行中の描画を無効にする(世代を進め、結果は画像にもコードにもステータスにも反映しない)。このため `data-render-generation` は、モード切替の直後に、表示中の画像の世代より先に進んだままになることがある。E2E は `tests/e2e/helpers.py` の専用ヘルパー(`wait_code_generated`、`wait_image_generation_changed`、`run_edited_code`、`wait_run_finished`)で待つこと。
+ - `sync` の描画は読込済みデータが必要。`edit` は Python が動いていればよい(コードが自分でファイルを読む)。
+- E2E が使う状態は `` の data 属性: `data-app-state`(`starting` / `ready` / `failed`)、`data-data-state`(`none` / `loading` / `ready` / `error`)、`data-render-state`(`idle` / `pending` / `rendering`)、`data-render-generation`、`data-font-state`(`idle` / `loading` / `ready` / `failed`)、`data-load-count`。表示中の画像は `#plotArea img` の `data-generation` と `data-summary`(`figure_summary` の JSON)を持つ。ステータスは `#status` の `data-kind`。Pythonコードタブ用に、`` の `data-code-mode`(`sync` / `edit`)、`#customPyCode`(textarea)の `data-generation`(表示中のコードを生成した世代。GUI 同期のとき、同じ世代の画像と対応する)、`#codeOutput` の `data-kind`(`ok` / `error`)、`#codeSyncStatus` の `data-state`(`sync` / `edit`)がある。
## 6. 起動とフォント
@@ -96,15 +138,16 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数
## 7. 文字コード判定・数値変換警告・ステータス
- 文字コード(`loader.detect_encoding`): BOM 付きなら utf-8-sig → UTF-8 → CP932 と EUC-JP。両方でデコードできるときは、半角カナ・私用領域・制御文字などの「文字化けらしい文字」が少ないほうを採用し、同数なら CP932。どれでも読めなければ日本語のエラーにする。判定結果は `#encodingLabel` に表示する。
-- 数値に変換できず除外した値は、系列ごとに警告にする(`plotting.dropped_warning`)。
+- 数値に変換できず除外した値は、系列ごとに警告にする(`dataprep.dropped_warning`。`dataprep.plan_plot` が集める)。
- ステータス(`#status`)の種別は `ok` / `warning` / `error`。警告は成功メッセージに添えて表示し、`error` のときは `data-kind="error"`。日本語フォント取得失敗のような常時有効な警告は、以後の表示にも添える。
## 8. 警告フィルタ
-`runtime.configure_warnings` が無視するのは、次の2つだけ。それ以外の警告は無視しない(A10)。
+無視する警告は、次の3つだけ。それ以外の警告は無視しない(A10)。
-- pandas の pyarrow 関連の `DeprecationWarning`
-- matplotlib のグリフ不足の `UserWarning`
+- pandas の pyarrow 関連の `DeprecationWarning`(`runtime.configure_warnings`、起動時)
+- matplotlib のグリフ不足の `UserWarning`(`runtime.configure_warnings`、起動時)
+- `plt.show()` が Agg で出す `UserWarning`(`FigureCanvasAgg is non-interactive, and thus cannot be shown`)。生成スクリプトも `plt.show()` で終わるため、`runner._execute` の中だけ、`warnings.catch_warnings()` の範囲で無視する(スクリプトの実行が終われば元に戻る。グローバルには設定しない)。
## 9. parentNode エラー
@@ -120,6 +163,13 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー
```
- 新しい機能には単体テストを必ず追加する。
+- 単体テスト(`tests/unit/`)のうち Phase 2 で加えたもの:
+ - `test_codegen.py`: 生成したスクリプトを CPython で実際に実行し、期待する artist(線、点、棒、凡例、軸範囲、ラベルなど)を検査する。「全体を実行した図」と「自動描画用の変種に `df` を渡した図」の要約が一致することも確かめる。読込部が `loader` と同じ結果になること、列名・タイトルなどに悪意のある文字列を入れてもコードを注入できないこと(`literal` / `comment_text`)も検査する。
+ - `test_dataprep.py`: 描画計画(Y 列の自動割り当て、X の変換、除外値の警告、データ由来のエラー)。
+ - `test_runner.py`: `run_script`(出力の捕捉、図の選び方、図と `linecache` の後始末、rcParams の分離、作業フォルダ、`plt.show()` の警告、エラーの行番号・日本語の説明)と画像出力。
+ - `test_fonts.py`: フォント一覧が生成スクリプトと rcParams で共通であること。`test_api.py`: 新しい `render` / `save` / `loadFile`(作業フォルダへの書き出し)/ エラーの形。
+- E2E(`tests/e2e/`)のうち Phase 2 で加えたもの: `test_code_tab.py`(同期表示、GUI 変更への追従、コピー、.py 保存、編集・実行、編集中の GUI 変更が反映されないこと、「GUI から再生成」、Ctrl/Cmd+Enter、トレースバックと print の表示、編集モードでの保存、狭い画面)、`test_codegen_consistency.py`(下記)。
+- 生成コードの一致確認(要件 7 章): `test_codegen_consistency.py` が、ブラウザで設定した図(line / scatter / bar)の `.py` を実際にダウンロードし、`tests/fixtures` のデータと同じフォルダに置いて CPython で実行する(`run_script_summary.py` が `MPLBACKEND=Agg`、`PYTHONPATH=py` で実行して `figure_summary` を出力する)。その要約を、ブラウザの画像の `data-summary` と比べる(系列数、軸範囲、ラベル、凡例、スケール、棒グラフの目盛ラベル)。フォントは環境で違うので、画素やレイアウトは比べない。
- E2E は要素を id だけで選ぶ。`index.html` の id は安定させ、変えるときはテストも直す。アプリ固有の待機・操作は `tests/e2e/helpers.py` にまとめる。
- テストデータは `tests/fixtures/` の合成データだけ(公開リポジトリなので実データを置かない)。
- テスト用サーバー(`tests/e2e/server.py`)は listen backlog を 128 に広げている。既定の 5 だと、Chromium が ES モジュールを一斉に取得したときに接続が切れて、アプリが起動しないことがある。
@@ -137,3 +187,12 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー
- このリポジトリ配下のみを対象に作業する。リポジトリ外のファイルには触れない。
- push やプルリクエストの作成は、オーナーの確認を取ってから行う。
- ローカル環境のパスを、リポジトリ内の文書やコードに書かない。
+
+## 13. Phase 2 でオーナーが決めたこと(2026-10-08)
+
+- 旧「Pythonコード(beta)」タブの名前空間(`get_plot_data`、従来形式の settings dict、`plotted_count`)は、互換を残さずに廃止した。
+- 生成スクリプトの最後は `fig.savefig(...)`(GUI の「保存」と同じ形式・解像度)と `plt.show()`。
+- 編集は明示的な「編集」ボタンで始める。`useCustomCode`(「使用する」)は廃止した。編集中は GUI の変更で、コードも図も変わらない。
+- 棒グラフ(1系列・複数系列)は、カテゴリの目盛ラベルを保つ(Phase 1 の不具合を修正)。複数系列は、棒の位置にラベルを付ける(25個を超えたら間引く)。
+- line / scatter で X が数値でない列(文字列・日時)のとき、Phase 1 と同じ見た目を保つため、生成コードに `ax.set_xscale("linear")` を入れる(目盛は位置の番号になる)。D5(日付軸)で見直す(Phase 4)。
+- ファイル選択欄に、選んだファイル名を欄の中に表示する(`#fileNameLabel`。`input` は選択のたびに空に戻すため、JS が表示する)。
diff --git a/README.md b/README.md
index 6c60376..5a080ed 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,26 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html`
- 軸の対数表示と範囲指定、余白指定、主目盛線・副目盛線、凡例位置
- 保存: png / jpg / svg / pdf、背景透過
- データ確認タブ: 先頭100行を表示し、描画から除外される先頭行をグレーで表示
-- Pythonコード(beta)タブ: 描画用コードの確認と実行(Phase 2 で作り替え予定)
+- Pythonコードタブ: 設定から生成した matplotlib のスクリプトを表示(下記)
- 数値に変換できず除外した値の警告、日本語のエラーメッセージ
+## Pythonコードタブ
+
+図は、設定から生成した Python スクリプトの実行で描いています。「Pythonコード」タブには、そのスクリプト全体(データの読み込みから保存まで)が表示されます。
+
+- 設定を変えると、スクリプトも図と一緒に更新されます(「GUI と同期しています」)。
+- 「コピー」でクリップボードへコピーし、「.py で保存」でファイルに書き出せます。ファイル名は「保存設定」のファイル名に `.py` を付けたものです。
+- 「編集」を押すと、スクリプトを書き換えられます。「実行」(または Ctrl / Cmd + Enter)でそのコードを実行し、図を更新します。編集中は GUI の変更を図にもコードにも反映しません(「GUI と同期していません」)。「GUI から再生成」で、現在の設定から作ったコードに戻ります。
+- `print` の出力と、エラー時の行番号付きトレースバックは、タブ内の「実行結果」に表示されます。
+
+### 書き出した .py をローカルで実行する
+
+1. 書き出した `.py` を、データファイルと同じフォルダに置きます(スクリプトは元のファイル名でデータを読みます)。
+2. `pip install pandas matplotlib numpy` を実行します。`.xlsx` を読むときは `openpyxl` も入れます。
+3. `python plot.py` で実行します(ファイル名は書き出した名前に読み替えてください)。
+
+日本語フォントは、見つかったものを使います。無い場合も実行はできますが、日本語は豆腐(□)で表示されます。
+
## プライバシー
読み込んだデータの処理はすべてブラウザの中で行い、外部には送信しません。外部への通信は、ライブラリ(PyScript / Pyodide / pandas / matplotlib など)とフォントの取得だけです。通信先は次のとおりです。
diff --git a/css/style.css b/css/style.css
index 13946ed..3844322 100644
--- a/css/style.css
+++ b/css/style.css
@@ -159,28 +159,56 @@ button {
background: var(--card);
color: var(--text);
}
-input[type="file"] {
+.file-picker {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 10px;
padding: 6px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
background: var(--surface);
+ font-size: 14px;
}
-input[type="file"]::file-selector-button {
- border: none;
+.file-picker:focus-within {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.file-picker-btn {
+ flex: none;
border-radius: 6px;
padding: 8px 12px;
- margin-right: 10px;
background: var(--file-button-bg);
color: var(--file-button-text);
font-weight: 700;
- cursor: pointer;
+ white-space: nowrap;
+}
+.file-picker:hover .file-picker-btn {
+ background: var(--button-ghost-hover);
}
-input[type="file"]::-webkit-file-upload-button {
+.file-picker-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text);
+}
+.file-picker-name[data-selected="false"] {
+ color: var(--text-muted);
+}
+.file-picker input[type="file"] {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
border: none;
- border-radius: 6px;
- padding: 8px 12px;
- margin-right: 10px;
- background: var(--file-button-bg);
- color: var(--file-button-text);
- font-weight: 700;
+ border-radius: 8px;
+ opacity: 0;
+ cursor: pointer;
+}
+.file-picker input[type="file"]::file-selector-button {
cursor: pointer;
}
input[type="color"] {
@@ -463,11 +491,13 @@ button.secondary:hover {
margin-bottom: 8px;
font-size: 13px;
}
+#codePanel {
+ overflow: auto;
+}
.code-editor-wrap {
display: flex;
flex-direction: column;
gap: 10px;
- height: 100%;
min-height: 0;
}
.code-toolbar {
@@ -476,25 +506,106 @@ button.secondary:hover {
flex-wrap: wrap;
gap: 8px;
}
-.code-toolbar .inline {
- margin-right: auto;
-}
.code-toolbar button {
width: auto;
}
-#customPyCode {
- width: 100%;
- height: 100%;
- min-height: 360px;
- resize: vertical;
+.code-toolbar button[hidden] {
+ display: none;
+}
+.code-sync-badge {
+ margin-right: auto;
+ padding: 4px 10px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ background: var(--surface);
+}
+.code-sync-badge[data-state="edit"] {
+ color: var(--status-warning);
+ border-color: var(--status-warning);
+ background: var(--warning-bg);
+}
+.code-editor-box {
+ display: flex;
+ height: clamp(260px, 52vh, 560px);
+ min-height: 0;
border: 1px solid var(--line);
border-radius: 10px;
+ overflow: hidden;
+ background: var(--surface);
+}
+.is-edit .code-editor-box {
+ border-color: var(--status-warning);
+ box-shadow: 0 0 0 1px var(--status-warning);
+}
+.code-gutter,
+#customPyCode {
+ margin: 0;
+ padding: 12px 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 13px;
+ line-height: 20px;
+ white-space: pre;
+}
+.code-gutter {
+ flex: none;
+ min-width: 3.4em;
+ padding: 12px 8px 32px;
+ overflow: hidden;
+ text-align: right;
+ color: var(--table-rownum-text);
+ background: var(--table-rownum-bg);
+ border-right: 1px solid var(--line-soft);
+ user-select: none;
+}
+#customPyCode {
+ flex: 1;
+ min-width: 0;
+ width: auto;
padding: 12px;
+ border: none;
+ outline: none;
+ resize: none;
+ overflow: auto;
+ background: transparent;
+ color: var(--text);
+ tab-size: 4;
+}
+#customPyCode:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--accent);
+}
+.code-output-title {
+ margin: 6px 0 0;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-muted);
+}
+.code-output {
+ margin: 0;
+ max-height: 260px;
+ min-height: 44px;
+ overflow: auto;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
background: var(--surface);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- font-size: 13px;
+ font-size: 12px;
line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.code-output:empty::before {
+ content: "(出力はありません)";
+ color: var(--text-muted);
+}
+.code-output[data-kind="error"] {
+ border-color: var(--error-border);
+ background: var(--error-bg);
+ color: var(--error-text);
}
.help-text {
margin-top: 6px;
@@ -653,7 +764,7 @@ button.series-color-chip.active {
overflow: auto;
}
.layout {
- grid-template-columns: 1fr;
+ grid-template-columns: minmax(0, 1fr);
height: auto;
}
.panel {
@@ -676,3 +787,14 @@ button.series-color-chip.active {
flex: none;
}
}
+@media (max-width: 480px) {
+ .tabs {
+ gap: 6px;
+ }
+ .tab-btn {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 8px 6px;
+ font-size: 13px;
+ }
+}
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 334c603..8212281 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -111,3 +111,46 @@ Phase 1 で行った改善:
ベースラインとの比較では、起動(コールド・ウォーム)、再描画、保存はいずれも悪い。移行直後よりは改善したが、ベースラインには戻っていない。要件(8章)の「起動時間を F6 の現状値より悪くしない」は満たしていない。初回ファイル読込 → 最初のプロットは良い(上の注記のとおり参考値)。
**決定(2026-10-08、オーナー)**: ウォーム起動に残る差(約 +0.5 秒)は実行環境(Pyodide 314 / Python 3.14)自体のコストとして許容する。以後の要件8章の基準値は、この「Phase 1 後」の値とする(docs/REQUIREMENTS.md 8章に注記済み)。
+
+## Phase 2 後
+
+### 計測条件
+
+| 項目 | 内容 |
+|---|---|
+| 日付 | 2026-10-08 |
+| 対象 | `88beb50`(Phase 2: 設定 → `dataprep.plan_plot` → `codegen.generate_script` → `runner` が生成スクリプトを実行)に、編集モード実行の計測行を追加した `tests/perf/bench.py` を組み合わせたもの。PyScript 2026.7.3 / Pyodide 314.0.3 |
+| マシン | Mac16,10 / Apple M4 / メモリ 24 GB |
+| OS | macOS 27.0.1 |
+| ブラウザ | Chromium 153.0.8010.12(Playwright、ヘッドレス) |
+| ネットワーク | 実ネットワーク(ベースラインと同じ) |
+| 配信 | ローカルの静的 HTTP サーバー(127.0.0.1、サブパスなし) |
+| 反復 | 各項目 N=5。計測方法は Phase 1 後と同じ(描画系の項目は 10,000 行 × 系列 5 本) |
+
+追加した項目: 「編集モードでの実行(Ctrl/Cmd+Enter → プロット更新)」。同じ 10,000 行のファイルで、Pythonコードタブを編集モードにして `#customPyCode` で Ctrl/Cmd+Enter を押してから、新しい画像(`#plotArea img` の世代が進み、描画が idle)になるまで。ファイルを仮想 FS から読み直し、スクリプト全体を実行する。デバウンスは含まない。保存の計測の後に測るので、既存の項目には影響しない。Phase 1 後の値はない。
+
+### 結果(ミリ秒、中央値 / 最小 / 最大)
+
+| 項目 | Phase 1 後 | Phase 2 後 | Phase 1 後比 |
+|---|---:|---:|---|
+| コールド起動 | 3,650 / 3,593 / 6,581 | 3,607 / 3,578 / 6,407 | 同等(-1%) |
+| ウォーム起動 | 3,462 / 3,432 / 3,469 | 3,452 / 3,440 / 3,458 | 同等(-0%) |
+| 初回ファイル読込 → 最初のプロット | 186 / 184 / 187 | 178 / 175 / 181 | 良い(-4%) |
+| 設定変更 → プロット更新(デバウンス込み) | 474 / 469 / 476 | 466 / 460 / 477 | 同等(-2%) |
+| 同上(デバウンス 250ms 除く) | 224 / 219 / 226 | 216 / 210 / 227 | 同等(-4%、範囲が重なる) |
+| 保存(PNG、クリック → ダウンロード開始) | 257 / 246 / 262 | 246 / 211 / 253 | 同等(-4%、最小側にばらつき) |
+| 初回 .xlsx 読込 → 最初のプロット | 336 / 335 / 339 | 329 / 327 / 332 | 良い(-2%、範囲は重ならない) |
+| 編集モードでの実行(Ctrl/Cmd+Enter → プロット更新) | (計測なし) | 438 / 431 / 464 | - |
+
+コールド起動の最大値は1回目の反復のみ(キャッシュが冷えていたため)で、2回目以降は約3.6秒。
+
+### 要件の確認
+
+要件「設定変更後 1 秒以内の再描画(10,000 行 × 5 系列)」: 設定変更 → プロット更新は、デバウンス 250 ms を含めて中央値 466 ms(最大 477 ms)で、**満たしている**。編集モードの実行も中央値 438 ms(最大 464 ms)で 1 秒以内。
+
+### 分析
+
+Phase 1 後より悪化した項目はなかった。このため、悪化の内訳調査は行っていない。codegen 経由の描画(データ整形 → スクリプト生成 → 実行)は、Phase 1 の直接描画と同じ水準に収まっている。起動に変更はない(差は誤差の範囲)。
+
+編集モードの実行(438 ms)は、設定変更の描画(デバウンス除く 216 ms)より約 220 ms 長い。仮想 FS からのファイル再読込と、生成スクリプトの全体実行(読込・整形・描画)を含むためとみられるが、内訳は未計測。短縮が必要になったら、Pyodide 内で読込・コンパイル・実行・PNG 変換の時間を分けて測ることから始める。
+
diff --git a/index.html b/index.html
index 3693f77..65d7e56 100644
--- a/index.html
+++ b/index.html
@@ -48,8 +48,11 @@
描画中…
@@ -296,17 +299,22 @@
Matplotlib GUI
+
-
- 有効時はこのコードで描画します。df・settings・plt・pd が利用可能です。fig を必ず生成してください。Ctrl/Cmd + Enter でも再描画できます。
+ このコードは GUI の設定から自動で作られ、設定を変えると更新されます。「編集」を押すとコードを書き換えて実行できます(Ctrl/Cmd + Enter でも実行)。編集中は GUI の変更が図に反映されません。「GUI から再生成」で元に戻せます。「.py で保存」したファイルは、データファイルと同じフォルダに置けばローカルの Python でそのまま実行できます。
+
実行結果
+
diff --git a/js/bridge.js b/js/bridge.js
index bea9838..70ee820 100644
--- a/js/bridge.js
+++ b/js/bridge.js
@@ -3,9 +3,13 @@
// - 描画要求には世代番号を振る。同時に走る Python 描画は最大1つで、実行中の要求は「やり直し」の印だけ付け、
// 終わったあと最新の設定で1回だけ描画する。古い世代の結果は画像・ステータスに反映しない。
// - Python の起動前に選んだファイル・変えた設定は保持され、起動後に最新の内容で1回読込・描画する。
+// - Pythonコードタブのモード(code-state.js): sync は GUI の設定から生成したコードで描く。edit は利用者が編集したコードを
+// 「実行」したときだけ描き、GUI の変更では再描画しない(読込は行うが自動実行しない)。
import { subscribe, toJson, getSettings } from "./state.js";
import { addStickyWarning, setStatus, warningTexts } from "./ui/notify.js";
import { showPlot } from "./ui/plotView.js";
+import { showCodeOutput } from "./ui/codeOutput.js";
+import { isEditMode, setCodeMode, setGeneratedCode } from "./code-state.js";
import { applySkipRows, showPreview } from "./ui/dataPreview.js";
import { setColumns } from "./ui/series.js";
import { showEncoding, showFileName } from "./ui/fileInfo.js";
@@ -30,7 +34,8 @@ let renderDirty = false;
let loadSeq = 0;
let loadCount = 0;
let loadWarnings = [];
-let customCodeProvider = () => null; // 有効なカスタムコードの文字列、無効なら null
+let editCodeProvider = () => ""; // 編集モードのコード(Pythonコードタブの textarea の内容)
+let editHasRun = false; // 編集モードに入ってから「実行」したことがある(フォント取得後の描き直しの判断に使う)
let readyCallbacks = [];
let fontState = "idle"; // idle | loading | ready | failed
let fontPercent = null;
@@ -56,17 +61,26 @@ const setFontState = (state) => {
};
export const isPythonReady = () => api !== null;
-export const canAutoRender = () => dataReady;
-export const setCustomCodeProvider = (fn) => {
- customCodeProvider = fn;
+export const isDataReady = () => dataReady;
+export const setEditCodeProvider = (fn) => {
+ editCodeProvider = fn;
};
+const EDIT_MODE_MESSAGE =
+ "Pythonコードを編集中のため、GUI の変更は図に反映されません。「Pythonコード」タブの「GUI から再生成」で戻せます。";
+const PYTHON_NOT_READY_MESSAGE = "Python の起動が終わってから実行してください。";
+
+// 描画に渡すコード: GUI 同期なら null(設定から生成)、編集モードなら textarea の内容
+const currentCode = () => (isEditMode() ? String(editCodeProvider() || "") : null);
+// sync は読込済みデータが必要。edit は Python が動いていればよい(コードが自分でファイルを読む)
+const canRender = () => api !== null && (isEditMode() || dataReady);
+
const EXCEL_LOADING_MESSAGE = "Excel 読込用のライブラリを取得中…";
const callPython = (name, ...args) => JSON.parse(api[name](...args));
const reportError = (error) => {
- setStatus(error.message, "error", error.detail || "");
+ setStatus(error.message, "error", error.detail || error.traceback || "");
};
const reportException = (err, context) => {
const detail = `context: ${context}\n${err && err.stack ? err.stack : String(err)}`;
@@ -109,7 +123,7 @@ const startFont = () => {
try {
api.registerFont(bytes);
setFontState("ready");
- if (fontWaitGaveUp) renderNow(); // フォント無しで描いた図を、現在の設定で1回だけ描き直す(通常の合流処理・新しい世代)
+ if (fontWaitGaveUp && (!isEditMode() || editHasRun)) renderNow(); // フォント無しで描いた図を、現在の設定で1回だけ描き直す(通常の合流処理・新しい世代)
} catch (_) {
setFontState("failed");
addStickyWarning(FONT_FAILED_MESSAGE);
@@ -141,22 +155,31 @@ const waitForFont = async () => {
// ---- 描画 ----
const executeRender = async (generation) => {
- const stale = () => generation !== latestGeneration || !dataReady;
+ const stale = () => generation !== latestGeneration || !canRender();
try {
await waitForFont(); // 最初の描画だけがここで待つ(取得済みなら即通過)
if (stale()) return;
await nextPaint(); // 「描画中…」を先に描かせる(Python の実行は同期でメインスレッドを塞ぐ)
if (stale()) return;
- const result = callPython("render", toJson(), customCodeProvider());
+ const editing = isEditMode();
+ const result = callPython("render", toJson(), currentCode());
if (stale()) return;
if (result.ok) {
- showPlot(result.image, generation);
- const skipped = Number.isInteger(result.skipRows) ? result.skipRows : 0;
- setStatus(`描画に成功しました(${result.seriesCount}系列、スキップ${skipped}行)。`, "ok", "", [
- ...loadWarnings,
- ...warningTexts(result.warnings),
- ]);
+ showPlot(result.image, generation, result.summary);
+ showCodeOutput({ output: result.output, kind: "ok" });
+ if (!editing && typeof result.code === "string" && !isEditMode()) setGeneratedCode(result.code, generation);
+ if (Number.isInteger(result.seriesCount)) {
+ const skipped = Number.isInteger(result.skipRows) ? result.skipRows : 0;
+ setStatus(`描画に成功しました(${result.seriesCount}系列、スキップ${skipped}行)。`, "ok", "", [
+ ...loadWarnings,
+ ...warningTexts(result.warnings),
+ ]);
+ } else {
+ setStatus("コードの実行に成功しました。", "ok", result.output || "", loadWarnings);
+ }
} else {
+ const traceback = result.error && result.error.traceback ? result.error.traceback : "";
+ showCodeOutput({ output: result.output, traceback, kind: traceback ? "error" : "ok" });
reportError(result.error);
}
} catch (err) {
@@ -180,7 +203,7 @@ const runRenderLoop = async () => {
// 描画を要求する。実行中なら「やり直し」の印だけ付け、最新の設定で終了後に1回だけ走る。
const requestRender = () => {
- if (!api || !dataReady) return;
+ if (!canRender()) return;
latestGeneration += 1;
root.dataset.renderGeneration = String(latestGeneration);
if (renderRunning) {
@@ -195,13 +218,13 @@ export const scheduleRender = (delay = RENDER_DELAY_MS) => {
if (!renderRunning) setRenderState("pending");
renderTimer = window.setTimeout(() => {
renderTimer = null;
- if (api && dataReady) requestRender();
+ if (canRender()) requestRender();
else if (!renderRunning) setRenderState("idle");
}, delay);
};
export const renderNow = () => {
- if (api && dataReady) {
+ if (canRender()) {
if (renderTimer) {
window.clearTimeout(renderTimer);
renderTimer = null;
@@ -263,8 +286,12 @@ const doLoad = async () => {
setDataState("ready");
loadCount += 1;
root.dataset.loadCount = String(loadCount);
- setStatus("");
- renderNow();
+ if (isEditMode()) {
+ setStatus(EDIT_MODE_MESSAGE, "warning"); // 編集中は自動で実行しない(ファイルは作業フォルダに置かれている)
+ } else {
+ setStatus("");
+ renderNow();
+ }
} catch (err) {
if (seq === loadSeq) {
setDataState("error");
@@ -293,6 +320,54 @@ export const selectFile = (file) => {
scheduleLoad(LOAD_DELAY_MS.file);
};
+// ---- Pythonコードタブのモード切替・実行 ----
+// 予約済み・実行中の GUI 同期描画を無効にする(世代を進める。結果は画像にもコードにもステータスにも反映されない)
+const invalidatePendingRender = () => {
+ const pending = renderTimer !== null;
+ if (renderTimer) {
+ window.clearTimeout(renderTimer);
+ renderTimer = null;
+ }
+ if (pending || renderRunning) {
+ latestGeneration += 1;
+ root.dataset.renderGeneration = String(latestGeneration);
+ }
+ renderDirty = false;
+ setRenderState(renderRunning ? "rendering" : "idle");
+};
+
+export const enterEditMode = () => {
+ if (isEditMode()) return;
+ invalidatePendingRender();
+ editHasRun = false;
+ setCodeMode("edit");
+};
+
+// 「GUI から再生成」: 同期に戻し、現在の設定でコードと図を作り直す(データが無ければモードだけ戻す)
+export const leaveEditMode = () => {
+ if (!isEditMode()) return;
+ invalidatePendingRender();
+ setCodeMode("sync");
+ setStatus("");
+ if (api && dataReady) requestRender(); // 実行中の編集コードの結果は、世代が進むので捨てられる
+};
+
+// 編集モードのコードを実行する(同期モードでは GUI の設定で再描画する)
+export const runCode = () => {
+ if (!api) {
+ setStatus(PYTHON_NOT_READY_MESSAGE, "warning");
+ return;
+ }
+ if (isEditMode()) editHasRun = true;
+ renderNow();
+};
+
+// 「.py で保存」のファイル名。Python の formats.build_filename と同じ規則(Python が使えないときだけ既定名)
+export const scriptFilename = () => {
+ if (!api || typeof api.scriptFilename !== "function") return "plot.py";
+ return String(api.scriptFilename(getSettings().save.filename));
+};
+
const downloadDataUri = (filename, dataUri) => {
if (!filename || !dataUri) return;
const link = document.createElement("a");
@@ -307,7 +382,7 @@ export const saveNow = async () => {
if (!api) return;
try {
await waitForFont();
- const result = callPython("save", toJson(), customCodeProvider());
+ const result = callPython("save", toJson(), currentCode());
if (!result.ok) {
reportError(result.error);
return;
@@ -319,7 +394,6 @@ export const saveNow = async () => {
}
};
-export const getDefaultCustomCode = () => (api ? api.defaultCustomCode() : "");
export const onPythonReady = (fn) => {
if (api) fn();
else readyCallbacks.push(fn);
@@ -347,7 +421,8 @@ const routeChange = (_settings, change) => {
if (path.startsWith("load.")) {
scheduleLoad(path === "load.delimiter" ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header);
} else if (!path.startsWith("save.")) {
- scheduleRender();
+ if (isEditMode()) setStatus(EDIT_MODE_MESSAGE, "warning"); // 編集中は再描画もコードの更新もしない
+ else scheduleRender();
}
};
diff --git a/js/code-state.js b/js/code-state.js
new file mode 100644
index 0000000..f9d739e
--- /dev/null
+++ b/js/code-state.js
@@ -0,0 +1,38 @@
+// Pythonコードタブの状態。設定オブジェクト(state.js)には入れない(スキーマは version 1 のまま)。
+// mode: "sync"(GUI から生成したコードを表示。GUI の変更で更新)| "edit"(利用者が編集。GUI の変更では上書きしない)
+// generatedCode: 直近の GUI 同期の描画で得たスクリプト全体(generation はそのときの世代番号)
+// DOM には だけ反映する。
+const root = typeof document !== "undefined" ? document.documentElement : null;
+const listeners = new Set();
+const state = { mode: "sync", generatedCode: "", generation: null };
+
+if (root) root.dataset.codeMode = state.mode;
+
+const notify = (change) => {
+ for (const fn of Array.from(listeners)) fn({ ...state }, change);
+};
+
+export const getCodeMode = () => state.mode;
+export const isEditMode = () => state.mode === "edit";
+export const getGeneratedCode = () => state.generatedCode;
+export const getGeneratedGeneration = () => state.generation;
+
+export const subscribeCode = (fn) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+};
+
+export const setCodeMode = (mode) => {
+ if (mode !== "sync" && mode !== "edit") return;
+ if (state.mode === mode) return;
+ state.mode = mode;
+ if (root) root.dataset.codeMode = mode;
+ notify({ kind: "mode" });
+};
+
+// GUI 同期の描画が成功したときに呼ぶ。編集中(mode が "edit")は保持だけして、表示は更新しない(購読側が mode を見る)。
+export const setGeneratedCode = (code, generation) => {
+ state.generatedCode = code;
+ state.generation = generation;
+ notify({ kind: "generated" });
+};
diff --git a/js/main.js b/js/main.js
index ff548c9..1e3e8d5 100644
--- a/js/main.js
+++ b/js/main.js
@@ -1,9 +1,9 @@
// エントリ。DOM 構築後(module は defer)に UI を設定オブジェクトへ結び付ける。
// 画面の入力 → state.js(設定オブジェクト)→ bridge.js が JSON で Python を呼ぶ → 結果を ui/* が描く
import { subscribe } from "./state.js";
-import { getDefaultCustomCode, initBridge, onPythonReady, saveNow, selectFile } from "./bridge.js";
+import { initBridge, saveNow, selectFile } from "./bridge.js";
import { bindColorPanelOutsideClick } from "./ui/colorPicker.js";
-import { bindCustomCodeEvents, initDefaultCode } from "./ui/customCode.js";
+import { bindCodeTab } from "./ui/codeTab.js";
import { applyStateToForm, bindForms } from "./ui/forms.js";
import { bindSaveFormatEvents, syncSaveFormatOptions } from "./ui/saveFormat.js";
import { bindSeriesEvents, renderSeriesList, setColumns, syncMarkerSizeDisplay, syncVisibility } from "./ui/series.js";
@@ -24,7 +24,7 @@ bindTabs();
bindForms();
bindSeriesEvents();
bindSaveFormatEvents();
-bindCustomCodeEvents();
+bindCodeTab();
bindColorPanelOutsideClick();
// state の変更に応じた画面の更新(再描画・読込の予約は bridge.js が購読して行う)
@@ -41,4 +41,3 @@ applyStateToForm();
setColumns([]);
syncSaveFormatOptions();
initBridge();
-onPythonReady(() => initDefaultCode(getDefaultCustomCode()));
diff --git a/js/ui/codeOutput.js b/js/ui/codeOutput.js
new file mode 100644
index 0000000..954490a
--- /dev/null
+++ b/js/ui/codeOutput.js
@@ -0,0 +1,11 @@
+// 「実行結果」欄(#codeOutput)。print / stderr の出力と、失敗時のトレースバックを表示する。
+// 空のときは CSS(:empty)が「(出力はありません)」を出す。
+export const showCodeOutput = ({ output = "", traceback = "", kind = "ok" } = {}) => {
+ const el = document.getElementById("codeOutput");
+ if (!el) return;
+ const parts = [String(output || "").replace(/\s+$/, ""), String(traceback || "").replace(/\s+$/, "")].filter(Boolean);
+ const text = parts.join("\n\n");
+ el.dataset.kind = kind === "error" ? "error" : "ok";
+ if (text) el.textContent = text;
+ else el.replaceChildren();
+};
diff --git a/js/ui/codeTab.js b/js/ui/codeTab.js
new file mode 100644
index 0000000..a724819
--- /dev/null
+++ b/js/ui/codeTab.js
@@ -0,0 +1,156 @@
+// Pythonコードタブ。モード(code-state.js)に応じて、textarea・ボタン・同期表示を切り替える。
+// sync: 読み取り専用。GUI から生成したスクリプトを表示(描画の成功ごとに更新)。
+// edit: 編集可。GUI の変更では上書きしない。「実行」(Ctrl/Cmd+Enter)でそのコードを実行する。
+import { enterEditMode, isDataReady, leaveEditMode, runCode, scriptFilename, setEditCodeProvider } from "../bridge.js";
+import { getCodeMode, getGeneratedCode, getGeneratedGeneration, isEditMode, subscribeCode } from "../code-state.js";
+import { setStatus } from "./notify.js";
+
+const $ = (id) => document.getElementById(id);
+
+const SYNC_TEXT = "GUI と同期しています";
+const EDIT_TEXT = "GUI と同期していません";
+
+// 行番号の列。textarea と同じ行数だけ 1 から並べる(トレースバックの「line N」と対応させる)
+const updateGutter = () => {
+ const editor = $("customPyCode");
+ const gutter = $("codeLineNumbers");
+ if (!editor || !gutter) return;
+ const count = Math.max(1, editor.value.split("\n").length);
+ const text = Array.from({ length: count }, (_, i) => String(i + 1)).join("\n");
+ if (gutter.textContent !== text) gutter.textContent = text;
+ gutter.scrollTop = editor.scrollTop;
+};
+
+const showGenerated = () => {
+ const editor = $("customPyCode");
+ if (!editor) return;
+ editor.value = getGeneratedCode();
+ const generation = getGeneratedGeneration();
+ if (generation === null) delete editor.dataset.generation;
+ else editor.dataset.generation = String(generation);
+ updateGutter();
+};
+
+const applyMode = () => {
+ const edit = isEditMode();
+ const editor = $("customPyCode");
+ if (editor) {
+ editor.readOnly = !edit;
+ editor.setAttribute("aria-readonly", edit ? "false" : "true");
+ }
+ const wrap = document.querySelector(".code-editor-wrap");
+ if (wrap) wrap.classList.toggle("is-edit", edit);
+ const badge = $("codeSyncStatus");
+ if (badge) {
+ badge.textContent = edit ? EDIT_TEXT : SYNC_TEXT;
+ badge.dataset.state = edit ? "edit" : "sync";
+ }
+ // 現在のモードに当てはまるボタンだけを出す(コピー・.py で保存は常に)
+ const visible = {
+ editCodeBtn: !edit,
+ applyCustomCodeBtn: edit,
+ resetCustomCodeBtn: edit,
+ copyCodeBtn: true,
+ downloadCodeBtn: true,
+ };
+ for (const [id, show] of Object.entries(visible)) {
+ const btn = $(id);
+ if (btn) btn.hidden = !show;
+ }
+};
+
+const copyWithSelection = (editor) => {
+ const keepStart = editor.selectionStart;
+ const keepEnd = editor.selectionEnd;
+ editor.focus();
+ editor.select();
+ let ok = false;
+ try {
+ ok = document.execCommand("copy");
+ } catch (_) {
+ ok = false;
+ }
+ editor.setSelectionRange(keepStart, keepEnd);
+ return ok;
+};
+
+const copyCode = async () => {
+ const editor = $("customPyCode");
+ if (!editor) return;
+ const text = editor.value;
+ if (!text.trim()) {
+ setStatus("コピーするコードがありません。ファイルを読み込んでください。", "warning");
+ return;
+ }
+ let ok = false;
+ try {
+ await navigator.clipboard.writeText(text);
+ ok = true;
+ } catch (_) {
+ ok = copyWithSelection(editor);
+ }
+ if (ok) setStatus("Pythonコードをコピーしました。", "ok");
+ else setStatus("コピーできませんでした。コードを選択して、手動でコピーしてください。", "error");
+};
+
+const downloadCode = () => {
+ const editor = $("customPyCode");
+ if (!editor) return;
+ const text = editor.value;
+ if (!text.trim()) {
+ setStatus("保存するコードがありません。ファイルを読み込んでください。", "warning");
+ return;
+ }
+ try {
+ const name = scriptFilename();
+ const url = URL.createObjectURL(new Blob([text], { type: "text/x-python;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = name;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
+ setStatus(`Pythonコードを保存しました: ${name}`, "ok");
+ } catch (_) {
+ setStatus("Pythonコードを保存できませんでした。", "error");
+ }
+};
+
+export const bindCodeTab = () => {
+ const editor = $("customPyCode");
+ setEditCodeProvider(() => (editor ? editor.value : ""));
+
+ subscribeCode((_state, change) => {
+ if (change.kind === "mode") {
+ applyMode();
+ if (!isEditMode()) showGenerated(); // 「GUI から再生成」: 直近の生成コードをすぐ表示する
+ } else if (getCodeMode() === "sync") {
+ showGenerated(); // 編集中は GUI 由来のコードで上書きしない
+ }
+ });
+
+ $("editCodeBtn")?.addEventListener("click", () => {
+ enterEditMode();
+ if (editor) editor.focus();
+ });
+ $("applyCustomCodeBtn")?.addEventListener("click", () => runCode());
+ $("resetCustomCodeBtn")?.addEventListener("click", () => leaveEditMode());
+ $("copyCodeBtn")?.addEventListener("click", () => copyCode());
+ $("downloadCodeBtn")?.addEventListener("click", () => downloadCode());
+
+ if (editor) {
+ editor.addEventListener("keydown", (event) => {
+ if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return;
+ event.preventDefault();
+ if (isEditMode() || isDataReady()) runCode();
+ });
+ editor.addEventListener("input", updateGutter);
+ editor.addEventListener("scroll", () => {
+ const gutter = $("codeLineNumbers");
+ if (gutter) gutter.scrollTop = editor.scrollTop;
+ });
+ }
+ applyMode();
+ updateGutter();
+};
diff --git a/js/ui/customCode.js b/js/ui/customCode.js
deleted file mode 100644
index 574a9be..0000000
--- a/js/ui/customCode.js
+++ /dev/null
@@ -1,45 +0,0 @@
-// Pythonコード(beta)タブ。コード本文と「使用する」は設定オブジェクトには入れず、描画要求のたびに別引数で渡す。
-import { canAutoRender, renderNow, scheduleRender, setCustomCodeProvider } from "../bridge.js";
-
-const customCodeInput = () => document.getElementById("customPyCode");
-const useCustomCodeInput = () => document.getElementById("useCustomCode");
-
-// 有効時はコード文字列、無効時は null
-export const getCustomCode = () => {
- const use = useCustomCodeInput();
- const editor = customCodeInput();
- return use && use.checked && editor ? String(editor.value || "") : null;
-};
-
-// Python 起動後に既定コードを入れる(未入力のときだけ)
-export const initDefaultCode = (defaultCode) => {
- const editor = customCodeInput();
- if (!editor) return;
- editor.dataset.defaultCode = defaultCode;
- if (!String(editor.value || "").trim()) editor.value = defaultCode;
-};
-
-export const bindCustomCodeEvents = () => {
- setCustomCodeProvider(getCustomCode);
- const use = useCustomCodeInput();
- const editor = customCodeInput();
- const applyBtn = document.getElementById("applyCustomCodeBtn");
- const resetBtn = document.getElementById("resetCustomCodeBtn");
-
- if (use) use.addEventListener("change", () => scheduleRender(0));
- if (applyBtn) applyBtn.addEventListener("click", () => renderNow());
- if (resetBtn && editor) {
- resetBtn.addEventListener("click", () => {
- editor.value = editor.dataset.defaultCode || "";
- if (use) use.checked = false;
- if (canAutoRender()) renderNow();
- });
- }
- if (editor) {
- editor.addEventListener("keydown", (event) => {
- if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return;
- event.preventDefault();
- renderNow();
- });
- }
-};
diff --git a/js/ui/fileInfo.js b/js/ui/fileInfo.js
index ef8829d..268fa59 100644
--- a/js/ui/fileInfo.js
+++ b/js/ui/fileInfo.js
@@ -1,4 +1,6 @@
// 選択中のファイル名と検出した文字コードの表示(#fileNameLabel / #encodingLabel)。
+// #fileNameLabel はファイル選択ボックス(.file-picker)内の表示。input は選択のたびに空に戻すので、
+// ブラウザ標準の「選択されていません」には頼らず、JS がファイル名(未選択時は既定文言)を出す。
const ENCODING_NAMES = {
"utf-8": "UTF-8",
"utf-8-sig": "UTF-8(BOM付き)",
@@ -8,9 +10,15 @@ const ENCODING_NAMES = {
export const encodingDisplayName = (encoding) => (encoding === null || encoding === undefined ? "Excel" : ENCODING_NAMES[encoding] || String(encoding));
+const NO_FILE_TEXT = "選択されていません";
+
export const showFileName = (name) => {
const label = document.getElementById("fileNameLabel");
- if (label) label.textContent = name ? `選択中: ${name}` : "";
+ if (label) {
+ label.textContent = name || NO_FILE_TEXT;
+ label.title = name || "";
+ label.dataset.selected = name ? "true" : "false";
+ }
showEncoding(undefined);
};
diff --git a/js/ui/plotView.js b/js/ui/plotView.js
index aae1403..6fbb4d6 100644
--- a/js/ui/plotView.js
+++ b/js/ui/plotView.js
@@ -1,10 +1,11 @@
-// プロット画像の表示。
-export const showPlot = (dataUri, generation) => {
+// プロット画像の表示。summary(図の要約。runner.figure_summary)は E2E のために data-summary に JSON で持たせる。
+export const showPlot = (dataUri, generation, summary) => {
const plotArea = document.getElementById("plotArea");
if (!plotArea) return;
const img = document.createElement("img");
img.alt = "plot";
img.src = dataUri;
img.dataset.generation = String(generation);
+ if (summary !== undefined) img.dataset.summary = JSON.stringify(summary);
plotArea.replaceChildren(img);
};
diff --git a/js/ui/tabs.js b/js/ui/tabs.js
index f46887f..95d8324 100644
--- a/js/ui/tabs.js
+++ b/js/ui/tabs.js
@@ -1,4 +1,4 @@
-// 「プロット / データ確認 / Pythonコード(beta)」のタブ切替(WAI-ARIA tabs。矢印キーでも移動できる)。
+// 「プロット / データ確認 / Pythonコード」のタブ切替(WAI-ARIA tabs。矢印キーでも移動できる)。
const TABS = [
["plot", "tabPlotBtn", "plotPanel"],
["data", "tabDataBtn", "dataPanel"],
diff --git a/py/main.py b/py/main.py
index ad3c104..395f934 100644
--- a/py/main.py
+++ b/py/main.py
@@ -4,6 +4,7 @@
"""
import asyncio
import json
+from pathlib import Path
import matplotlib
@@ -22,6 +23,9 @@
configure_rcparams()
+# ブラウザ内で実行するスクリプトが、アップロードしたファイルを元のファイル名で読めるように、作業フォルダに置く
+api.SESSION.workdir = Path.cwd()
+
# openpyxl / et_xmlfile は Pyodide 同梱ではない。バージョン固定の wheel URL を Pyodide の loadPackage に直接渡す
# (micropip と PyPI の問い合わせを避ける)。URL は PyPI JSON API で確認した files.pythonhosted.org の正規 URL。
@@ -89,15 +93,19 @@ def _register_font(data):
return api.register_font(data.to_py())
+def _script_filename(save_filename):
+ return api.script_filename(save_filename if isinstance(save_filename, str) else "")
+
+
# create_proxy の参照を保持しておく(GC されると JS から呼べなくなる)
_PROXIES = {
"ensureExcel": create_proxy(_ensure_excel),
"loadFile": create_proxy(_load_file),
"render": create_proxy(_render),
"save": create_proxy(_save),
+ "scriptFilename": create_proxy(_script_filename),
"registerFont": create_proxy(_register_font),
"fontStatus": create_proxy(api.font_status),
- "defaultCustomCode": create_proxy(api.default_custom_code),
}
_API = Object.new()
for _name, _proxy in _PROXIES.items():
diff --git a/py/mplgui/api.py b/py/mplgui/api.py
index 62d1260..c19ed22 100644
--- a/py/mplgui/api.py
+++ b/py/mplgui/api.py
@@ -5,22 +5,21 @@
"""
from __future__ import annotations
+import base64
import json
import traceback
-
-import matplotlib.pyplot as plt
+from pathlib import Path
from . import fonts
+from .codegen import GENERIC_ERROR_MESSAGE, GeneratedScript, generate_script
+from .dataprep import plan_plot
from .errors import UserError
-from .loader import PREVIEW_ROWS, build_preview, column_options, load_file
-from .runner import (
- DEFAULT_CUSTOM_PLOT_CODE,
- build_filename,
- figure_to_data_uri,
- make_figure,
-)
-from .settings import parse_load_settings, parse_settings
+from .formats import SAVE_DPI, build_filename
+from .loader import PREVIEW_ROWS, SourceInfo, build_preview, column_options, load_file
+from .runner import ScriptError, run_to_image
+from .settings import Settings, parse_load_settings, parse_save_settings, parse_settings
+PREVIEW_DPI = 100
INTERNAL_ERROR_MESSAGE = "内部エラーが発生しました。もう一度操作してください。"
@@ -30,6 +29,37 @@ class Session:
def __init__(self):
self.df = None
self.filename = None
+ self.source: SourceInfo | None = None
+ self.workdir: Path | None = None # 設定すると、アップロードしたファイルを元の名前でここに置く
+ self._written: Path | None = None
+
+ def forget(self) -> None:
+ self.df = None
+ self.filename = None
+ self.source = None
+ self._remove_written()
+
+ def _remove_written(self) -> None:
+ if self._written is not None:
+ try:
+ self._written.unlink()
+ except OSError:
+ pass
+ self._written = None
+
+ def write_upload(self, name: str, data: bytes) -> None:
+ """編集モードのスクリプトが読めるように、アップロードしたファイルを workdir に置く(前のファイルは消す)。"""
+ if self.workdir is None:
+ return
+ base = Path(name).name
+ if base in {"", ".", ".."}:
+ self._remove_written()
+ return
+ target = self.workdir / base
+ if self._written is not None and self._written != target:
+ self._remove_written()
+ target.write_bytes(data)
+ self._written = target
SESSION = Session()
@@ -41,7 +71,11 @@ def _dumps(obj) -> str:
def _error_payload(exc: BaseException, context: str) -> dict:
if isinstance(exc, UserError):
- return {"ok": False, "error": exc.to_dict()}
+ payload = {"ok": False, "error": exc.to_dict()}
+ output = getattr(exc, "output", None)
+ if output is not None:
+ payload["output"] = output
+ return payload
detail = f"context: {context}\ntype: {type(exc).__name__}\nmessage: {exc}\n\ntraceback:\n{traceback.format_exc().strip()}"
return {"ok": False, "error": {"message": INTERNAL_ERROR_MESSAGE, "detail": detail}}
@@ -72,12 +106,14 @@ def _parse_json(text: str) -> dict:
@_guard("load_file")
def load_file_json(name: str, data: bytes, load_settings_json: str) -> dict:
"""ファイルを読み込み、列の選択肢とプレビューを返す。失敗時は読込済みデータを破棄する。"""
- SESSION.df = None
- SESSION.filename = None
+ SESSION.forget()
load = parse_load_settings(_parse_json(load_settings_json))
- loaded = load_file(bytes(data), name, load.delimiter, load.has_header)
+ raw = bytes(data)
+ loaded = load_file(raw, name, load.delimiter, load.has_header)
SESSION.df = loaded.df
SESSION.filename = name
+ SESSION.source = loaded.source
+ SESSION.write_upload(name, raw)
return {
"ok": True,
"encoding": loaded.encoding,
@@ -87,40 +123,88 @@ def load_file_json(name: str, data: bytes, load_settings_json: str) -> dict:
}
-def _figure_for(settings_json: str, custom_code: str | None):
- settings = parse_settings(_parse_json(settings_json))
+def _source_info() -> SourceInfo:
+ if SESSION.source is not None:
+ return SESSION.source
+ name = Path(SESSION.filename or "data.csv").name
+ kind = Path(name).suffix.lower().lstrip(".") or "csv"
+ return SourceInfo(filename=name, kind=kind, encoding="utf-8", separator=",", has_header=True,
+ sheet_name="Sheet1" if kind == "xlsx" else None)
+
+
+def _step_error(script: GeneratedScript, err: ScriptError) -> ScriptError:
+ """生成コードの実行時エラーを、失敗した手順に応じた日本語メッセージにする。"""
+ step = script.step_for_line(err.line)
+ mapped = ScriptError(
+ step.message if step else GENERIC_ERROR_MESSAGE,
+ field=step.field if step else None,
+ detail=err.detail,
+ traceback_text=err.traceback_text,
+ line=err.line,
+ output=err.output,
+ exc=err.exc,
+ )
+ return mapped
+
+
+def _generate(settings: Settings):
if SESSION.df is None:
raise UserError("先にファイルを読み込んでください。")
- return settings, make_figure(SESSION.df, settings, custom_code)
+ plan = plan_plot(SESSION.df, settings)
+ return plan, generate_script(settings, _source_info(), plan)
+
+
+def _run(settings: Settings | None, code: str | None, *, file_format: str, transparent: bool, dpi: int):
+ """GUI 同期(code が None): 設定から生成したスクリプトを、読込済みの df で実行する(読込・保存の行は空にする)。
+ 編集モード(code が文字列): そのスクリプトを、アップロードしたファイルのある作業フォルダで実行する。設定は使わない。"""
+ if code is None:
+ plan, script = _generate(settings)
+ try:
+ result = run_to_image(
+ script.auto_render_text(), file_format=file_format, transparent=transparent, dpi=dpi,
+ injected={"df": SESSION.df},
+ )
+ except ScriptError as err:
+ raise _step_error(script, err) from err
+ return plan, script, result
+ return None, None, run_to_image(code, file_format=file_format, transparent=transparent, dpi=dpi, cwd=SESSION.workdir)
+
+
+def _data_uri(result) -> str:
+ return f"data:{result.mime};base64,{base64.b64encode(result.data).decode('ascii')}"
@_guard("render")
-def render_json(settings_json: str, custom_code: str | None = None) -> dict:
- """設定どおりに描画し、PNG の data URI を返す。"""
- _, result = _figure_for(settings_json, custom_code)
- try:
- uri, _ext = figure_to_data_uri(result.fig, "png", dpi=100)
- finally:
- plt.close(result.fig)
+def render_json(settings_json: str, code: str | None = None) -> dict:
+ """PNG(data URI)を返す。code が None なら設定どおりに描き、文字列ならそのコードを実行する。"""
+ settings = parse_settings(_parse_json(settings_json)) if code is None else None
+ plan, script, result = _run(settings, code, file_format="png", transparent=False, dpi=PREVIEW_DPI)
+ payload = {"ok": True, "image": _data_uri(result), "output": result.output, "summary": result.summary}
+ if plan is not None:
+ payload.update(seriesCount=plan.plotted_count, skipRows=plan.skip_rows, warnings=list(plan.warnings), code=script.text)
+ return payload
+
+
+@_guard("save")
+def save_json(settings_json: str, code: str | None = None) -> dict:
+ """保存設定の形式で図を書き出し、data URI とファイル名を返す(ダウンロードは JS が行う)。"""
+ raw = _parse_json(settings_json)
+ # 編集モードは保存設定だけを使う(描画設定が不正でも、編集したコードの図は保存できる)
+ settings = parse_settings(raw) if code is None else None
+ save = settings.save if settings is not None else parse_save_settings(raw)
+ _, _, result = _run(settings, code, file_format=save.format, transparent=save.transparent, dpi=SAVE_DPI)
return {
"ok": True,
- "image": uri,
- "seriesCount": result.plotted_count,
- "skipRows": result.skip_rows,
- "warnings": result.warnings,
+ "filename": build_filename(save.filename, result.ext),
+ "mime": result.mime,
+ "dataUri": _data_uri(result),
+ "output": result.output,
}
-@_guard("save")
-def save_json(settings_json: str, custom_code: str | None = None) -> dict:
- """保存設定の形式で図を書き出し、data URI とファイル名を返す(ダウンロードは JS が行う)。"""
- settings, result = _figure_for(settings_json, custom_code)
- try:
- uri, ext = figure_to_data_uri(result.fig, settings.save.format, transparent=settings.save.transparent)
- finally:
- plt.close(result.fig)
- mime = uri[5 : uri.index(";")]
- return {"ok": True, "filename": build_filename(settings.save.filename, ext), "mime": mime, "dataUri": uri}
+def script_filename(save_filename: str) -> str:
+ """「.py で保存」のファイル名。保存ファイル名と同じ規則(formats.build_filename)で拡張子を .py にする。"""
+ return build_filename(str(save_filename or ""), "py")
def register_font(data: bytes) -> str:
@@ -133,7 +217,3 @@ def register_font(data: bytes) -> str:
def font_status() -> str:
return _dumps({"ok": True, "registered": fonts.is_registered()})
-
-
-def default_custom_code() -> str:
- return DEFAULT_CUSTOM_PLOT_CODE.strip() + "\n"
diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py
new file mode 100644
index 0000000..f146eb2
--- /dev/null
+++ b/py/mplgui/codegen.py
@@ -0,0 +1,501 @@
+"""設定(と読み込んだファイルの情報・描画計画)から、matplotlib のスクリプトを生成する(純粋な関数)。
+
+生成するコードが使うのは pandas / matplotlib / numpy だけ。ローカルの Python でそのまま実行できる。
+GUI の描画は、このスクリプトの「データ読込」と「保存・表示」を除いた部分を実行して行う(runner)。
+
+安全性: 列名・タイトル・ラベル・色・ファイル名は信頼できない入力であり、実行されるコードに入る。
+すべての値は literal() だけを通して書き出し(str / int / 有限の float / bool / None を repr で出力)、
+コメントに入る文字列は comment_text() で改行・制御文字を空白にする。
+"""
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Callable
+
+from .dataprep import PlotPlan, SeriesPlan
+from .fonts import SANS_SERIF_PRIORITY
+from .formats import FORMATS, SAVE_DPI, build_filename, savefig_kwargs
+from .loader import SourceInfo
+from .settings import SeriesSettings, Settings
+
+SCRIPT_NAME = "plot.py"
+
+GENERIC_ERROR_MESSAGE = "グラフを描画できませんでした。X列・Y列に指定した列の値の形式(数値と文字列の混在など)や、設定の値を確認してください。"
+TIGHT_LAYOUT_ERROR = "レイアウトの自動調整に失敗しました。余白を手動で指定するか、図の大きさ・フォントサイズを小さくしてください。"
+MARGIN_ERROR = "余白の設定を適用できませんでした。左 < 右、下 < 上 になるように、0〜1の値で指定してください。"
+
+
+def axis_error_message(label: str) -> str:
+ return f"{label}軸のスケールまたは範囲を適用できませんでした。範囲の値を確認してください。"
+
+
+# ---------------------------------------------------------------- literals and comments
+
+
+def literal(value) -> str:
+ """コードに書き出せる値(str / int / 有限の float / bool / None)だけを repr で文字列にする。"""
+ kind = type(value)
+ if value is None or kind is bool:
+ return repr(value)
+ if kind is int:
+ return repr(value)
+ if kind is float:
+ if not math.isfinite(value):
+ raise ValueError("有限でない数値はコードに書き出せません")
+ return repr(value)
+ if kind is str:
+ text = repr(value)
+ if text[0] == "'" and '"' not in value and "'" not in value:
+ text = '"' + text[1:-1] + '"' # 読みやすいように二重引用符にする(中身はエスケープだけなので安全)
+ return text
+ raise TypeError(f"コードに書き出せない型です: {kind.__name__}")
+
+
+_LINE_BREAKS = {"
", "
", "\x85", "\x0b", "\x0c", "\r", "\n"}
+
+
+def comment_text(text) -> str:
+ """コメントに入れる文字列。改行・印字できない文字(制御文字を含む)を空白にする。"""
+ return "".join(" " if (ch in _LINE_BREAKS or not ch.isprintable()) else ch for ch in str(text))
+
+
+def _column_comment(plan_series: SeriesPlan, which: str) -> str:
+ if which == "x":
+ if plan_series.x_index is None:
+ return "行番号"
+ return f"{comment_text(plan_series.x_label)} [{plan_series.x_index}]"
+ return comment_text(plan_series.data_label)
+
+
+# ---------------------------------------------------------------- result type
+
+
+@dataclass(frozen=True)
+class Step:
+ start: int # 1 始まり、両端を含む
+ end: int
+ message: str
+ field: str | None = None
+
+
+@dataclass(frozen=True)
+class GeneratedScript:
+ text: str
+ load_lines: tuple[int, int]
+ output_lines: tuple[int, int]
+ steps: tuple[Step, ...]
+
+ def auto_render_text(self) -> str:
+ """データ読込と保存・表示の行を空行にしたもの(行番号は表示中のスクリプトと同じ)。df を外から渡して実行する。"""
+ lines = self.text.split("\n")
+ for start, end in (self.load_lines, self.output_lines):
+ for n in range(start, end + 1):
+ lines[n - 1] = ""
+ return "\n".join(lines)
+
+ def step_for_line(self, line: int | None) -> Step | None:
+ if line is None:
+ return None
+ for step in self.steps:
+ if step.start <= line <= step.end:
+ return step
+ return None
+
+
+class _Builder:
+ def __init__(self) -> None:
+ self.lines: list[str] = []
+ self.steps: list[Step] = []
+ self.load_lines = (0, -1)
+ self.output_lines = (0, -1)
+
+ def add(self, *lines: str) -> None:
+ self.lines.extend(lines)
+
+ def blank(self) -> None:
+ self.lines.append("")
+
+ def section(self, title: str) -> int:
+ """区切りの見出しを書き、その行番号(1 始まり)を返す。"""
+ if self.lines:
+ self.blank()
+ self.lines.append(f"# ==== {title} ====")
+ return len(self.lines)
+
+ def step(self, message: str, field: str | None = None):
+ return _StepContext(self, message, field)
+
+
+class _StepContext:
+ def __init__(self, builder: _Builder, message: str, field: str | None) -> None:
+ self.builder, self.message, self.field = builder, message, field
+
+ def __enter__(self):
+ self.start = len(self.builder.lines) + 1
+ return self
+
+ def __exit__(self, *exc):
+ end = len(self.builder.lines)
+ if end >= self.start:
+ self.builder.steps.append(Step(self.start, end, self.message, self.field))
+ return False
+
+
+# ---------------------------------------------------------------- numbers
+
+
+def _label_size_expr(font_size: float) -> str:
+ """目盛・凡例の文字サイズ。max(font_size - 1, 1) と同じ値になる式(下限が効くときは値そのもの)。"""
+ return "FONT_SIZE - 1" if font_size - 1 >= 1 else literal(1.0)
+
+
+# ---------------------------------------------------------------- sections
+
+
+def _emit_header(b: _Builder, source: SourceInfo) -> None:
+ name = comment_text(source.filename)
+ b.add(
+ "# matplotlib GUI が生成したスクリプト",
+ f"# 使い方: データファイル「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。",
+ "# 必要なライブラリ: pandas, matplotlib, numpy",
+ "import matplotlib.pyplot as plt",
+ "import numpy as np",
+ "import pandas as pd",
+ "",
+ "# 日本語フォント: 一覧の先頭から、見つかったものを使う。",
+ "# どれも無い環境では DejaVu Sans になる(日本語は豆腐になるが、実行はできる)",
+ 'plt.rcParams["font.family"] = "sans-serif"',
+ f'plt.rcParams["font.sans-serif"] = {_font_list_literal()}',
+ 'plt.rcParams["axes.unicode_minus"] = False',
+ )
+
+
+def _font_list_literal() -> str:
+ return "[" + ", ".join(literal(name) for name in SANS_SERIF_PRIORITY) + "]"
+
+
+def _emit_load(b: _Builder, source: SourceInfo) -> None:
+ start = b.section("1. データの読み込み")
+ header_arg = literal(0 if source.has_header else None)
+ header_note = "あり" if source.has_header else "なし"
+ if source.kind == "xlsx":
+ b.add(
+ f"# 形式: Excel(.xlsx)、シート: {comment_text(source.sheet_name)}(先頭のシート)、先頭行をヘッダにする: {header_note}",
+ "# .xlsx の読み込みには openpyxl が必要です(pip install openpyxl)",
+ f"DATA_FILE = {literal(source.filename)}",
+ "# 前置き行(データの前にある説明の行)を飛ばすときは、read_excel に skiprows=行数 を足す",
+ f"df = pd.read_excel(DATA_FILE, sheet_name={literal(source.sheet_name)}, header={header_arg})",
+ )
+ else:
+ b.add(
+ f"# 文字コード: {comment_text(source.encoding)}(自動判定)、区切り文字: {comment_text(literal(source.separator))}、先頭行をヘッダにする: {header_note}",
+ f"DATA_FILE = {literal(source.filename)}",
+ "# 前置き行(データの前にある説明の行)を飛ばすときは、read_csv に skiprows=行数 を足す",
+ f"df = pd.read_csv(DATA_FILE, encoding={literal(source.encoding)}, sep={literal(source.separator)}, "
+ f'header={header_arg}, engine="python")',
+ )
+ if not source.has_header:
+ b.add('df.columns = [f"column_{i}" for i in range(len(df.columns))] # 列名を column_0, column_1, ... にする')
+ b.load_lines = (start, len(b.lines))
+
+
+def _emit_skip_rows(b: _Builder, plan: PlotPlan) -> None:
+ b.section("2. 描画に使う行")
+ if plan.skip_rows > 0:
+ b.add(f"df = df.iloc[{literal(plan.skip_rows)}:].reset_index(drop=True) # 先頭の{plan.skip_rows}行を描画から除外する")
+ else:
+ b.add("# 先頭の行は除外しない(全ての行を使う)")
+
+
+def _emit_figure(b: _Builder, settings: Settings) -> None:
+ b.section("3. 図と軸")
+ fig = settings.plot.figure
+ b.add(f"fig, ax = plt.subplots(figsize=({literal(fig.width)}, {literal(fig.height)}), dpi=100)")
+
+
+# ---- series emitters (registry) ----
+
+
+def _series_comment(p: SeriesPlan, *, bar_x: bool = False) -> str:
+ where = "(第2Y軸)" if p.secondary else ""
+ return f"# 系列{p.number}{where}: X = {_column_comment(p, 'x')}、Y = {_column_comment(p, 'y')}"
+
+
+def _target(p: SeriesPlan) -> str:
+ return "ax2" if p.secondary else "ax"
+
+
+def _emit_x_data(b: _Builder, p: SeriesPlan) -> None:
+ if p.x_index is None:
+ b.add("x = pd.Series(df.index) # X は指定なし。行番号を使う")
+ elif p.convert_x:
+ b.add(f'x = pd.to_numeric(df.iloc[:, {p.x_index}], errors="coerce") # 数値にできない値は NaN にする')
+ else:
+ b.add(f"x = df.iloc[:, {p.x_index}]")
+
+
+def _emit_y_data(b: _Builder, p: SeriesPlan) -> None:
+ b.add(f'y = pd.to_numeric(df.iloc[:, {p.y_index}], errors="coerce") # 数値にできない値は NaN にする')
+
+
+def _emit_ok_mask(b: _Builder) -> None:
+ b.add("ok = x.notna() & y.notna() # X か Y が欠けている行は描かない")
+
+
+class _Axes2State:
+ """第2Y軸(ax2)を、最初に描く第2軸の系列の直前に作る。"""
+
+ def __init__(self) -> None:
+ self.created = False
+
+ def ensure(self, b: _Builder, p: SeriesPlan) -> None:
+ if p.secondary and not self.created:
+ b.add("ax2 = ax.twinx() # 第2Y軸(右側)")
+ self.created = True
+
+
+def _style_args(s: SeriesSettings) -> str:
+ return f"color={literal(s.color)}, linewidth={literal(s.line_width)}, linestyle={literal(s.line_style)}"
+
+
+def _emit_line_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None:
+ ax2 = _Axes2State()
+ for i, (s, p) in enumerate(zip(settings.series, plan.series)):
+ if i > 0:
+ b.blank() # 系列ごとに1行あける
+ b.add(_series_comment(p))
+ if not p.has_points:
+ b.add("# 描ける点(X・Y とも数値の行)が無いため、この系列は描かない")
+ continue
+ with b.step(GENERIC_ERROR_MESSAGE):
+ ax2.ensure(b, p)
+ _emit_x_data(b, p)
+ _emit_y_data(b, p)
+ _emit_ok_mask(b)
+ marker = ""
+ if p.marker_size > 0:
+ b.add("# 点の大きさ: 面積(scatter の s と同じ単位)の平方根を markersize(直径)にする")
+ marker = f', marker="o", markersize={literal(p.marker_size)} ** 0.5'
+ b.add(f"{_target(p)}.plot(x[ok], y[ok], {_style_args(s)}{marker}, label={literal(p.legend_label)})")
+
+
+def _emit_scatter_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None:
+ ax2 = _Axes2State()
+ for i, (s, p) in enumerate(zip(settings.series, plan.series)):
+ if i > 0:
+ b.blank() # 系列ごとに1行あける
+ b.add(_series_comment(p))
+ if not p.has_points:
+ b.add("# 描ける点(X・Y とも数値の行)が無いため、この系列は描かない")
+ continue
+ with b.step(GENERIC_ERROR_MESSAGE):
+ ax2.ensure(b, p)
+ _emit_x_data(b, p)
+ _emit_y_data(b, p)
+ _emit_ok_mask(b)
+ b.add(
+ f"{_target(p)}.scatter(x[ok], y[ok], color={literal(s.color)}, s={literal(p.marker_size)}, "
+ f"label={literal(p.legend_label)})"
+ )
+
+
+def _emit_bar_x(b: _Builder, plan: PlotPlan) -> None:
+ if plan.bar_x_index is None:
+ b.add("x = pd.Series(df.index) # X は指定なし。行番号を使う")
+ else:
+ b.add(f"x = df.iloc[:, {plan.bar_x_index}]")
+
+
+def _emit_bar_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None:
+ ax2 = _Axes2State()
+ if len(plan.series) == 1:
+ s, p = settings.series[0], plan.series[0]
+ b.add(_series_comment(p))
+ with b.step(GENERIC_ERROR_MESSAGE):
+ ax2.ensure(b, p)
+ _emit_bar_x(b, plan)
+ _emit_y_data(b, p)
+ _emit_ok_mask(b)
+ b.add(f"{_target(p)}.bar(x[ok], y[ok], {_style_args(s)}, label={literal(p.legend_label)})")
+ return
+
+ n = len(plan.series)
+ b.add("# 複数の系列を、X の値ごとに横に並べた棒グラフにする(X は共通)")
+ with b.step(GENERIC_ERROR_MESSAGE):
+ _emit_bar_x(b, plan)
+ b.add("bar_data = pd.DataFrame({")
+ b.add(' "x": x,')
+ for p in plan.series:
+ b.add(f' "y{p.number}": pd.to_numeric(df.iloc[:, {p.y_index}], errors="coerce"), # {_column_comment(p, "y")}')
+ b.add("})")
+ keys = ", ".join(literal(f"y{p.number}") for p in plan.series)
+ b.add(f'bar_data = bar_data.dropna(subset=[{keys}], how="all") # 全ての系列が欠けている行は描かない')
+ b.add("x_labels = [str(v) for v in bar_data[\"x\"]]")
+ b.add("positions = np.arange(len(x_labels))")
+ b.add(f"width = 0.8 / {n} # 棒1本の幅")
+ for i, (s, p) in enumerate(zip(settings.series, plan.series)):
+ b.blank()
+ b.add(_series_comment(p))
+ ax2.ensure(b, p)
+ b.add(f"offset = -0.4 + (width / 2.0) + ({i} * width)")
+ b.add(
+ f'{_target(p)}.bar(positions + offset, bar_data["y{p.number}"].fillna(0.0), width=width, '
+ f"{_style_args(s)}, label={literal(p.legend_label)})"
+ )
+
+
+SERIES_EMITTERS: dict[str, Callable[[_Builder, Settings, PlotPlan], None]] = {
+ "line": _emit_line_series,
+ "scatter": _emit_scatter_series,
+ "bar": _emit_bar_series,
+}
+
+
+def _emit_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None:
+ b.section("4. 系列")
+ SERIES_EMITTERS[plan.plot_type](b, settings, plan)
+
+
+def _axis_lines(var: str, which: str, scale: str, lo, hi) -> list[str]:
+ lines = []
+ if scale == "log":
+ lines.append(f'{var}.set_{which}scale("log")')
+ limits = [f"{k}={literal(v)}" for k, v in (("left" if which == "x" else "bottom", lo), ("right" if which == "x" else "top", hi)) if v is not None]
+ if limits:
+ lines.append(f"{var}.set_{which}lim({', '.join(limits)})")
+ return lines
+
+
+def _emit_axes(b: _Builder, settings: Settings, plan: PlotPlan) -> None:
+ b.section("5. 軸(ラベル・スケール・範囲・目盛)")
+ plot, axes = settings.plot, settings.axes
+ if plot.title:
+ b.add(f"ax.set_title({literal(plot.title)}, fontsize=FONT_SIZE + 2)")
+ b.add(f"ax.set_xlabel({literal(axes.x.label or plan.x_label)}, fontsize=FONT_SIZE)")
+ b.add(f"ax.set_ylabel({literal(axes.y.label or plan.y_label)}, fontsize=FONT_SIZE)")
+ if plan.uses_secondary:
+ b.add(f"ax2.set_ylabel({literal(axes.y2.label or plan.y2_label)}, fontsize=FONT_SIZE)")
+
+ for var, which, label, axis in (
+ ("ax", "x", "X", axes.x),
+ ("ax", "y", "Y", axes.y),
+ ("ax2", "y", "第2Y", axes.y2),
+ ):
+ if var == "ax2" and not plan.uses_secondary:
+ continue
+ lines = _axis_lines(var, which, axis.scale, axis.min, axis.max)
+ if var == "ax" and which == "x" and axis.scale == "linear" and any(p.categorical_x and p.has_points for p in plan.series):
+ lines.insert(0, 'ax.set_xscale("linear") # X が文字列・日時の列: 目盛は位置の番号になる(値を目盛にするには、この行を消す)')
+ if lines:
+ with b.step(axis_error_message(label), f"{label}軸の範囲"):
+ b.add(*lines)
+
+ if plan.plot_type == "bar" and len(plan.series) > 1:
+ b.add(
+ "# 棒の位置に X の値を目盛として付ける(25個を超えるときは間引く)",
+ "tick_step = max(1, len(positions) // 25)",
+ "ax.set_xticks(positions[::tick_step])",
+ 'ax.set_xticklabels(x_labels[::tick_step], rotation=45, ha="right")',
+ )
+
+ size = _label_size_expr(plot.font_size)
+ b.add("# 目盛は内向きにする", f'ax.tick_params(which="both", direction="in", labelsize={size})')
+ if plan.uses_secondary:
+ b.add(f'ax2.tick_params(which="both", direction="in", labelsize={size})')
+
+ if not plot.grid_minor:
+ if axes.x.scale == "log" or axes.y.scale == "log":
+ b.add("ax.minorticks_off() # 対数軸の補助目盛は出さない")
+ if plan.uses_secondary and axes.y2.scale == "log":
+ b.add("ax2.minorticks_off()")
+
+
+def _emit_grid_and_legend(b: _Builder, settings: Settings, plan: PlotPlan) -> None:
+ b.section("6. グリッドと凡例")
+ plot = settings.plot
+ if plot.grid_minor:
+ b.add("ax.minorticks_on()")
+ if plan.uses_secondary:
+ b.add("ax2.minorticks_on()")
+ if plot.grid_major:
+ b.add('ax.grid(True, which="major", axis="both", alpha=0.3)')
+ if plan.uses_secondary:
+ b.add('ax2.grid(True, which="major", axis="y", alpha=0.3)')
+ if plot.grid_minor:
+ b.add('ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":")')
+ if plan.uses_secondary:
+ b.add('ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":")')
+ if not (plot.grid_major or plot.grid_minor):
+ b.add("# グリッドは表示しない")
+
+ if plot.legend_location == "none":
+ b.add("# 凡例は表示しない")
+ return
+ if not any(p.has_points and p.legend_label and not p.legend_label.startswith("_") for p in plan.series):
+ b.add("# 凡例に出せるラベルが無い(空、または _ で始まる)ため、凡例は表示しない")
+ return
+ size = _label_size_expr(plot.font_size)
+ loc = literal(plot.legend_location)
+ if plan.uses_secondary:
+ b.add(
+ "# 第1・第2Y軸の系列をまとめて凡例にする",
+ "handles, labels = ax.get_legend_handles_labels()",
+ "handles2, labels2 = ax2.get_legend_handles_labels()",
+ f'ax.legend(handles + handles2, labels + labels2, loc={loc}, fontsize={size}, edgecolor="black")',
+ )
+ else:
+ b.add(f'ax.legend(loc={loc}, fontsize={size}, edgecolor="black")')
+
+
+def _emit_margins(b: _Builder, settings: Settings) -> None:
+ b.section("7. 余白")
+ provided = settings.plot.margins.provided()
+ if len(provided) < 4:
+ b.add("# 余白は自動で調整する")
+ with b.step(TIGHT_LAYOUT_ERROR, "余白"):
+ b.add("fig.tight_layout()")
+ else:
+ b.add("# 余白は4辺とも指定されている")
+ if provided:
+ args = ", ".join(f"{k}={literal(v)}" for k, v in provided.items())
+ if len(provided) < 4:
+ b.add("# 指定された辺だけ、自動調整の結果を上書きする")
+ with b.step(MARGIN_ERROR, "余白"):
+ b.add(f"fig.subplots_adjust({args})")
+
+
+def _emit_output(b: _Builder, settings: Settings) -> None:
+ start = b.section("8. 保存と表示")
+ save = settings.save
+ _, _, ext = FORMATS[save.format]
+ filename = build_filename(save.filename, ext)
+ kwargs = savefig_kwargs(save.format, save.transparent)
+ args = "".join(f", {k}={literal(v)}" for k, v in kwargs.items())
+ note = f"GUI の「保存」と同じ形式({ext})" + ("と解像度" if "dpi" in kwargs else "")
+ b.add(f"fig.savefig({literal(filename)}{args}) # {note}", "plt.show()")
+ b.output_lines = (start, len(b.lines))
+
+
+# ---------------------------------------------------------------- entry
+
+
+def generate_script(settings: Settings, source: SourceInfo, plan: PlotPlan) -> GeneratedScript:
+ """設定・読み込んだファイルの情報・描画計画から、完全なスクリプトを作る。"""
+ b = _Builder()
+ _emit_header(b, source)
+ b.add(f"FONT_SIZE = {literal(settings.plot.font_size)} # 文字の大きさ(pt)")
+ _emit_load(b, source)
+ _emit_skip_rows(b, plan)
+ _emit_figure(b, settings)
+ _emit_series(b, settings, plan)
+ _emit_axes(b, settings, plan)
+ _emit_grid_and_legend(b, settings, plan)
+ _emit_margins(b, settings)
+ _emit_output(b, settings)
+ return GeneratedScript(
+ text="\n".join(b.lines) + "\n",
+ load_lines=b.load_lines,
+ output_lines=b.output_lines,
+ steps=tuple(b.steps),
+ )
diff --git a/py/mplgui/dataprep.py b/py/mplgui/dataprep.py
new file mode 100644
index 0000000..80a3fbb
--- /dev/null
+++ b/py/mplgui/dataprep.py
@@ -0,0 +1,301 @@
+"""DataFrame の中身を見て決める処理(codegen に渡す描画計画 PlotPlan を作る)。
+
+js / pyodide には依存しない。列指定は "__idx__N"(列番号)形式。
+ここで決めるのは「データを見ないと分からないこと」だけ(Y 列の自動割り当て、X の数値変換の要否、
+描ける点があるか、数値に変換できず除外した値の警告、データ由来のエラー)。コードの組み立ては codegen が行う。
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pandas as pd
+
+from .errors import UserError
+from .settings import Settings
+
+MAX_EXAMPLES = 3
+NO_DRAWABLE_DATA = "描画可能なデータがありません。"
+
+# ---------------------------------------------------------------- dropped (non-numeric) values (A7)
+
+
+def _is_blank_cell(value) -> bool:
+ if value is None:
+ return True
+ try:
+ if pd.isna(value):
+ return True
+ except (TypeError, ValueError):
+ pass
+ return isinstance(value, str) and value.strip() == ""
+
+
+def coerce_numeric(raw: pd.Series) -> tuple[pd.Series, int, list[str]]:
+ """数値に変換する。戻り値は (変換後, 変換できなかった非空欄のセル数, 例(最大3件・重複なし))。空欄は数えない。
+
+ すでに数値型の列は、そのまま返す(NaN は空欄であり、変換の失敗ではない)。
+ """
+ if pd.api.types.is_numeric_dtype(raw.dtype):
+ return raw, 0, []
+ numeric = pd.to_numeric(raw, errors="coerce")
+ blank = pd.Series([_is_blank_cell(v) for v in raw.tolist()], index=raw.index, dtype=bool)
+ failed = numeric.isna() & ~blank
+ count = int(failed.sum())
+ examples: list[str] = []
+ if count:
+ for v in raw[failed].tolist():
+ text = str(v)
+ if text not in examples:
+ examples.append(text)
+ if len(examples) >= MAX_EXAMPLES:
+ break
+ return numeric, count, examples
+
+
+def dropped_warning(series_no: int, what: str, count: int, examples: list[str]) -> dict:
+ ex = ", ".join(f'"{e}"' for e in examples)
+ message = f"{what}: 数値に変換できない値が{count}件あったため、その行を除外しました(例: {ex})。"
+ return {"series": series_no, "message": message}
+
+
+def _no_data_message(warnings: list[dict]) -> str:
+ base = "描画可能な数値データがありません。"
+ if warnings:
+ return base + "数値に変換できない値は除外されます。" + warnings[0]["message"]
+ return base
+
+
+def _looks_numeric(raw: pd.Series) -> bool:
+ """文字列を含む列でも、空欄以外の半数以上が数値に変換できるなら数値列として扱う。"""
+ if pd.api.types.is_numeric_dtype(raw.dtype):
+ return True
+ non_blank = [v for v in raw.tolist() if not _is_blank_cell(v)]
+ if not non_blank:
+ return False
+ ok = pd.to_numeric(pd.Series(non_blank, dtype=object), errors="coerce").notna().sum()
+ return ok * 2 >= len(non_blank)
+
+
+# ---------------------------------------------------------------- columns
+
+
+def parse_column_index(requested: str) -> int | None:
+ if not requested.startswith("__idx__"):
+ return None
+ idx_text = requested.replace("__idx__", "", 1)
+ if not idx_text.isdigit():
+ return None
+ return int(idx_text)
+
+
+def _column_out_of_range(idx: int, ncols: int) -> str:
+ return f"選択した列({idx + 1}列目)がデータにありません(データは{ncols}列です)。別のファイルを読み込んだ場合は、列を選び直してください。"
+
+
+def resolve_column_index(df: pd.DataFrame, requested: str) -> int:
+ """列指定("__idx__N" または列名)を列番号にする。見つからなければ UserError。"""
+ if requested.startswith("__idx__"):
+ idx = parse_column_index(requested)
+ if idx is None:
+ raise UserError("列の指定が正しくありません。列を選び直してください。", detail=requested)
+ if idx >= len(df.columns):
+ raise UserError(_column_out_of_range(idx, len(df.columns)))
+ return idx
+ for i, col in enumerate(df.columns):
+ if col == requested or str(col) == requested:
+ return i
+ raise UserError(f"列「{requested}」がデータに見つかりません。列を選び直してください。")
+
+
+def resolve_y_indices(df: pd.DataFrame, requests: list[str]) -> list[str]:
+ """Y列が自動("")の系列に、未使用の最初の数値列を割り当てる。戻り値は列指定のリスト。"""
+ numeric_indices = [i for i, dtype in enumerate(df.dtypes) if pd.api.types.is_numeric_dtype(dtype)]
+ candidates = numeric_indices if numeric_indices else [0]
+ used: set[int] = set()
+ resolved = []
+ for request in requests:
+ if request:
+ idx = parse_column_index(request)
+ if idx is not None:
+ used.add(idx)
+ resolved.append(request)
+ continue
+ chosen = next((i for i in candidates if i not in used), candidates[0])
+ used.add(chosen)
+ resolved.append(f"__idx__{chosen}")
+ return resolved
+
+
+def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int) -> pd.DataFrame:
+ if skip_rows >= len(df_full):
+ raise UserError("スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数")
+ return df_full.iloc[skip_rows:].reset_index(drop=True)
+
+
+# ---------------------------------------------------------------- plan
+
+
+@dataclass(frozen=True)
+class SeriesPlan:
+ number: int # 1 始まり
+ y_index: int
+ y_name: str
+ x_index: int | None # None = 行番号
+ x_label: str # X 未指定は "index"
+ data_label: str # f"{name} [{idx}]"
+ legend_label: str
+ convert_x: bool # X を pd.to_numeric で変換する(line / scatter のみ)
+ categorical_x: bool # line / scatter で X が数値でない列(文字列・日時)のまま使う
+ marker_size: float
+ has_points: bool
+ secondary: bool
+
+
+@dataclass(frozen=True)
+class PlotPlan:
+ skip_rows: int
+ plot_type: str
+ series: tuple[SeriesPlan, ...]
+ x_label: str # 既定の X ラベル
+ y_label: str # 既定の Y ラベル
+ y2_label: str | None # 第2Y軸を作るときだけ
+ bar_x_index: int | None # bar の X 列(None = 行番号)
+ plotted_count: int
+ uses_secondary: bool # 第2Y軸(ax2)を作る(描ける点のある第2軸の系列がある)
+ warnings: tuple[dict, ...] # [{"series": 1始まり, "message": str}]
+
+ @property
+ def plotted(self) -> tuple[SeriesPlan, ...]:
+ return tuple(s for s in self.series if s.has_points)
+
+
+def _label_for(df: pd.DataFrame, idx: int) -> tuple[str, str]:
+ name = str(df.columns[idx])
+ return name, f"{name} [{idx}]"
+
+
+def plan_plot(df_full: pd.DataFrame, settings: Settings) -> PlotPlan:
+ """データと設定から描画計画を作る。データ由来の問題は UserError にする。"""
+ plot = settings.plot
+ df = slice_skip_rows(df_full, plot.skip_rows)
+ y_requests = resolve_y_indices(df, [s.y for s in settings.series])
+ if plot.type in {"line", "scatter"}:
+ return _plan_xy(df, settings, y_requests)
+ return _plan_bar(df, settings, y_requests)
+
+
+def _plan_xy(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> PlotPlan:
+ plot_type = settings.plot.type
+ plans: list[SeriesPlan] = []
+ warnings: list[dict] = []
+ for n, (series, y_request) in enumerate(zip(settings.series, y_requests), start=1):
+ x_index = resolve_column_index(df, series.x) if series.x else None
+ x_label = str(df.columns[x_index]) if x_index is not None else "index"
+ y_index = resolve_column_index(df, y_request)
+ y_name, data_label = _label_for(df, y_index)
+
+ x_series = df.iloc[:, x_index] if x_index is not None else pd.Series(df.index)
+ convert_x = False
+ if x_index is not None and not pd.api.types.is_numeric_dtype(x_series.dtype) and _looks_numeric(x_series):
+ convert_x = True
+ x_series, x_dropped, x_examples = coerce_numeric(x_series)
+ if x_dropped:
+ warnings.append(dropped_warning(n, f"系列{n}のX列({x_label})", x_dropped, x_examples))
+ y_numeric, y_dropped, y_examples = coerce_numeric(df.iloc[:, y_index])
+ if y_dropped:
+ warnings.append(dropped_warning(n, f"系列{n}({y_name})", y_dropped, y_examples))
+
+ has_points = bool((y_numeric.notna() & pd.notna(x_series)).any())
+ plans.append(
+ SeriesPlan(
+ number=n,
+ y_index=y_index,
+ y_name=y_name,
+ x_index=x_index,
+ x_label=x_label,
+ data_label=data_label,
+ legend_label=series.label or data_label,
+ convert_x=convert_x,
+ categorical_x=x_index is not None and not convert_x and not pd.api.types.is_numeric_dtype(x_series.dtype),
+ marker_size=series.effective_marker_size(plot_type),
+ has_points=has_points,
+ secondary=series.secondary_axis,
+ )
+ )
+
+ if not any(p.has_points for p in plans):
+ raise UserError(_no_data_message(warnings))
+
+ x_labels = [p.x_label for p in plans]
+ common_x = x_labels[0] if len(set(x_labels)) == 1 else "x"
+ return _finish(settings, plans, warnings, common_x, None)
+
+
+def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> PlotPlan:
+ plot = settings.plot
+ x_req = plot.x_column.strip()
+ if x_req:
+ bar_x_index = resolve_column_index(df, x_req)
+ x = df.iloc[:, bar_x_index]
+ x_label = str(df.columns[bar_x_index])
+ else:
+ bar_x_index = None
+ x = pd.Series(df.index)
+ x_label = "index"
+
+ frame = pd.DataFrame({"x": x})
+ plans: list[SeriesPlan] = []
+ warnings: list[dict] = []
+ for i, (series, y_request) in enumerate(zip(settings.series, y_requests)):
+ n = i + 1
+ y_index = resolve_column_index(df, y_request)
+ y_name, data_label = _label_for(df, y_index)
+ numeric, dropped, examples = coerce_numeric(df.iloc[:, y_index])
+ frame[f"y_{i}"] = numeric
+ if dropped:
+ warnings.append(dropped_warning(n, f"系列{n}({y_name})", dropped, examples))
+ plans.append(
+ SeriesPlan(
+ number=n,
+ y_index=y_index,
+ y_name=y_name,
+ x_index=bar_x_index,
+ x_label=x_label,
+ data_label=data_label,
+ legend_label=series.label or data_label,
+ convert_x=False,
+ categorical_x=False,
+ marker_size=series.effective_marker_size("bar"),
+ has_points=True,
+ secondary=series.secondary_axis,
+ )
+ )
+
+ y_keys = [f"y_{i}" for i in range(len(plans))]
+ frame = frame.dropna(subset=y_keys, how="all")
+ if frame.empty:
+ raise UserError(_no_data_message(warnings))
+
+ if len(plans) == 1:
+ has_points = bool((frame["y_0"].notna() & pd.notna(frame["x"])).any())
+ if not has_points:
+ raise UserError(NO_DRAWABLE_DATA)
+ return _finish(settings, plans, warnings, x_label, bar_x_index)
+
+
+def _finish(settings: Settings, plans: list[SeriesPlan], warnings: list[dict], x_label: str, bar_x_index) -> PlotPlan:
+ primary = [p.data_label for p in plans if not p.secondary]
+ secondary = [p.data_label for p in plans if p.secondary]
+ uses_secondary = any(p.secondary and p.has_points for p in plans)
+ return PlotPlan(
+ skip_rows=settings.plot.skip_rows,
+ plot_type=settings.plot.type,
+ series=tuple(plans),
+ x_label=x_label,
+ y_label=primary[0] if len(primary) == 1 else "values",
+ y2_label=(secondary[0] if len(secondary) == 1 else "values") if uses_secondary else None,
+ bar_x_index=bar_x_index,
+ plotted_count=sum(1 for p in plans if p.has_points),
+ uses_secondary=uses_secondary,
+ warnings=tuple(warnings),
+ )
diff --git a/py/mplgui/fonts.py b/py/mplgui/fonts.py
index d296421..416b003 100644
--- a/py/mplgui/fonts.py
+++ b/py/mplgui/fonts.py
@@ -7,16 +7,16 @@
import matplotlib.pyplot as plt
from matplotlib import font_manager
+# 日本語フォントの優先順(唯一の定義)。codegen が生成するスクリプトの rcParams もこの並びを使う。
SANS_SERIF_PRIORITY = [
- "Noto Sans JP",
"Noto Sans CJK JP",
+ "Noto Sans JP",
"IPAexGothic",
"Yu Gothic",
"Hiragino Sans",
"MS Gothic",
"DejaVu Sans",
]
-PREFERRED_JP_FONTS = ["Noto Sans CJK JP", "Noto Sans JP"]
_registered = False
@@ -28,14 +28,9 @@ def configure_rcparams() -> None:
def register_font_file(path) -> None:
- """フォントファイルを matplotlib に登録し、日本語フォントを sans-serif の先頭へ移す。"""
+ """フォントファイルを matplotlib に登録する。rcParams は生成コードと同じ SANS_SERIF_PRIORITY の並びにする。"""
font_manager.fontManager.addfont(str(path))
- current = list(plt.rcParams.get("font.sans-serif", []))
- for name in reversed(PREFERRED_JP_FONTS):
- if name in current:
- current.remove(name)
- current.insert(0, name)
- plt.rcParams["font.sans-serif"] = current
+ configure_rcparams()
def register_font_bytes(data: bytes, filename: str = "NotoSansCJKjp-Regular.otf") -> bool:
diff --git a/py/mplgui/formats.py b/py/mplgui/formats.py
new file mode 100644
index 0000000..927977e
--- /dev/null
+++ b/py/mplgui/formats.py
@@ -0,0 +1,41 @@
+"""保存形式の表と保存ファイル名(runner と codegen が共有する。js / pyodide には依存しない)。"""
+from __future__ import annotations
+
+import re
+
+# 保存形式 → (savefig の format, MIME, 拡張子)
+FORMATS = {
+ "png": ("png", "image/png", "png"),
+ "jpg": ("jpeg", "image/jpeg", "jpg"),
+ "svg": ("svg", "image/svg+xml", "svg"),
+ "pdf": ("pdf", "application/pdf", "pdf"),
+}
+
+SAVE_DPI = 120 # 保存時の解像度(GUI の「保存」と生成コードで共通)
+
+_INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
+
+
+def build_filename(raw_filename: str, ext: str) -> str:
+ """保存ファイル名を作る。空欄時は plot.
。入力中の拡張子は取り除いて ext を付ける。"""
+ raw_filename = _INVALID_FILENAME_CHARS.sub("_", (raw_filename or "").strip())
+ if raw_filename:
+ filename_base = raw_filename.rstrip(".")
+ if "." in filename_base:
+ filename_base = filename_base.rsplit(".", 1)[0]
+ filename_base = filename_base.strip() or "plot"
+ return f"{filename_base}.{ext}"
+ return f"plot.{ext}"
+
+
+def savefig_kwargs(file_format: str, transparent: bool = False, dpi: int = SAVE_DPI) -> dict:
+ """figure.savefig に渡す引数(format 以外)。"""
+ save_format = FORMATS[file_format][0]
+ kwargs: dict = {}
+ if save_format in {"png", "jpeg"}:
+ kwargs["dpi"] = dpi
+ if save_format == "jpeg":
+ kwargs["facecolor"] = "white"
+ if transparent:
+ kwargs["transparent"] = True
+ return kwargs
diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py
index f4ac7d3..2590062 100644
--- a/py/mplgui/loader.py
+++ b/py/mplgui/loader.py
@@ -16,10 +16,23 @@
PREVIEW_ROWS = 100
+@dataclass(frozen=True)
+class SourceInfo:
+ """読み込んだファイルの情報。codegen が生成スクリプトの「データの読み込み」部に使う。"""
+
+ filename: str
+ kind: str # "csv" | "txt" | "xlsx"
+ encoding: str | None = None # xlsx は None
+ separator: str | None = None # デコード後の区切り文字(csv / txt)。xlsx は None
+ has_header: bool = True
+ sheet_name: str | None = None # xlsx のみ(先頭シート)
+
+
@dataclass
class LoadedData:
df: pd.DataFrame
encoding: str | None # xlsx は None
+ source: SourceInfo | None = None
# バックスロッシュ表記(\t, \x41, \u3001 など)だけを解釈する。それ以外(「、」などの非 ASCII 文字)は変更しない。
@@ -117,11 +130,15 @@ def load_file(file_bytes: bytes, filename: str, delimiter: str, has_header: bool
header = 0 if has_header else None
used_encoding: str | None = None
+ sep: str | None = None
+ sheet_name: str | None = None
try:
if suffix == ".xlsx":
try:
- df = pd.read_excel(io.BytesIO(file_bytes), header=header)
+ excel = pd.ExcelFile(io.BytesIO(file_bytes))
+ sheet_name = str(excel.sheet_names[0])
+ df = excel.parse(sheet_name=excel.sheet_names[0], header=header)
except Exception as exc:
raise UserError(
"Excelファイル(.xlsx)を読み込めませんでした。ファイルが壊れていないか、パスワードが掛かっていないかを確認し、"
@@ -152,7 +169,15 @@ def load_file(file_bytes: bytes, filename: str, delimiter: str, has_header: bool
if not has_header:
df.columns = [f"column_{i}" for i in range(len(df.columns))]
- return LoadedData(df=df, encoding=used_encoding)
+ source = SourceInfo(
+ filename=Path(filename).name,
+ kind=suffix.lstrip("."),
+ encoding=used_encoding,
+ separator=sep,
+ has_header=has_header,
+ sheet_name=sheet_name,
+ )
+ return LoadedData(df=df, encoding=used_encoding, source=source)
def load_dataframe(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> pd.DataFrame:
diff --git a/py/mplgui/plotting.py b/py/mplgui/plotting.py
deleted file mode 100644
index b5e59f6..0000000
--- a/py/mplgui/plotting.py
+++ /dev/null
@@ -1,522 +0,0 @@
-"""TRANSITIONAL: 設定 → matplotlib Figure(Phase 2 で codegen に置き換える)。
-
-DOM / js には依存しない。呼び出し側が戻り値の Figure を必ず plt.close() すること。
-列指定は "__idx__N"(列番号)形式を使う。同名列があっても区別できる。
-"""
-from __future__ import annotations
-
-import traceback
-from dataclasses import dataclass, field
-
-import matplotlib.pyplot as plt
-import pandas as pd
-
-from .errors import UserError
-from .settings import MarginSettings, Settings, series_to_legacy
-
-
-@dataclass
-class FigureResult:
- fig: object
- plotted_count: int
- skip_rows: int
- warnings: list = field(default_factory=list) # [{"series": 1始まり, "message": str}](A7 で使用)
-
-
-# ---------------------------------------------------------------- dropped (non-numeric) values (A7)
-
-MAX_EXAMPLES = 3
-
-
-def _is_blank_cell(value) -> bool:
- if value is None:
- return True
- try:
- if pd.isna(value):
- return True
- except (TypeError, ValueError):
- pass
- return isinstance(value, str) and value.strip() == ""
-
-
-def coerce_numeric(raw: pd.Series) -> tuple[pd.Series, int, list[str]]:
- """数値に変換する。戻り値は (変換後, 変換できなかった非空欄のセル数, 例(最大3件・重複なし))。空欄は数えない。"""
- numeric = pd.to_numeric(raw, errors="coerce")
- blank = pd.Series([_is_blank_cell(v) for v in raw.tolist()], index=raw.index, dtype=bool)
- failed = numeric.isna() & ~blank
- count = int(failed.sum())
- examples: list[str] = []
- if count:
- for v in raw[failed].tolist():
- text = str(v)
- if text not in examples:
- examples.append(text)
- if len(examples) >= MAX_EXAMPLES:
- break
- return numeric, count, examples
-
-
-def dropped_warning(series_no: int, what: str, count: int, examples: list[str]) -> dict:
- ex = ", ".join(f'"{e}"' for e in examples)
- message = f"{what}: 数値に変換できない値が{count}件あったため、その行を除外しました(例: {ex})。"
- return {"series": series_no, "message": message}
-
-
-def _no_data_message(entries: list[dict]) -> str:
- base = "描画可能な数値データがありません。"
- hints = [w["message"] for e in entries for w in e.get("warnings", [])]
- if hints:
- return base + "数値に変換できない値は除外されます。" + hints[0]
- return base
-
-
-def _looks_numeric(raw: pd.Series) -> bool:
- """文字列を含む列でも、空欄以外の半数以上が数値に変換できるなら数値列として扱う。"""
- if pd.api.types.is_numeric_dtype(raw.dtype):
- return True
- non_blank = [v for v in raw.tolist() if not _is_blank_cell(v)]
- if not non_blank:
- return False
- ok = pd.to_numeric(pd.Series(non_blank, dtype=object), errors="coerce").notna().sum()
- return ok * 2 >= len(non_blank)
-
-
-# ---------------------------------------------------------------- column helpers (legacy-dict based)
-
-
-def parse_column_index(requested: str) -> int | None:
- if not requested.startswith("__idx__"):
- return None
- idx_text = requested.replace("__idx__", "", 1)
- if not idx_text.isdigit():
- return None
- return int(idx_text)
-
-
-def _column_out_of_range(idx: int, ncols: int) -> str:
- return f"選択した列({idx + 1}列目)がデータにありません(データは{ncols}列です)。別のファイルを読み込んだ場合は、列を選び直してください。"
-
-
-def resolve_column(df: pd.DataFrame, requested: str):
- if requested.startswith("__idx__"):
- idx_text = requested.replace("__idx__", "", 1)
- if not idx_text.isdigit():
- raise UserError("列の指定が正しくありません。列を選び直してください。", detail=requested)
- idx = int(idx_text)
- if idx < 0 or idx >= len(df.columns):
- raise UserError(_column_out_of_range(idx, len(df.columns)))
- return df.columns[idx]
-
- if requested in df.columns:
- return requested
- matched = [col for col in df.columns if str(col) == requested]
- if len(matched) == 1:
- return matched[0]
- raise UserError(f"列「{requested}」がデータに見つかりません。列を選び直してください。")
-
-
-def get_series_from_request(df: pd.DataFrame, requested: str) -> tuple[pd.Series, str, int]:
- idx = parse_column_index(requested)
- if idx is not None:
- if idx < 0 or idx >= len(df.columns):
- raise UserError(_column_out_of_range(idx, len(df.columns)))
- return df.iloc[:, idx], str(df.columns[idx]), idx
-
- resolved = resolve_column(df, requested)
- found_idx = -1
- for i, col in enumerate(df.columns):
- if col == resolved:
- found_idx = i
- break
- return df[resolved], str(resolved), found_idx
-
-
-def resolve_series_requests(df: pd.DataFrame, settings: list[dict]) -> list[str]:
- """Y列が自動("")の系列に、未使用の最初の数値列を割り当てる。"""
- numeric_indices = [i for i, dtype in enumerate(df.dtypes) if pd.api.types.is_numeric_dtype(dtype)]
- candidates = numeric_indices if numeric_indices else [0]
- used_indices = set()
- resolved = []
-
- for setting in settings:
- request = setting["y_request"]
- if request:
- idx = parse_column_index(request)
- if idx is not None:
- used_indices.add(idx)
- resolved.append(request)
- continue
-
- chosen = None
- for idx in candidates:
- if idx not in used_indices:
- chosen = idx
- break
- if chosen is None:
- chosen = candidates[0]
- used_indices.add(chosen)
- resolved.append(f"__idx__{chosen}")
-
- return resolved
-
-
-def get_plot_data(df: pd.DataFrame, series_settings: list[dict], x_request: str = "") -> tuple[pd.Series, str, list[dict]]:
- """共通X列を使うデータ整形(bar 用)。x_request が空なら index。"""
- x_req = (x_request or "").strip()
- y_requests = resolve_series_requests(df, series_settings)
-
- if x_req:
- x, x_label, _ = get_series_from_request(df, x_req)
- else:
- x = pd.Series(df.index, name="index")
- x_label = "index"
-
- plot_df = pd.DataFrame({"x": x})
- series_entries = []
- for i, (setting, request) in enumerate(zip(series_settings, y_requests)):
- y_series, y_name, y_idx = get_series_from_request(df, request)
- y_key = f"y_{i}"
- label_suffix = f" [{y_idx}]" if y_idx >= 0 else ""
- data_label = f"{y_name}{label_suffix}"
- numeric, dropped, examples = coerce_numeric(y_series)
- plot_df[y_key] = numeric
- entry = dict(setting)
- entry["warnings"] = [dropped_warning(i + 1, f"系列{i + 1}({y_name})", dropped, examples)] if dropped else []
- entry["y_key"] = y_key
- entry["data_label"] = data_label
- entry["legend_label"] = setting.get("legend_name") or data_label
- series_entries.append(entry)
-
- y_keys = [entry["y_key"] for entry in series_entries]
- plot_df = plot_df.dropna(subset=y_keys, how="all")
- if plot_df.empty:
- raise UserError(_no_data_message(series_entries))
-
- for entry in series_entries:
- entry["y_series"] = plot_df[entry["y_key"]]
-
- return plot_df["x"], x_label, series_entries
-
-
-def get_per_series_xy_data(df: pd.DataFrame, series_settings: list[dict]) -> tuple[list[dict], str]:
- """系列ごとにX列を指定できるデータ整形(line / scatter 用)。"""
- y_requests = resolve_series_requests(df, series_settings)
- entries = []
- x_labels = []
-
- for setting, y_request in zip(series_settings, y_requests):
- x_request = setting.get("x_request", "").strip()
- if x_request:
- x_series, x_label, _ = get_series_from_request(df, x_request)
- else:
- x_series = pd.Series(df.index, name="index")
- x_label = "index"
-
- y_series, y_name, y_idx = get_series_from_request(df, y_request)
- label_suffix = f" [{y_idx}]" if y_idx >= 0 else ""
- data_label = f"{y_name}{label_suffix}"
-
- i = len(entries)
- warnings_list = []
- x_series = x_series.reset_index(drop=True)
- if x_request and not pd.api.types.is_numeric_dtype(x_series.dtype) and _looks_numeric(x_series):
- x_series, x_dropped, x_examples = coerce_numeric(x_series)
- if x_dropped:
- warnings_list.append(dropped_warning(i + 1, f"系列{i + 1}のX列({x_label})", x_dropped, x_examples))
- y_numeric, y_dropped, y_examples = coerce_numeric(y_series)
- if y_dropped:
- warnings_list.append(dropped_warning(i + 1, f"系列{i + 1}({y_name})", y_dropped, y_examples))
-
- entry = dict(setting)
- entry["warnings"] = warnings_list
- entry["x_series"] = x_series
- entry["y_series"] = y_numeric.reset_index(drop=True)
- entry["data_label"] = data_label
- entry["legend_label"] = setting.get("legend_name") or data_label
- entry["x_label"] = x_label
- entries.append(entry)
- x_labels.append(x_label)
-
- has_points = False
- for entry in entries:
- mask = entry["y_series"].notna() & pd.notna(entry["x_series"])
- if bool(mask.any()):
- has_points = True
- break
- if not has_points:
- raise UserError(_no_data_message(entries))
-
- common_x_label = x_labels[0] if len(set(x_labels)) == 1 else "x"
- return entries, common_x_label
-
-
-# ---------------------------------------------------------------- styling shared with the legacy override
-
-
-def apply_axis_scale_and_limits(target_ax, axis_kind: str, axis_label: str, scale: str,
- min_value: float | None, max_value: float | None) -> None:
- if scale not in {"linear", "log"}:
- raise UserError(f"「{axis_label}軸スケール」の値が不正です。linear(線形)か log(対数)を選んでください。", field=f"{axis_label}軸スケール")
- if min_value is not None and max_value is not None and min_value >= max_value:
- raise UserError(f"{axis_label}軸の範囲は最小値 < 最大値で指定してください。", field=f"{axis_label}軸の範囲")
- if scale == "log" and ((min_value is not None and min_value <= 0) or (max_value is not None and max_value <= 0)):
- raise UserError(f"{axis_label}軸を対数にする場合、範囲は0より大きい値で指定してください。", field=f"{axis_label}軸の範囲")
-
- try:
- if axis_kind == "x":
- target_ax.set_xscale(scale)
- if min_value is not None or max_value is not None:
- target_ax.set_xlim(left=min_value, right=max_value)
- elif axis_kind == "y":
- target_ax.set_yscale(scale)
- if min_value is not None or max_value is not None:
- target_ax.set_ylim(bottom=min_value, top=max_value)
- else:
- raise UserError("軸の種類が正しくありません。", detail=str(axis_kind))
- except UserError:
- raise
- except Exception as exc:
- raise UserError(
- f"{axis_label}軸のスケールまたは範囲を適用できませんでした。範囲の値を確認してください。",
- field=f"{axis_label}軸の範囲",
- detail=f"{type(exc).__name__}: {exc}",
- ) from exc
-
-
-_TIGHT_LAYOUT_ERROR = "レイアウトの自動調整に失敗しました。余白を手動で指定するか、図の大きさ・フォントサイズを小さくしてください。"
-
-
-def _tight_layout(fig) -> None:
- try:
- fig.tight_layout()
- except Exception as exc:
- raise UserError(_TIGHT_LAYOUT_ERROR, field="余白", detail=f"{type(exc).__name__}: {exc}") from exc
-
-
-def apply_subplot_margins(fig, margins: dict, use_tight_layout_if_auto: bool = False) -> None:
- provided = {k: v for k, v in (margins or {}).items() if v is not None}
- if provided:
- if use_tight_layout_if_auto and len(provided) < 4:
- try:
- fig.tight_layout()
- auto_margins = {
- "left": fig.subplotpars.left,
- "right": fig.subplotpars.right,
- "bottom": fig.subplotpars.bottom,
- "top": fig.subplotpars.top,
- }
- auto_margins.update(provided)
- fig.subplots_adjust(**auto_margins)
- return
- except Exception:
- pass
- try:
- fig.subplots_adjust(**provided)
- except Exception as exc:
- raise UserError(
- "余白の設定を適用できませんでした。左 < 右、下 < 上 になるように、0〜1の値で指定してください。",
- field="余白",
- detail=f"{type(exc).__name__}: {exc}",
- ) from exc
- elif use_tight_layout_if_auto:
- _tight_layout(fig)
-
-
-def apply_axes_decoration(ax, ax2, *, font_size: float, scales: dict, limits: dict,
- show_major_grid: bool, show_minor_grid: bool) -> None:
- """スケール・範囲・目盛(内向き)・グリッドを設定する。
-
- scales / limits は {"x","y","y2"} をキーにし、limits の値は (min, max)。
- """
- apply_axis_scale_and_limits(ax, "x", "X", scales["x"], *limits["x"])
- apply_axis_scale_and_limits(ax, "y", "Y", scales["y"], *limits["y"])
- if ax2 is not None:
- apply_axis_scale_and_limits(ax2, "y", "第2Y", scales["y2"], *limits["y2"])
-
- label_size = max(font_size - 1, 1)
- ax.tick_params(axis="x", which="both", direction="in", bottom=True, top=False, labelsize=label_size)
- ax.tick_params(axis="y", which="both", direction="in", left=True, right=False, labelsize=label_size)
- if ax2 is not None:
- ax2.tick_params(axis="y", which="both", direction="in", left=False, right=True, labelsize=label_size)
-
- if show_minor_grid:
- ax.minorticks_on()
- if ax2 is not None:
- ax2.minorticks_on()
- else:
- ax.minorticks_off()
- if ax2 is not None:
- ax2.minorticks_off()
-
- ax.grid(False, which="both", axis="both")
- if show_major_grid:
- ax.grid(True, which="major", axis="both", alpha=0.3)
- if show_minor_grid:
- ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":")
- if ax2 is not None:
- ax2.grid(False, which="both", axis="y")
- if show_major_grid:
- ax2.grid(True, which="major", axis="y", alpha=0.3)
- if show_minor_grid:
- ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":")
-
-
-def settings_scales_and_limits(settings: Settings) -> tuple[dict, dict]:
- a = settings.axes
- scales = {"x": a.x.scale, "y": a.y.scale, "y2": a.y2.scale}
- limits = {"x": (a.x.min, a.x.max), "y": (a.y.min, a.y.max), "y2": (a.y2.min, a.y2.max)}
- return scales, limits
-
-
-def figure_has_visible_artists(fig) -> bool:
- if fig is None or not hasattr(fig, "axes"):
- return False
- for axis in fig.axes:
- if len(axis.lines) > 0 or len(axis.collections) > 0 or len(axis.patches) > 0 or len(axis.images) > 0:
- return True
- if hasattr(axis, "containers") and len(axis.containers) > 0:
- return True
- return False
-
-
-# ---------------------------------------------------------------- figure creation
-
-
-def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int) -> pd.DataFrame:
- if skip_rows >= len(df_full):
- raise UserError("スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数")
- return df_full.iloc[skip_rows:].reset_index(drop=True)
-
-
-def create_figure(df_full: pd.DataFrame, settings: Settings) -> FigureResult:
- """設定どおりの Figure を作る。失敗時は作りかけの Figure を閉じて例外を送出する。"""
- plot = settings.plot
- skip_rows = plot.skip_rows
- df = slice_skip_rows(df_full, skip_rows)
- series_settings = series_to_legacy(settings)
- plot_type = plot.type
- font_size = plot.font_size
-
- fig, ax = plt.subplots(figsize=(plot.figure.width, plot.figure.height), dpi=100)
- try:
- ax2 = None
- plotted_labels = []
-
- def target_axis(entry):
- nonlocal ax2
- if entry.get("use_secondary_axis", False):
- if ax2 is None:
- ax2 = ax.twinx()
- return ax2
- return ax
-
- if plot_type in {"line", "scatter"}:
- all_entries, x_label = get_per_series_xy_data(df, series_settings)
- for entry in all_entries:
- x_series = entry["x_series"]
- y_series = entry["y_series"]
- mask = y_series.notna() & pd.notna(x_series)
- if not bool(mask.any()):
- continue
- target_ax = target_axis(entry)
- if plot_type == "line":
- marker = "o" if entry["marker_size"] > 0 else ""
- marker_size_for_line = entry["marker_size"] ** 0.5 if entry["marker_size"] > 0 else 0
- target_ax.plot(
- x_series[mask], y_series[mask],
- color=entry["color"], linewidth=entry["line_width"], linestyle=entry["line_style"],
- marker=marker, markersize=marker_size_for_line, label=entry["legend_label"],
- )
- else:
- target_ax.scatter(
- x_series[mask], y_series[mask],
- color=entry["color"], s=entry["marker_size"], label=entry["legend_label"],
- )
- plotted_labels.append(entry["legend_label"])
- else:
- x, x_label, all_entries = get_plot_data(df, series_settings, plot.x_column)
- if len(all_entries) == 1:
- entry = all_entries[0]
- y_series = entry["y_series"]
- mask = y_series.notna() & pd.notna(x)
- if bool(mask.any()):
- target_axis(entry).bar(
- x[mask], y_series[mask],
- color=entry["color"], linewidth=entry["line_width"], linestyle=entry["line_style"],
- label=entry["legend_label"],
- )
- plotted_labels.append(entry["legend_label"])
- else:
- x_labels = [str(v) for v in x.tolist()]
- positions = list(range(len(x_labels)))
- width = 0.8 / len(all_entries)
- for i, entry in enumerate(all_entries):
- y_values = [float(v) if pd.notna(v) else 0.0 for v in entry["y_series"].tolist()]
- offset = -0.4 + (width / 2.0) + (i * width)
- target_axis(entry).bar(
- [p + offset for p in positions], y_values, width=width,
- color=entry["color"], linewidth=entry["line_width"], linestyle=entry["line_style"],
- label=entry["legend_label"],
- )
- plotted_labels.append(entry["legend_label"])
- if positions:
- if len(positions) > 25:
- step = max(1, len(positions) // 25)
- tick_positions = [positions[i] for i in range(0, len(positions), step)]
- tick_labels = [x_labels[i] for i in range(0, len(x_labels), step)]
- else:
- tick_positions = positions
- tick_labels = x_labels
- ax.set_xticks(tick_positions)
- ax.set_xticklabels(tick_labels, rotation=45, ha="right")
-
- if not plotted_labels:
- raise UserError("描画可能なデータがありません。")
-
- primary_labels = [e["data_label"] for e in all_entries if not e.get("use_secondary_axis", False)]
- secondary_labels = [e["data_label"] for e in all_entries if e.get("use_secondary_axis", False)]
- axes = settings.axes
-
- if plot.title:
- ax.set_title(plot.title, fontsize=font_size + 2)
- ax.set_xlabel(axes.x.label or x_label, fontsize=font_size)
- if axes.y.label:
- ax.set_ylabel(axes.y.label, fontsize=font_size)
- else:
- ax.set_ylabel(primary_labels[0] if len(primary_labels) == 1 else "values", fontsize=font_size)
- if ax2 is not None:
- right_default = secondary_labels[0] if len(secondary_labels) == 1 else "values"
- ax2.set_ylabel(axes.y2.label or right_default, fontsize=font_size)
-
- scales, limits = settings_scales_and_limits(settings)
- apply_axes_decoration(
- ax, ax2, font_size=font_size, scales=scales, limits=limits,
- show_major_grid=plot.grid_major, show_minor_grid=plot.grid_minor,
- )
-
- if plot.legend_location != "none" and plotted_labels:
- handles, labels = ax.get_legend_handles_labels()
- if ax2 is not None:
- h2, l2 = ax2.get_legend_handles_labels()
- handles, labels = handles + h2, labels + l2
- if labels:
- ax.legend(handles, labels, loc=plot.legend_location, fontsize=max(font_size - 1, 1), edgecolor="black")
-
- m: MarginSettings = plot.margins
- apply_subplot_margins(
- fig, {"left": m.left, "right": m.right, "bottom": m.bottom, "top": m.top}, use_tight_layout_if_auto=True,
- )
- except UserError:
- plt.close(fig)
- raise
- except (TypeError, ValueError, OverflowError) as exc:
- plt.close(fig)
- raise UserError(
- "グラフを描画できませんでした。X列・Y列に指定した列の値の形式(数値と文字列の混在など)や、設定の値を確認してください。",
- detail=f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc().strip()}",
- ) from exc
- except BaseException:
- plt.close(fig)
- raise
- collected = [w for entry in all_entries for w in entry.get("warnings", [])]
- return FigureResult(fig=fig, plotted_count=len(plotted_labels), skip_rows=skip_rows, warnings=collected)
diff --git a/py/mplgui/runner.py b/py/mplgui/runner.py
index e845530..e1a8add 100644
--- a/py/mplgui/runner.py
+++ b/py/mplgui/runner.py
@@ -1,88 +1,38 @@
-"""Figure → 画像バイト列、保存ファイル名、旧 Pythonコード(beta) タブの実行(DOM / js には依存しない)。
+"""スクリプトの実行と、Figure → 画像バイト列(DOM / js には依存しない)。
-カスタムコードは設定とは別の引数で受け取る。GUI 設定による上書き(A8)は従来どおり残している(Phase 2 で変更予定)。
+GUI の描画も、ユーザーが編集したコードの実行も、ここの run_script() 1本を通る。
+GUI の設定を、実行後の Figure に上書き適用することはしない(A8)。
"""
from __future__ import annotations
import base64
import builtins
+import contextlib
import io
-import re
+import linecache
+import os
import traceback
-from functools import partial
+import warnings
+from dataclasses import dataclass, field
+from pathlib import Path
+import matplotlib
import matplotlib.pyplot as plt
-import pandas as pd
+from matplotlib.figure import Figure
from matplotlib.layout_engine import PlaceHolderLayoutEngine
from .errors import UserError
-from .plotting import (
- FigureResult,
- apply_axes_decoration,
- apply_subplot_margins,
- create_figure,
- figure_has_visible_artists,
- get_per_series_xy_data,
- get_plot_data,
- resolve_series_requests,
- settings_scales_and_limits,
- slice_skip_rows,
-)
-from .settings import Settings, to_legacy_dict
-
-DEFAULT_CUSTOM_PLOT_CODE = """# 利用可能オブジェクト:
-# - df: skipRows適用後のDataFrame
-# - current_df: 読み込み直後のDataFrame(skipRows未適用)
-# - settings: GUI設定(dict)
-# - plt, pd
-# 必須:
-# - fig に Figure を代入
-# 任意:
-# - plotted_count (int) を設定(未設定時は自動判定)
-
-fig, ax = plt.subplots(figsize=(settings["fig_width"], settings["fig_height"]), dpi=100)
-
-if df.shape[1] < 2:
- raise ValueError("2列以上のデータが必要です。")
-
-x = pd.to_numeric(df.iloc[:, 0], errors="coerce")
-y = pd.to_numeric(df.iloc[:, 1], errors="coerce")
-mask = x.notna() & y.notna()
-if not bool(mask.any()):
- raise ValueError("先頭2列に描画可能な数値データがありません。")
-
-series_settings = settings.get("series_settings", [])
-default_legend = ""
-if isinstance(series_settings, list) and len(series_settings) > 0:
- default_legend = str(series_settings[0].get("legend_name", "") or "").strip()
-if not default_legend:
- default_legend = str(df.columns[1])
-
-ax.plot(
- x[mask],
- y[mask],
- color="#005AFF",
- linewidth=2.0,
- marker="o",
- markersize=4,
- label=default_legend,
-)
-ax.set_title(settings["title"])
-ax.set_xlabel(settings["x_label"] or str(df.columns[0]))
-ax.set_ylabel(settings["y_label"] or str(df.columns[1]))
-ax.grid(True, alpha=0.3)
-ax.legend(loc="best", edgecolor="black")
-fig.tight_layout()
-
-plotted_count = 1
-"""
+from .formats import FORMATS, build_filename, savefig_kwargs # noqa: F401 (build_filename は runner からも使えるようにする)
+
+SCRIPT_FILENAME = "plot.py"
+CODE_FIELD = "Pythonコード"
+NO_FIGURE_MESSAGE = "図が作られませんでした。fig, ax = plt.subplots() などで図を作ってください。"
+
+# plt.show() は Agg バックエンドでは何も表示できず、この警告を出す。実行中はこれだけを無視する。
+_SHOW_WARNING = r"FigureCanvasAgg is non-interactive, and thus cannot be shown"
-_FORMATS = {
- "png": ("png", "image/png", "png"),
- "jpg": ("jpeg", "image/jpeg", "jpg"),
- "svg": ("svg", "image/svg+xml", "svg"),
- "pdf": ("pdf", "application/pdf", "pdf"),
-}
+
+# ---------------------------------------------------------------- image output
def drop_placeholder_layout_engine(fig) -> None:
@@ -98,24 +48,16 @@ def drop_placeholder_layout_engine(fig) -> None:
def figure_to_bytes(fig, file_format: str, transparent: bool = False, dpi: int = 120) -> tuple[bytes, str, str]:
"""Figure を指定形式のバイト列にする。戻り値は (bytes, mime, 拡張子)。"""
fmt = (file_format or "").lower()
- if fmt not in _FORMATS:
+ if fmt not in FORMATS:
raise UserError("「保存形式」の値が不正です。png / jpg / svg / pdf から選んでください。", field="保存形式")
if transparent and fmt not in {"png", "svg"}:
raise UserError("背景透過を有効にした場合、保存形式はpngまたはsvgを選択してください。", field="保存形式")
- save_format, mime, ext = _FORMATS[fmt]
-
- save_kwargs = {}
- if save_format in {"png", "jpeg"}:
- save_kwargs["dpi"] = dpi
- if save_format == "jpeg":
- save_kwargs["facecolor"] = "white"
- if transparent:
- save_kwargs["transparent"] = True
+ save_format, mime, ext = FORMATS[fmt]
buffer = io.BytesIO()
drop_placeholder_layout_engine(fig)
try:
- fig.savefig(buffer, format=save_format, **save_kwargs)
+ fig.savefig(buffer, format=save_format, **savefig_kwargs(fmt, transparent, dpi))
except (ValueError, OverflowError, MemoryError) as exc:
raise UserError(
"画像を書き出せませんでした。図幅・図高さを小さくするか、保存形式を変えてください。",
@@ -130,174 +72,208 @@ def figure_to_data_uri(fig, file_format: str, transparent: bool = False, dpi: in
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}", ext
-_INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
+def figure_summary(fig) -> list[dict]:
+ """Figure の中身の要約(軸ごと)。JSON にできる値だけで、E2E と生成コードの一致確認に使う。"""
+ out = []
+ for ax in fig.axes:
+ legend = ax.get_legend()
+ out.append(
+ {
+ "title": ax.get_title(),
+ "xlabel": ax.get_xlabel(),
+ "ylabel": ax.get_ylabel(),
+ "xlim": [float(v) for v in ax.get_xlim()],
+ "ylim": [float(v) for v in ax.get_ylim()],
+ "xscale": ax.get_xscale(),
+ "yscale": ax.get_yscale(),
+ "lines": len(ax.lines),
+ "collections": len(ax.collections),
+ "patches": len(ax.patches),
+ "legend": [t.get_text() for t in legend.get_texts()] if legend is not None else [],
+ "xticklabels": [t.get_text() for t in ax.get_xticklabels()],
+ }
+ )
+ return out
+
+
+# ---------------------------------------------------------------- script execution
+
+# 例外の型 → 日本語の説明(上から順に isinstance で判定する。サブクラスを先に並べる)
+_HINTS: tuple[tuple[type, str], ...] = (
+ (IndentationError, "インデント(字下げ)が正しくありません"),
+ (SyntaxError, "書き方(文法)が正しくありません"),
+ (NameError, "定義されていない名前を使っています"),
+ (FileNotFoundError, "ファイルが見つかりません"),
+ (ModuleNotFoundError, "読み込めないライブラリがあります(使えるのは pandas, matplotlib, numpy です)"),
+ (ImportError, "読み込めないライブラリや名前があります(使えるのは pandas, matplotlib, numpy です)"),
+ (KeyError, "存在しないキー(列名など)を指定しています"),
+ (IndexError, "範囲の外の位置を指定しています"),
+ (TypeError, "型が合わない操作をしています"),
+ (ValueError, "値が正しくありません"),
+ (AttributeError, "存在しない属性やメソッドを使っています"),
+ (ZeroDivisionError, "0で割り算をしています"),
+ (SystemExit, "exit() などでスクリプトが途中で終了しました"),
+)
-def build_filename(raw_filename: str, ext: str) -> str:
- """保存ファイル名を作る。空欄時は plot.。入力中の拡張子は取り除いて ext を付ける。"""
- raw_filename = _INVALID_FILENAME_CHARS.sub("_", (raw_filename or "").strip())
- if raw_filename:
- filename_base = raw_filename.rstrip(".")
- if "." in filename_base:
- filename_base = filename_base.rsplit(".", 1)[0]
- filename_base = filename_base.strip() or "plot"
- return f"{filename_base}.{ext}"
- return f"plot.{ext}"
+def japanese_hint(exc: BaseException) -> str | None:
+ for kind, hint in _HINTS:
+ if isinstance(exc, kind):
+ return hint
+ return None
-# ---------------------------------------------------------------- legacy custom code
+class ScriptError(UserError):
+ """スクリプトの実行時エラー。message は日本語の要約。traceback は plot.py の行だけ、detail は全体。"""
+ def __init__(self, message, *, field=None, detail=None, traceback_text="", line=None, output="", exc=None):
+ super().__init__(message, field=field, detail=detail)
+ self.traceback_text = traceback_text
+ self.line = line
+ self.output = output
+ self.exc = exc
-def _apply_gui_overrides_to_custom_figure(fig, legacy: dict, settings: Settings) -> None:
- """従来どおり、GUI設定(タイトル・ラベル・スケール・目盛・グリッド・凡例)でカスタムコードの図を上書きする。"""
- if fig is None or not hasattr(fig, "axes") or len(fig.axes) == 0:
- return
+ def to_dict(self) -> dict:
+ out = super().to_dict()
+ out["traceback"] = self.traceback_text
+ out["line"] = self.line
+ return out
+
+
+def _user_frames(tb) -> list[traceback.FrameSummary]:
+ return [f for f in traceback.extract_tb(tb) if f.filename == SCRIPT_FILENAME]
+
+
+def _format_user_traceback(exc: BaseException) -> tuple[str, int | None]:
+ """plot.py の行だけのトレースバック文字列と、最後の plot.py の行番号。"""
+ frames = _user_frames(exc.__traceback__)
+ lines: list[str] = []
+ line: int | None = None
+ if frames:
+ lines.append("Traceback (most recent call last):\n")
+ lines.extend(traceback.format_list(frames))
+ line = frames[-1].lineno
+ if isinstance(exc, SyntaxError) and exc.lineno:
+ line = exc.lineno
+ lines.extend(traceback.format_exception_only(type(exc), exc))
+ return "".join(lines), line
- ax = fig.axes[0]
- ax2 = fig.axes[1] if len(fig.axes) >= 2 else None
- font_size = float(legacy.get("font_size", 15) or 15)
- legend_location = legacy["legend_location"]
-
- if legacy["title"]:
- ax.set_title(legacy["title"], fontsize=font_size + 2)
- ax.set_xlabel(legacy["x_label"] or ax.get_xlabel(), fontsize=font_size)
- ax.set_ylabel(legacy["y_label"] or ax.get_ylabel(), fontsize=font_size)
- if ax2 is not None:
- ax2.set_ylabel(legacy["y2_label"] or ax2.get_ylabel(), fontsize=font_size)
-
- scales, limits = settings_scales_and_limits(settings)
- apply_axes_decoration(
- ax, ax2, font_size=font_size, scales=scales, limits=limits,
- show_major_grid=legacy["show_major_grid"], show_minor_grid=legacy["show_minor_grid"],
- )
-
- for axis in fig.axes:
- existing_legend = axis.get_legend()
- if existing_legend is not None:
- existing_legend.remove()
-
- if legend_location == "none":
- return
- handles = []
- labels = []
- for axis in fig.axes:
- h, lab = axis.get_legend_handles_labels()
- handles.extend(h)
- labels.extend(lab)
- if not labels:
+def _error_message(exc: BaseException, line: int | None) -> str:
+ kind = type(exc).__name__
+ hint = japanese_hint(exc)
+ what = f"{kind}: {hint}" if hint else kind
+ where = f"{line}行目、" if line else ""
+ return f"Pythonコードの実行中にエラーが発生しました({where}{what})。詳細は「{CODE_FIELD}」タブに表示しています。"
+
+
+@dataclass
+class ScriptRun:
+ fig: Figure
+ output: str
+ namespace: dict = field(default_factory=dict)
+
+
+@contextlib.contextmanager
+def _working_directory(path):
+ if path is None:
+ yield
return
+ previous = os.getcwd()
+ os.chdir(path)
+ try:
+ yield
+ finally:
+ os.chdir(previous)
- for i, setting in enumerate(legacy.get("series_settings", [])):
- if i >= len(labels):
- break
- legend_name = str(setting.get("legend_name", "") or "").strip()
- if legend_name:
- labels[i] = legend_name
-
- ax.legend(handles, labels, loc=legend_location, fontsize=max(font_size - 1, 1), edgecolor="black")
-
-
-def run_custom_code(code: str, df_full: pd.DataFrame, settings: Settings) -> FigureResult:
- """Pythonコード(beta)を実行し、fig を取り出す。名前空間は従来と同じ。"""
- skip_rows = settings.plot.skip_rows
- df = slice_skip_rows(df_full, skip_rows)
- if not (code or "").strip():
- raise UserError("カスタムコードが空です。", field="カスタムコード")
-
- legacy = to_legacy_dict(settings)
- series_legacy = legacy["series_settings"]
- x_request = settings.plot.x_column
- helper_plot_data = partial(get_plot_data, x_request=x_request)
- context = {
- "df": df,
- "current_df": df_full,
- "settings": legacy,
- "series_settings": series_legacy,
- "helpers": {
- "get_plot_data": helper_plot_data,
- "get_per_series_xy_data": get_per_series_xy_data,
- "resolve_series_requests": resolve_series_requests,
- },
- }
- local_scope = {
- "df": df.copy(),
- "current_df": df_full.copy(),
- "settings": legacy,
- "ctx": context,
- "series_settings": series_legacy,
- "get_plot_data": helper_plot_data,
- "get_per_series_xy_data": get_per_series_xy_data,
- "resolve_series_requests": resolve_series_requests,
- "plt": plt,
- "pd": pd,
- "fig": None,
- "plotted_count": None,
- "skip_rows": skip_rows,
- }
- global_scope = {"__builtins__": builtins, "plt": plt, "pd": pd}
- before = set(plt.get_fignums())
+def _pick_figure(namespace: dict, before: set[int]):
+ fig = namespace.get("fig")
+ if isinstance(fig, Figure):
+ return fig
+ new_numbers = [n for n in plt.get_fignums() if n not in before]
+ if new_numbers:
+ return plt.figure(new_numbers[-1])
+ return None
+
+
+@contextlib.contextmanager
+def run_script(code: str, *, injected: dict | None = None, cwd: str | Path | None = None):
+ """コードを実行し、Figure と print の出力を返す(with ブロックを抜けると、実行中に作られた図を全て閉じる)。
- def close_new_figures(keep=None):
+ - rcParams は実行前に戻す(スクリプトが変えた設定を次の実行に持ち越さない)。
+ - 実行中に作られた図は、ブロックを抜けるときに閉じる(呼び出し側は with の中で画像にする)。
+ - 失敗は ScriptError(compile / 実行時の例外)または UserError(図が作られなかった)。
+ """
+ before = set(plt.get_fignums())
+ buffer = io.StringIO()
+ namespace: dict = {"__name__": "__main__", "__builtins__": builtins, **(injected or {})}
+ try:
+ with matplotlib.rc_context():
+ try:
+ _execute(code, namespace, buffer, cwd)
+ except UserError:
+ raise
+ except (Exception, SystemExit) as exc:
+ text, line = _format_user_traceback(exc)
+ raise ScriptError(
+ _error_message(exc, line),
+ field=CODE_FIELD,
+ detail="".join(traceback.format_exception(exc)),
+ traceback_text=text,
+ line=line,
+ output=buffer.getvalue(),
+ exc=exc,
+ ) from exc
+ fig = _pick_figure(namespace, before)
+ if fig is None:
+ err = UserError(NO_FIGURE_MESSAGE, field=CODE_FIELD)
+ err.output = buffer.getvalue()
+ raise err
+ yield ScriptRun(fig=fig, output=buffer.getvalue(), namespace=namespace)
+ finally:
+ linecache.cache.pop(SCRIPT_FILENAME, None) # トレースバックの整形は終わっている
for num in set(plt.get_fignums()) - before:
- if keep is not None and getattr(keep, "number", None) == num:
- continue
plt.close(num)
- fig = None
- try:
- try:
- exec(code, global_scope, local_scope)
- except UserError:
- raise
- except Exception as exc:
- raise UserError(
- f"カスタムコードの実行に失敗しました: {exc}", field="カスタムコード", detail=traceback.format_exc()
- ) from exc
-
- fig = local_scope.get("fig")
- if fig is None:
- raise UserError("カスタムコードで fig を生成してください。", field="カスタムコード")
- if not hasattr(fig, "savefig"):
- raise UserError("fig には matplotlib.figure.Figure を設定してください。", field="カスタムコード")
- _apply_gui_overrides_to_custom_figure(fig, legacy, settings)
- m = settings.plot.margins
- apply_subplot_margins(
- fig, {"left": m.left, "right": m.right, "bottom": m.bottom, "top": m.top}, use_tight_layout_if_auto=True
- )
- plotted_raw = local_scope.get("plotted_count")
- if plotted_raw is None:
- plotted_count = 1 if figure_has_visible_artists(fig) else 0
- else:
- try:
- plotted_count = int(plotted_raw)
- except Exception:
- raise UserError("plotted_count は整数で指定してください。", field="カスタムコード") from None
- if plotted_count <= 0 and figure_has_visible_artists(fig):
- plotted_count = 1
- if plotted_count <= 0:
- raise UserError("描画可能なデータがありません。")
-
- custom_skip_rows = local_scope.get("skip_rows", skip_rows)
+def _execute(code: str, namespace: dict, buffer: io.StringIO, cwd) -> None:
+ lines = code.splitlines(True)
+ linecache.cache[SCRIPT_FILENAME] = (len(code), None, lines, SCRIPT_FILENAME)
+ with _working_directory(cwd), contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
+ compiled = compile(code, SCRIPT_FILENAME, "exec")
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore", message=_SHOW_WARNING, category=UserWarning)
+ exec(compiled, namespace) # noqa: S102 - 利用者自身のコード(または生成したコード)を実行する
+
+
+
+
+@dataclass
+class ImageResult:
+ data: bytes
+ mime: str
+ ext: str
+ output: str
+ summary: list
+
+
+def run_to_image(
+ code: str,
+ *,
+ file_format: str = "png",
+ transparent: bool = False,
+ dpi: int = 100,
+ injected: dict | None = None,
+ cwd: str | Path | None = None,
+) -> ImageResult:
+ """コードを実行して Figure を画像にする。失敗した例外には、実行中の出力を output 属性で付ける。"""
+ with run_script(code, injected=injected, cwd=cwd) as run:
+ output = run.output
try:
- custom_skip_rows = int(custom_skip_rows)
- except Exception:
- custom_skip_rows = skip_rows
- if custom_skip_rows < 0:
- custom_skip_rows = skip_rows
- except BaseException:
- close_new_figures()
- if fig is not None and hasattr(fig, "number"):
- plt.close(fig)
- raise
- close_new_figures(keep=fig)
- return FigureResult(fig=fig, plotted_count=plotted_count, skip_rows=custom_skip_rows)
-
-
-def make_figure(df_full: pd.DataFrame, settings: Settings, custom_code: str | None = None) -> FigureResult:
- """custom_code が None なら GUI 設定で、文字列ならそのコードで Figure を作る。"""
- if custom_code is not None:
- return run_custom_code(custom_code, df_full, settings)
- return create_figure(df_full, settings)
+ data, mime, ext = figure_to_bytes(run.fig, file_format, transparent, dpi)
+ summary = figure_summary(run.fig)
+ except UserError as exc:
+ exc.output = output
+ raise
+ return ImageResult(data=data, mime=mime, ext=ext, output=output, summary=summary)
diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py
index c5d8e19..685b822 100644
--- a/py/mplgui/settings.py
+++ b/py/mplgui/settings.py
@@ -396,67 +396,20 @@ def parse_settings(raw: dict | None) -> Settings:
if any(s.secondary_axis for s in series):
_check_axis_range(axes.y2, "第2Y")
- sv = merged["save"]
- save = SaveSettings(
+ save = _parse_save(merged["save"])
+ return Settings(version=SCHEMA_VERSION, load=load, plot=plot, axes=axes, series=series, save=save)
+
+
+def _parse_save(sv: dict) -> SaveSettings:
+ return SaveSettings(
filename=_string(sv["filename"], "保存ファイル名").strip(),
format=_choice(str(sv["format"]).strip().lower() if isinstance(sv["format"], str) else sv["format"], "保存形式", SAVE_FORMATS),
transparent=_boolean(sv["transparent"], "背景を透過して保存"),
)
- return Settings(version=SCHEMA_VERSION, load=load, plot=plot, axes=axes, series=series, save=save)
-
-
-# ---------------------------------------------------------------- legacy (custom-code tab)
-def series_to_legacy(settings: Settings) -> list[dict]:
- """系列設定を従来の snake_case dict のリストにする(旧 _collect_series_settings と同形)。"""
- return [
- {
- "y_request": s.y,
- "x_request": s.x,
- "color": s.color,
- "line_width": s.line_width,
- "line_style": s.line_style,
- "marker_size": s.effective_marker_size(settings.plot.type),
- "legend_name": s.label,
- "use_secondary_axis": s.secondary_axis,
- }
- for s in settings.series
- ]
-
-
-def to_legacy_dict(settings: Settings) -> dict:
- """Pythonコード(beta)タブに渡す従来形式の settings dict(キーは従来と同じ)。"""
- p, a = settings.plot, settings.axes
- m = p.margins
- return {
- "skip_rows": p.skip_rows,
- "plot_type": p.type,
- "title": p.title,
- "x_label": a.x.label,
- "y_label": a.y.label,
- "y2_label": a.y2.label,
- "x_scale": a.x.scale,
- "y_scale": a.y.scale,
- "y2_scale": a.y2.scale,
- "x_min": a.x.min,
- "x_max": a.x.max,
- "y_min": a.y.min,
- "y_max": a.y.max,
- "y2_min": a.y2.min,
- "y2_max": a.y2.max,
- "show_major_grid": p.grid_major,
- "show_minor_grid": p.grid_minor,
- "legend_location": p.legend_location,
- "font_size": p.font_size,
- "fig_width": p.figure.width,
- "fig_height": p.figure.height,
- "subplot_margins": {"left": m.left, "right": m.right, "bottom": m.bottom, "top": m.top},
- "subplot_left": m.left,
- "subplot_right": m.right,
- "subplot_bottom": m.bottom,
- "subplot_top": m.top,
- "x_column_request": p.x_column,
- "series_settings": series_to_legacy(settings),
- "series_settings_error": "",
- }
+def parse_save_settings(raw: dict | None) -> SaveSettings:
+ """保存設定だけを検証する(編集モードの保存を、使わない描画設定の不備で止めないため)。"""
+ raw = raw if isinstance(raw, dict) else {}
+ _check_version(raw)
+ return _parse_save(_merge(default_settings()["save"], raw.get("save")))
diff --git a/pyscript.toml b/pyscript.toml
index 035789b..08787ef 100644
--- a/pyscript.toml
+++ b/pyscript.toml
@@ -12,7 +12,9 @@ packages_cache = "passthrough"
"./py/mplgui/errors.py" = "./mplgui/errors.py"
"./py/mplgui/settings.py" = "./mplgui/settings.py"
"./py/mplgui/loader.py" = "./mplgui/loader.py"
-"./py/mplgui/plotting.py" = "./mplgui/plotting.py"
+"./py/mplgui/dataprep.py" = "./mplgui/dataprep.py"
+"./py/mplgui/codegen.py" = "./mplgui/codegen.py"
+"./py/mplgui/formats.py" = "./mplgui/formats.py"
"./py/mplgui/runner.py" = "./mplgui/runner.py"
"./py/mplgui/fonts.py" = "./mplgui/fonts.py"
"./py/mplgui/runtime.py" = "./mplgui/runtime.py"
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index 01fa83d..45a30ff 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -127,3 +127,86 @@ def image_generation(page) -> str:
def status_warnings(page) -> list[str]:
return page.evaluate(STATUS_WARNINGS_JS)
+
+
+# --- Pythonコードタブ ---
+# 、#customPyCode(data-generation)、#codeOutput(data-kind)、#codeSyncStatus、
+# `#plotArea img` の data-summary(図の要約 JSON)。
+def code_mode(page) -> str:
+ return page.evaluate("document.documentElement.dataset.codeMode || ''")
+
+
+def code_text(page) -> str:
+ return page.input_value("#customPyCode")
+
+
+def code_generation(page) -> str:
+ return page.evaluate("document.getElementById('customPyCode').dataset.generation || ''")
+
+
+def open_code_tab(page):
+ page.click("#tabCodeBtn")
+
+
+def wait_code_generated(page, timeout=120_000):
+ """GUI 同期のコードが、表示中の画像と同じ世代で textarea に入るまで待つ。"""
+ page.wait_for_function(
+ """() => {
+ const img = document.querySelector('#plotArea img');
+ const code = document.getElementById('customPyCode');
+ return !!img && !!code.value && code.dataset.generation === img.dataset.generation;
+ }""",
+ timeout=timeout,
+ )
+
+
+def enter_edit_mode(page):
+ open_code_tab(page)
+ page.click("#editCodeBtn")
+ page.wait_for_function("document.documentElement.dataset.codeMode === 'edit'")
+
+
+def wait_image_generation_changed(page, previous, timeout=120_000):
+ """表示中の画像の世代が previous から変わり、描画要求が残っていないところまで待つ。新しい世代を返す。"""
+ page.wait_for_function(
+ """(prev) => {
+ const d = document.documentElement.dataset;
+ const img = document.querySelector('#plotArea img');
+ return d.renderState === 'idle' && !!img && img.dataset.generation !== prev
+ && img.dataset.generation === d.renderGeneration;
+ }""",
+ arg=previous,
+ timeout=timeout,
+ )
+ return image_generation(page)
+
+
+def run_edited_code(page, how="button"):
+ """編集モードのコードを実行し、描画が一段落するまで待つ(画像の世代が進む)。"""
+ previous = image_generation(page)
+ if how == "button":
+ page.click("#applyCustomCodeBtn")
+ else:
+ page.focus("#customPyCode")
+ page.keyboard.press("ControlOrMeta+Enter")
+ return wait_image_generation_changed(page, previous)
+
+
+def wait_run_finished(page, timeout=120_000):
+ """実行(成功・失敗とも)が終わるまで待つ。"""
+ page.wait_for_function("document.documentElement.dataset.renderState === 'idle'", timeout=timeout)
+
+
+def image_summary(page):
+ import json
+
+ raw = page.evaluate("(document.querySelector('#plotArea img') || {dataset: {}}).dataset.summary || ''")
+ return json.loads(raw) if raw else None
+
+
+def code_output(page) -> str:
+ return page.inner_text("#codeOutput")
+
+
+def code_output_kind(page) -> str:
+ return page.get_attribute("#codeOutput", "data-kind") or ""
diff --git a/tests/e2e/run_script_summary.py b/tests/e2e/run_script_summary.py
new file mode 100644
index 0000000..4be2415
--- /dev/null
+++ b/tests/e2e/run_script_summary.py
@@ -0,0 +1,12 @@
+"""生成したスクリプトを CPython で実行し、図の要約(runner.figure_summary)を JSON で出力する小さな補助。
+
+使い方: python run_script_summary.py SCRIPT.py (MPLBACKEND=Agg、PYTHONPATH に py/ を足す。cwd はデータのあるフォルダ)
+"""
+import json
+import runpy
+import sys
+
+from mplgui.runner import figure_summary
+
+namespace = runpy.run_path(sys.argv[1])
+print("FIGURE_SUMMARY:" + json.dumps(figure_summary(namespace["fig"])))
diff --git a/tests/e2e/test_code_tab.py b/tests/e2e/test_code_tab.py
new file mode 100644
index 0000000..ad0ff2c
--- /dev/null
+++ b/tests/e2e/test_code_tab.py
@@ -0,0 +1,210 @@
+"""Pythonコードタブ(P2〜P6): 同期表示、コピー・.py 保存、編集と実行、GUI から再生成、エラーと出力の表示。"""
+import pytest
+
+from helpers import (
+ code_generation,
+ code_mode,
+ code_output,
+ code_output_kind,
+ code_text,
+ enter_edit_mode,
+ image_generation,
+ image_summary,
+ load_fixture,
+ open_code_tab,
+ plot_src,
+ run_edited_code,
+ save_plot,
+ status_kind,
+ status_text,
+ wait_app_ready,
+ wait_code_generated,
+ wait_run_finished,
+ wait_settled,
+)
+
+pytestmark = pytest.mark.e2e
+
+TITLE = "元タイトル"
+
+
+@pytest.fixture
+def app(page, app_url, fixtures_dir, console_log):
+ """utf8.csv を読み込み、タイトルを設定して描画・コード生成まで済ませたページ。"""
+ page.goto(app_url)
+ wait_app_ready(page)
+ load_fixture(page, fixtures_dir / "utf8.csv")
+ wait_settled(page)
+ page.fill("#title", TITLE)
+ wait_settled(page)
+ wait_code_generated(page)
+ return page
+
+
+def test_tab_label_and_generated_script_in_sync_mode(page, app_url, fixtures_dir, console_log):
+ page.goto(app_url)
+ assert page.inner_text("#tabCodeBtn") == "Pythonコード"
+ assert page.get_attribute("#customPyCode", "placeholder") == "ファイルを読み込むと、ここに Python コードが表示されます。"
+ wait_app_ready(page)
+ load_fixture(page, fixtures_dir / "utf8.csv")
+ wait_settled(page)
+ wait_code_generated(page)
+ open_code_tab(page)
+ code = code_text(page)
+ assert "import matplotlib.pyplot as plt" in code and 'DATA_FILE = "utf8.csv"' in code
+ assert page.get_attribute("#customPyCode", "readonly") is not None
+ assert code_mode(page) == "sync"
+ assert "同期しています" in page.inner_text("#codeSyncStatus")
+ assert page.is_visible("#editCodeBtn") and page.is_visible("#copyCodeBtn") and page.is_visible("#downloadCodeBtn")
+ assert not page.is_visible("#applyCustomCodeBtn") and not page.is_visible("#resetCustomCodeBtn")
+ assert not page.query_selector("#useCustomCode")
+ assert page.get_attribute("#codeLineNumbers", "aria-hidden") == "true"
+ assert page.inner_text("#codeLineNumbers").split("\n")[-1] == str(len(code.rstrip("\n").split("\n")) + 1)
+ assert page.get_attribute("#codeOutput", "aria-live") == "polite"
+ assert code_output(page) == "" # 空のときの案内文は CSS(:empty::before)なので、テキストは空
+
+
+def test_gui_change_updates_code_with_same_generation(app):
+ page = app
+ assert f'"{TITLE}"' in code_text(page)
+ page.fill("#title", "新しいタイトル")
+ wait_settled(page)
+ wait_code_generated(page)
+ assert '"新しいタイトル"' in code_text(page) and TITLE not in code_text(page)
+ assert code_generation(page) == image_generation(page)
+ assert image_summary(page)[0]["title"] == "新しいタイトル"
+
+
+def test_copy_button_copies_textarea(app):
+ page = app
+ page.context.grant_permissions(["clipboard-read", "clipboard-write"])
+ open_code_tab(page)
+ page.click("#copyCodeBtn")
+ page.wait_for_function("document.getElementById('status').textContent.includes('コピーしました')")
+ assert status_text(page) == "Pythonコードをコピーしました。"
+ assert page.evaluate("navigator.clipboard.readText()") == code_text(page)
+
+
+def test_download_py_uses_save_filename_rules(app):
+ page = app
+ open_code_tab(page)
+ with page.expect_download() as dl:
+ page.click("#downloadCodeBtn")
+ download = dl.value
+ assert download.suggested_filename == "plot.py"
+ assert open(download.path(), encoding="utf-8").read() == code_text(page)
+ assert status_text(page) == "Pythonコードを保存しました: plot.py"
+
+ page.fill("#saveFilename", "my fig.png")
+ with page.expect_download() as dl2:
+ page.click("#downloadCodeBtn")
+ assert dl2.value.suggested_filename == "my fig.py"
+
+
+def test_edit_and_run_changes_the_figure(app):
+ page = app
+ before = image_generation(page)
+ enter_edit_mode(page)
+ assert code_mode(page) == "edit"
+ assert page.get_attribute("#customPyCode", "readonly") is None
+ assert "同期していません" in page.inner_text("#codeSyncStatus")
+ assert page.is_visible("#applyCustomCodeBtn") and page.is_visible("#resetCustomCodeBtn") and not page.is_visible("#editCodeBtn")
+ assert page.evaluate("document.activeElement.id") == "customPyCode"
+
+ page.fill("#customPyCode", code_text(page).replace(TITLE, "編集後のタイトル"))
+ gen = run_edited_code(page, "button")
+ assert gen != before
+ assert status_kind(page) == "ok" and status_text(page).startswith("コードの実行に成功しました")
+ assert image_summary(page)[0]["title"] == "編集後のタイトル"
+ assert code_output_kind(page) == "ok"
+
+
+def test_gui_change_in_edit_mode_is_not_applied_until_regenerate(app):
+ page = app
+ enter_edit_mode(page)
+ edited = code_text(page) + "\n# 編集中のメモ\n"
+ page.fill("#customPyCode", edited)
+ gen = image_generation(page)
+ src = plot_src(page)
+ page.fill("#title", "編集中に変えたタイトル")
+ page.wait_for_timeout(1200) # デバウンス(250ms)を十分に超えて待つ
+ assert code_text(page) == edited
+ assert image_generation(page) == gen and plot_src(page) == src
+ assert status_kind(page) == "warning" and "編集中のため" in status_text(page)
+ assert "同期していません" in page.inner_text("#codeSyncStatus")
+
+ page.click("#resetCustomCodeBtn")
+ assert code_mode(page) == "sync"
+ assert page.get_attribute("#customPyCode", "readonly") is not None
+ wait_settled(page)
+ wait_code_generated(page)
+ assert '"編集中に変えたタイトル"' in code_text(page) and "編集中のメモ" not in code_text(page)
+ assert image_generation(page) != gen
+ assert image_summary(page)[0]["title"] == "編集中に変えたタイトル"
+ assert "同期しています" in page.inner_text("#codeSyncStatus")
+ assert status_kind(page) == "ok"
+
+
+def test_ctrl_enter_runs_code(app):
+ page = app
+ enter_edit_mode(page)
+ page.fill("#customPyCode", code_text(page).replace(TITLE, "ショートカット"))
+ run_edited_code(page, "key")
+ assert image_summary(page)[0]["title"] == "ショートカット"
+
+
+def test_error_shows_line_numbered_traceback(app):
+ page = app
+ enter_edit_mode(page)
+ lines = code_text(page).split("\n")
+ lines.insert(10, "undefined_name_xyz") # 11 行目
+ page.fill("#customPyCode", "\n".join(lines))
+ gen = image_generation(page)
+ page.click("#applyCustomCodeBtn")
+ page.wait_for_function("document.getElementById('status').dataset.kind === 'error'")
+ wait_run_finished(page)
+ assert code_output_kind(page) == "error"
+ out = code_output(page)
+ assert "line 11" in out and "undefined_name_xyz" in out and "NameError" in out
+ assert status_kind(page) == "error"
+ assert "11行目" in status_text(page)
+ assert page.inner_text("#statusDetail").strip() != ""
+ assert image_generation(page) == gen # 前の画像は残る
+ assert code_text(page).split("\n")[10] == "undefined_name_xyz" # コードは書き換えられない
+
+ # 直して再実行すると、出力欄はエラー表示でなくなる
+ lines.pop(10)
+ page.fill("#customPyCode", "\n".join(lines))
+ run_edited_code(page, "button")
+ assert code_output_kind(page) == "ok" and "NameError" not in code_output(page)
+
+
+def test_print_output_is_shown(app):
+ page = app
+ enter_edit_mode(page)
+ page.fill("#customPyCode", 'print("hello from script")\n' + code_text(page))
+ run_edited_code(page, "button")
+ assert "hello from script" in code_output(page)
+ assert code_output_kind(page) == "ok"
+
+
+def test_save_in_edit_mode_uses_edited_code(app):
+ page = app
+ enter_edit_mode(page)
+ code = code_text(page).replace(TITLE, "保存される編集後タイトル")
+ code = code.replace('plt.rcParams["axes.unicode_minus"] = False', 'plt.rcParams["axes.unicode_minus"] = False\nplt.rcParams["svg.fonttype"] = "none" # 文字をテキストのまま残す', 1)
+ page.fill("#customPyCode", code)
+ page.select_option("#saveFormat", "svg")
+ download = save_plot(page)
+ assert download.suggested_filename == "plot.svg"
+ svg = open(download.path(), encoding="utf-8").read()
+ assert "保存される編集後タイトル" in svg and TITLE not in svg
+ assert status_kind(page) == "ok"
+
+
+def test_no_horizontal_scroll_on_narrow_screens(page, app_url, console_log):
+ page.set_viewport_size({"width": 375, "height": 812})
+ page.goto(app_url)
+ for tab in ("tabPlotBtn", "tabDataBtn", "tabCodeBtn"):
+ page.click(f"#{tab}")
+ assert page.evaluate("document.documentElement.scrollWidth") <= 375, tab
diff --git a/tests/e2e/test_codegen_consistency.py b/tests/e2e/test_codegen_consistency.py
new file mode 100644
index 0000000..2dba630
--- /dev/null
+++ b/tests/e2e/test_codegen_consistency.py
@@ -0,0 +1,116 @@
+"""生成したスクリプトの一致確認(要件 7 章): .py を CPython で実行した図と、ブラウザで描いた図の要素が一致すること。
+
+フォントは CPython とブラウザで異なるので、画素やレイアウトは比べない。系列数・軸範囲・ラベル・凡例・スケールを比べる。
+"""
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from helpers import (
+ image_summary,
+ load_fixture,
+ open_code_tab,
+ wait_app_ready,
+ wait_code_generated,
+ wait_settled,
+)
+
+pytestmark = pytest.mark.e2e
+
+REPO = Path(__file__).resolve().parents[2]
+HELPER = Path(__file__).with_name("run_script_summary.py")
+
+
+def _configure_line(page):
+ page.select_option("#series-s1-x", "__idx__0")
+ page.select_option("#series-s1-y", "__idx__1")
+ page.click("#addSeriesBtn")
+ page.select_option("#series-s2-x", "__idx__0")
+ page.select_option("#series-s2-y", "__idx__2")
+ page.check("#series-s2-use-y2")
+ page.fill("#title", "指数と線形")
+ page.fill("#xLabel", "時間 (s)")
+ page.fill("#yLabel", "指数")
+ page.fill("#y2Label", "線形")
+ page.select_option("#yScale", "log")
+
+
+def _configure_scatter(page):
+ page.select_option("#plotType", "scatter")
+ page.select_option("#series-s1-x", "__idx__0")
+ page.select_option("#series-s1-y", "__idx__1")
+ page.fill("#title", "散布図")
+
+
+def _configure_bar(page):
+ page.select_option("#plotType", "bar")
+ page.select_option("#xColumn", "__idx__0")
+ page.select_option("#series-s1-y", "__idx__1")
+ page.click("#addSeriesBtn")
+ page.select_option("#series-s2-y", "__idx__2")
+ page.fill("#title", "品目別の売上")
+ page.fill("#yLabel", "売上")
+
+
+CASES = {
+ "line": ("growth.csv", _configure_line),
+ "scatter": ("utf8.csv", _configure_scatter),
+ "bar": ("categories.csv", _configure_bar),
+}
+
+
+def _run_in_cpython(script: Path, workdir: Path) -> list[dict]:
+ env = dict(os.environ, MPLBACKEND="Agg", PYTHONPATH=str(REPO / "py"))
+ proc = subprocess.run(
+ [sys.executable, str(HELPER), str(script)], cwd=workdir, env=env, capture_output=True, text=True, timeout=120
+ )
+ assert proc.returncode == 0, proc.stderr
+ line = next(ln for ln in proc.stdout.splitlines() if ln.startswith("FIGURE_SUMMARY:"))
+ return json.loads(line[len("FIGURE_SUMMARY:"):])
+
+
+EXACT_KEYS = ("title", "xlabel", "ylabel", "xscale", "yscale", "lines", "collections", "patches", "legend")
+
+
+@pytest.mark.parametrize("case", list(CASES))
+def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_dir, tmp_path, console_log):
+ fixture, configure = CASES[case]
+ page.goto(app_url)
+ wait_app_ready(page)
+ load_fixture(page, fixtures_dir / fixture)
+ wait_settled(page)
+ configure(page)
+ wait_settled(page)
+ wait_code_generated(page)
+ browser = image_summary(page)
+ assert browser, "data-summary が無い"
+
+ open_code_tab(page)
+ with page.expect_download() as dl:
+ page.click("#downloadCodeBtn")
+ script = tmp_path / dl.value.suggested_filename
+ dl.value.save_as(script)
+ shutil.copy(fixtures_dir / fixture, tmp_path / fixture) # データは元のファイル名でスクリプトの隣に置く
+ local = _run_in_cpython(script, tmp_path)
+
+ assert len(local) == len(browser)
+ assert len(browser) == (2 if case == "line" else 1)
+ for b_ax, l_ax in zip(browser, local):
+ for key in EXACT_KEYS:
+ assert l_ax[key] == b_ax[key], key
+ assert l_ax["xlim"] == pytest.approx(b_ax["xlim"], rel=1e-6)
+ assert l_ax["ylim"] == pytest.approx(b_ax["ylim"], rel=1e-6)
+ if case == "bar":
+ assert local[0]["xticklabels"] == browser[0]["xticklabels"]
+ assert "品目A" in browser[0]["xticklabels"][0]
+ assert browser[0]["patches"] == 12 # 2 系列 × 6 カテゴリ
+ if case == "line":
+ assert browser[0]["yscale"] == "log" and browser[0]["lines"] == 1 and browser[1]["lines"] == 1
+ assert browser[0]["legend"] == ["指数 [1]", "線形 [2]"]
+ if case == "scatter":
+ assert browser[0]["collections"] == 1 and browser[0]["lines"] == 0
diff --git a/tests/e2e/test_data_and_file.py b/tests/e2e/test_data_and_file.py
index 80e639c..4ec443d 100644
--- a/tests/e2e/test_data_and_file.py
+++ b/tests/e2e/test_data_and_file.py
@@ -49,6 +49,18 @@ def test_data_preview_row_numbers_and_skipped_rows(page, app_url, tmp_path, cons
page.wait_for_function("document.querySelectorAll('#dataArea tbody tr.skipped-row').length === 2")
+def test_file_picker_shows_selected_name(page, app_url, fixtures_dir, console_log):
+ page.goto(app_url)
+ wait_app_ready(page)
+ assert "選択されていません" in page.inner_text("#fileNameLabel")
+ load_fixture(page, fixtures_dir / "utf8.csv")
+ wait_settled(page)
+ text = page.inner_text("#fileNameLabel")
+ assert "utf8.csv" in text
+ assert "選択されていません" not in text
+ assert page.eval_on_selector("#fileInput", "e => e.value") == ""
+
+
def test_file_input_reset_and_labels(page, app_url, fixtures_dir, console_log):
page.goto(app_url)
wait_app_ready(page)
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index 438b8dc..f5ac6ca 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -12,3 +12,5 @@
| `multi_sheet.xlsx` | 2 シート(Sheet1 / 二枚目)で内容が異なる |
| `duplicate_columns.csv` | 同名の列(温度)が 2 つあり値が異なる(列番号指定の確認) |
| `non_numeric.csv` | 「1,234」形式の数値、「12 mV」のような単位付き、N/A、空欄が混在 |
+| `growth.csv` | 時間 / 指数 / 線形。値はすべて正(対数軸で描ける)。生成コードの一致確認用 |
+| `categories.csv` | 品目(文字列)/ 売上A / 売上B。文字列カテゴリの棒グラフ用 |
diff --git a/tests/fixtures/categories.csv b/tests/fixtures/categories.csv
new file mode 100644
index 0000000..c01219e
--- /dev/null
+++ b/tests/fixtures/categories.csv
@@ -0,0 +1,7 @@
+品目,売上A,売上B
+品目A,10,30
+品目B,17,25
+品目C,24,20
+品目D,31,15
+品目E,15,27
+品目F,22,22
diff --git a/tests/fixtures/growth.csv b/tests/fixtures/growth.csv
new file mode 100644
index 0000000..6919dc9
--- /dev/null
+++ b/tests/fixtures/growth.csv
@@ -0,0 +1,31 @@
+時間,指数,線形
+0,1.0,10.0
+1,1.2599,11.5
+2,1.5874,13.0
+3,2.0,14.5
+4,2.5198,16.0
+5,3.1748,17.5
+6,4.0,19.0
+7,5.0397,20.5
+8,6.3496,22.0
+9,8.0,23.5
+10,10.0794,25.0
+11,12.6992,26.5
+12,16.0,28.0
+13,20.1587,29.5
+14,25.3984,31.0
+15,32.0,32.5
+16,40.3175,34.0
+17,50.7968,35.5
+18,64.0,37.0
+19,80.6349,38.5
+20,101.5937,40.0
+21,128.0,41.5
+22,161.2699,43.0
+23,203.1873,44.5
+24,256.0,46.0
+25,322.5398,47.5
+26,406.3747,49.0
+27,512.0,50.5
+28,645.0796,52.0
+29,812.7493,53.5
diff --git a/tests/fixtures/make_fixtures.py b/tests/fixtures/make_fixtures.py
index fd04108..9fd1bb3 100644
--- a/tests/fixtures/make_fixtures.py
+++ b/tests/fixtures/make_fixtures.py
@@ -66,6 +66,12 @@ def main():
(OUT / "tab.txt").write_bytes(csv_text(header, data, sep="\t").encode("utf-8"))
+ # 生成コードの一致確認用: 正の値だけ(対数軸に使える)/文字列のカテゴリ列を持つ棒グラフ用
+ growth = [(i, round(2 ** (i / 3), 4), round(10 + i * 1.5, 2)) for i in range(N)]
+ (OUT / "growth.csv").write_bytes(csv_text(["時間", "指数", "線形"], growth).encode("utf-8"))
+ cats = [(f"品目{c}", 10 + i * 7 % 23, 30 - i * 5 % 17) for i, c in enumerate("ABCDEF")]
+ (OUT / "categories.csv").write_bytes(csv_text(["品目", "売上A", "売上B"], cats).encode("utf-8"))
+
wb = openpyxl.Workbook()
ws1 = wb.active
ws1.title = "Sheet1"
diff --git a/tests/perf/bench.py b/tests/perf/bench.py
index 7fde7d0..01e7be6 100644
--- a/tests/perf/bench.py
+++ b/tests/perf/bench.py
@@ -17,7 +17,10 @@
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tests" / "e2e"))
-from helpers import load_fixture, plot_src, wait_app_ready, wait_data_ready, wait_plot_changed # noqa: E402
+from helpers import ( # noqa: E402
+ enter_edit_mode, image_generation, load_fixture, plot_src,
+ wait_app_ready, wait_data_ready, wait_plot_changed,
+)
from server import start_server # noqa: E402
from playwright.sync_api import sync_playwright # noqa: E402
@@ -96,7 +99,7 @@ def bench_startup(browser, url, reps):
def bench_render(browser, url, reps, csv_path):
- first_plot, change, save = [], [], []
+ first_plot, change, save, edit_run = [], [], [], []
ctx = browser.new_context()
# フォント等をHTTPキャッシュに載せるための捨て試行
p0 = ctx.new_page()
@@ -125,9 +128,27 @@ def bench_render(browser, url, reps, csv_path):
page.click("#savePlotBtn")
dl.value.path()
save.append((time.perf_counter() - t) * 1000)
+ # 編集モードでの実行(Phase 2 で追加。保存の後に測るので既存の項目には影響しない)。
+ # Ctrl/Cmd+Enter から新しい画像の表示まで(ファイルの再読込とスクリプト全体の実行を含む)
+ page.wait_for_timeout(1000)
+ enter_edit_mode(page)
+ page.wait_for_timeout(1500)
+ page.focus("#customPyCode")
+ prev = image_generation(page)
+ t = time.perf_counter()
+ page.keyboard.press("ControlOrMeta+Enter")
+ page.wait_for_function(
+ """(prev) => {
+ const d = document.documentElement.dataset;
+ const img = document.querySelector('#plotArea img');
+ return d.renderState === 'idle' && !!img && img.dataset.generation !== prev;
+ }""",
+ arg=prev, timeout=TIMEOUT,
+ )
+ edit_run.append((time.perf_counter() - t) * 1000)
page.close()
ctx.close()
- return first_plot, change, save
+ return first_plot, change, save, edit_run
def bench_xlsx(browser, url, reps, xlsx_path):
@@ -175,7 +196,7 @@ def main():
browser = p.chromium.launch(headless=not a.headed)
print("chromium", browser.version, "headed" if a.headed else "headless", "platform", platform.platform())
cold, warm = bench_startup(browser, url, a.reps)
- first, change, save = bench_render(browser, url, a.reps, csv_path)
+ first, change, save, edit_run = bench_render(browser, url, a.reps, csv_path)
xlsx = bench_xlsx(browser, url, a.reps, REPO / "tests" / "fixtures" / "multi_sheet.xlsx")
browser.close()
res = {
@@ -183,6 +204,7 @@ def main():
"first_plot_ms": first, "change_to_plot_ms": change,
"change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in change],
"save_png_ms": save,
+ "edit_mode_run_ms": edit_run,
"first_xlsx_plot_ms": xlsx,
}
for k, v in res.items():
diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py
index ffd7b60..b81dd1c 100644
--- a/tests/unit/test_api.py
+++ b/tests/unit/test_api.py
@@ -13,9 +13,17 @@
@pytest.fixture(autouse=True)
def fresh_session():
- api.SESSION.df = None
+ api.SESSION.forget()
+ api.SESSION.workdir = None
yield
- api.SESSION.df = None
+ api.SESSION.forget()
+ api.SESSION.workdir = None
+
+
+@pytest.fixture
+def workdir(tmp_path):
+ api.SESSION.workdir = tmp_path
+ return tmp_path
def load(name="utf8.csv", settings=None):
@@ -62,7 +70,7 @@ def test_internal_error_has_short_message_and_traceback(monkeypatch):
def boom(*a, **k):
raise RuntimeError("kaboom")
- monkeypatch.setattr(api, "make_figure", boom)
+ monkeypatch.setattr(api, "generate_script", boom)
r = json.loads(api.render_json(json.dumps(default_settings())))
assert r["ok"] is False and r["error"]["message"] == api.INTERNAL_ERROR_MESSAGE
assert "RuntimeError" in r["error"]["detail"] and "kaboom" in r["error"]["detail"] and "Traceback" in r["error"]["detail"]
@@ -86,12 +94,169 @@ def test_save_response():
assert r["ok"] is False and "背景透過" in r["error"]["message"]
-def test_render_with_custom_code():
+def test_render_returns_generated_code_summary_and_output():
+ load()
+ r = json.loads(api.render_json(json.dumps(default_settings())))
+ assert r["ok"] is True and r["output"] == ""
+ assert r["code"].startswith("# matplotlib GUI が生成したスクリプト") and "pd.read_csv(DATA_FILE" in r["code"]
+ assert 'encoding="utf-8"' in r["code"] and "plt.show()" in r["code"] # 表示用は完全なスクリプト
+ assert r["summary"][0]["lines"] == 1 and r["summary"][0]["xlabel"] == "index" and r["summary"][0]["ylabel"] == "時間 [0]"
+
+
+def test_render_gui_mode_reuses_loaded_dataframe_without_reading_file(workdir):
+ load()
+ (workdir / "utf8.csv").unlink() # ファイルが無くても、読込済みの df で描ける
+ r = json.loads(api.render_json(json.dumps(default_settings()), None))
+ assert r["ok"] is True and not list(workdir.glob("*.png")) # savefig も実行しない
+
+
+def test_render_warnings_come_from_plan():
+ load("non_numeric.csv")
+ s = default_settings()
+ s["series"] = [{"x": "__idx__0", "y": "__idx__2"}, {"x": "__idx__0", "y": "__idx__1"}]
+ r = json.loads(api.render_json(json.dumps(s)))
+ assert r["ok"] and r["seriesCount"] == 1 and r["warnings"][0]["series"] == 2
+ assert "数値に変換できない値" in r["warnings"][0]["message"]
+
+
+def test_upload_is_written_to_workdir_under_original_name(workdir):
+ load("utf8.csv")
+ assert (workdir / "utf8.csv").read_bytes() == (FIXTURES / "utf8.csv").read_bytes()
+ load("cp932.csv")
+ assert not (workdir / "utf8.csv").exists() and (workdir / "cp932.csv").is_file() # 前のファイルは消す
+ load("cp932.csv")
+ assert (workdir / "cp932.csv").is_file() # 同じ名前の再読込では消さない
+
+
+def test_failed_load_forgets_everything_and_removes_written_file(workdir):
+ load("utf8.csv")
+ bad = json.loads(api.load_file_json("x.pdf", b"x", json.dumps(default_settings())))
+ assert bad["ok"] is False
+ assert api.SESSION.df is None and api.SESSION.source is None and api.SESSION.filename is None
+ assert not (workdir / "utf8.csv").exists() and not (workdir / "x.pdf").exists()
+
+
+def test_upload_name_with_path_is_reduced_to_base_name(workdir):
+ s = default_settings()
+ r = json.loads(api.load_file_json("../evil/utf8.csv", (FIXTURES / "utf8.csv").read_bytes(), json.dumps(s)))
+ assert r["ok"] and (workdir / "utf8.csv").is_file() and not (workdir.parent / "evil").exists()
+
+
+def test_edit_mode_runs_displayed_script_against_uploaded_file(workdir):
+ load("utf8.csv")
+ code = json.loads(api.render_json(json.dumps(default_settings())))["code"]
+ (workdir / "utf8.csv").write_text("時間,電圧\n0,1\n1,5\n2,3\n", encoding="utf-8") # 生成コードが読む実ファイル
+ r = json.loads(api.render_json(json.dumps(default_settings()), code))
+ assert r["ok"] is True and r["image"].startswith("data:image/png;base64,")
+ assert r["summary"][0]["ylabel"] == "時間 [0]" and "seriesCount" not in r and "code" not in r
+ assert (workdir / "plot.png").is_file() # 編集モードでは savefig まで実行する(作業フォルダに書く)
+
+
+def test_edit_mode_print_output_and_independence_from_gui_settings(workdir):
load()
- r = json.loads(api.render_json(json.dumps(default_settings()), api.default_custom_code()))
+ code = (
+ "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([1, 2], [3, 4])\n"
+ "ax.set_title('コードのタイトル')\nprint('出力')\n"
+ )
+ s = default_settings()
+ s["plot"]["title"] = "GUIのタイトル"
+ s["plot"]["grid"]["major"] = True
+ r = json.loads(api.render_json(json.dumps(s), code))
+ assert r["ok"] and r["output"] == "出力\n"
+ assert r["summary"][0]["title"] == "コードのタイトル" # A8: GUI の設定を上書き適用しない
+
+
+def test_edit_mode_works_without_loaded_file_and_ignores_invalid_settings():
+ r = json.loads(api.render_json("{broken", "import matplotlib.pyplot as plt\nplt.plot([0, 1])\n"))
assert r["ok"] is True
- r = json.loads(api.render_json(json.dumps(default_settings()), "raise ValueError('x')"))
- assert r["ok"] is False and "カスタムコード" in r["error"]["message"]
+
+
+def test_edit_mode_error_payload(workdir):
+ load()
+ code = "print('途中')\nimport matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot(nothing)\n"
+ r = json.loads(api.render_json(json.dumps(default_settings()), code))
+ err = r["error"]
+ assert r["ok"] is False and r["output"] == "途中\n"
+ assert err["line"] == 4 and err["field"] == "Pythonコード"
+ assert "4行目" in err["message"] and "NameError" in err["message"] and "is not defined" not in err["message"]
+ assert 'File "plot.py", line 4' in err["traceback"] and "nothing" in err["traceback"]
+ assert "Traceback" in err["detail"] and "runner.py" in err["detail"]
+
+
+def test_edit_mode_no_figure_error_has_output():
+ r = json.loads(api.render_json(json.dumps(default_settings()), "print('x')"))
+ assert r["ok"] is False and r["error"]["field"] == "Pythonコード" and r["output"] == "x\n"
+
+
+def test_save_with_code_uses_save_settings(workdir):
+ load()
+ code = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([1, 2], [3, 4])\nprint('save')\n"
+ s = default_settings()
+ s["save"].update(filename="out", format="svg")
+ r = json.loads(api.save_json(json.dumps(s), code))
+ assert r["ok"] and r["filename"] == "out.svg" and r["dataUri"].startswith("data:image/svg+xml;base64,") and r["output"] == "save\n"
+ s["save"].update(format="jpg", transparent=True)
+ r = json.loads(api.save_json(json.dumps(s), code))
+ assert r["ok"] is False and "背景透過" in r["error"]["message"] and r["output"] == "save\n"
+
+
+def test_save_with_code_ignores_invalid_plot_settings_but_checks_save_settings():
+ code = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([1, 2], [3, 4])\n"
+ s = default_settings()
+ s["axes"]["x"].update(min=5, max=1) # 描画設定の不備(GUI 同期なら軸範囲のエラー)
+ assert json.loads(api.save_json(json.dumps(s)))["ok"] is False
+ r = json.loads(api.save_json(json.dumps(s), code))
+ assert r["ok"] and r["filename"] == "plot.png"
+ s["save"]["format"] = "bmp"
+ r = json.loads(api.save_json(json.dumps(s), code))
+ assert r["ok"] is False and r["error"]["field"] == "保存形式"
+
+
+def test_save_uses_120_dpi_and_render_100():
+ import base64
+ import struct
+
+ load()
+ s = default_settings() # 8 x 6 インチ
+ r = json.loads(api.render_json(json.dumps(s)))
+ w, h = struct.unpack(">II", base64.b64decode(r["image"].split(",")[1])[16:24])
+ assert (w, h) == (800, 600)
+ r = json.loads(api.save_json(json.dumps(s)))
+ w, h = struct.unpack(">II", base64.b64decode(r["dataUri"].split(",")[1])[16:24])
+ assert (w, h) == (960, 720)
+
+
+def test_runtime_error_in_generated_code_maps_to_step_message(monkeypatch):
+ import matplotlib.axes
+ from matplotlib.figure import Figure
+
+ load()
+ s = default_settings()
+ s["axes"]["y"].update(min=1, max=5)
+
+ def boom(self, *a, **k):
+ raise ValueError("bad limits")
+
+ monkeypatch.setattr(matplotlib.axes.Axes, "set_ylim", boom)
+ r = json.loads(api.render_json(json.dumps(s)))
+ err = r["error"]
+ assert r["ok"] is False and err["message"] == "Y軸のスケールまたは範囲を適用できませんでした。範囲の値を確認してください。"
+ assert err["field"] == "Y軸の範囲" and "bad limits" in err["detail"] and err["line"] and "ax.set_ylim" in err["traceback"]
+ monkeypatch.undo()
+
+ def tight_boom(self, *a, **k):
+ raise RuntimeError("tight failed")
+
+ monkeypatch.setattr(Figure, "tight_layout", tight_boom)
+ r = json.loads(api.render_json(json.dumps(default_settings())))
+ assert "レイアウト" in r["error"]["message"] and r["error"]["field"] == "余白" and "tight failed" in r["error"]["detail"]
+
+ monkeypatch.undo()
+ s = default_settings()
+ s["plot"]["margins"].update(left=0.1, right=0.9, bottom=0.1, top=0.9)
+ monkeypatch.setattr(Figure, "subplots_adjust", tight_boom)
+ r = json.loads(api.render_json(json.dumps(s)))
+ assert "余白の設定" in r["error"]["message"] and r["error"]["field"] == "余白"
def test_font_status_before_register():
@@ -210,7 +375,7 @@ def test_unparsable_values_only_series_error_is_japanese():
_assert_japanese_clean(json.loads(api.render_json(json.dumps(s))))
-def test_unexpected_mismatch_error_is_wrapped_with_detail(monkeypatch):
+def test_unexpected_mismatch_error_is_wrapped_with_detail(monkeypatch): # 描画の途中の TypeError は汎用メッセージにする
import matplotlib.axes
load()
@@ -222,3 +387,14 @@ def boom(self, *a, **k):
r = json.loads(api.render_json(json.dumps(default_settings())))
_assert_japanese_clean(r)
assert "cannot cast" in r["error"]["detail"]
+
+
+@pytest.mark.parametrize(
+ "raw, expected",
+ [("", "plot.py"), (" ", "plot.py"), ("figure1", "figure1.py"), ("fig.png", "fig.py"), ("a/b:c", "a_b_c.py"),
+ ("結果.svg", "結果.py"), ("...", "plot.py"), (None, "plot.py")],
+)
+def test_script_filename_uses_save_filename_rules(raw, expected):
+ assert api.script_filename(raw) == expected
+ # 保存ファイル名と同じ規則: 拡張子だけが違う
+ assert api.script_filename(raw or "").rsplit(".", 1)[0] == api.build_filename(raw or "", "png").rsplit(".", 1)[0]
diff --git a/tests/unit/test_codegen.py b/tests/unit/test_codegen.py
new file mode 100644
index 0000000..7e456b7
--- /dev/null
+++ b/tests/unit/test_codegen.py
@@ -0,0 +1,582 @@
+import ast
+import contextlib
+import io
+import shutil
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+from matplotlib.collections import PathCollection
+from matplotlib.patches import Rectangle
+
+from mplgui import codegen
+from mplgui.codegen import comment_text, generate_script, literal
+from mplgui.dataprep import plan_plot
+from mplgui.fonts import SANS_SERIF_PRIORITY
+from mplgui.loader import SourceInfo, load_file
+from mplgui.runner import ScriptError, figure_summary, run_script
+from mplgui.settings import default_settings, parse_settings
+
+pytestmark = pytest.mark.unit
+
+FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
+
+
+@pytest.fixture
+def data():
+ t = np.arange(30, dtype=float)
+ temp = np.sin(t / 4) * 10 + 20
+ temp[[3, 7]] = np.nan
+ return pd.DataFrame({
+ "時間": t,
+ "温度": temp,
+ "電圧": t * 3.0 + 1,
+ "区分": [f"区分{i % 6}" for i in range(30)],
+ "整数": (np.arange(30) % 5),
+ })
+
+
+def S(y, **kw):
+ d = {"x": "", "y": f"__idx__{y}"}
+ d.update(kw)
+ return d
+
+
+class Built:
+ """CSV に書いたデータから、スクリプトを作り、全体を実行 / 自動描画の2通りで図を作る。"""
+
+ def __init__(self, tmp_path, df, mutator=None, series=None, name="measure.csv", **load_args):
+ self.dir = tmp_path
+ self.csv = name
+ df.to_csv(tmp_path / name, index=False, encoding="utf-8")
+ self.loaded = load_file((tmp_path / name).read_bytes(), name, "", load_args.get("has_header", True))
+ raw = default_settings()
+ if series is not None:
+ raw["series"] = series
+ if mutator:
+ mutator(raw)
+ self.settings = parse_settings(raw)
+ self.plan = plan_plot(self.loaded.df, self.settings)
+ self.script = generate_script(self.settings, self.loaded.source, self.plan)
+
+ @contextlib.contextmanager
+ def full(self):
+ with run_script(self.script.text, cwd=self.dir) as run:
+ yield run
+
+ @contextlib.contextmanager
+ def auto(self):
+ with run_script(self.script.auto_render_text(), injected={"df": self.loaded.df}) as run:
+ yield run
+
+ def summaries(self):
+ with self.full() as run:
+ full = figure_summary(run.fig)
+ with self.auto() as run:
+ auto = figure_summary(run.fig)
+ return full, auto
+
+
+def lines(ax):
+ return ax.get_lines()
+
+
+def bars(ax):
+ return [p for p in ax.patches if isinstance(p, Rectangle)]
+
+
+# ---------------------------------------------------------------- matrix: compiles, runs as a file, runs auto, agree
+
+MATRIX = {
+ "line default": (lambda s: None, [S(1, x="__idx__0")]),
+ "line two series secondary": (lambda s: s["axes"]["y2"].update(label="右軸"), [S(1, x="__idx__0"), S(2, x="__idx__0", secondaryAxis=True)]),
+ "line markers dashed": (lambda s: None, [S(1, markerSize=16, lineStyle="dashed", lineWidth=3.5, color="#005AFF")]),
+ "line skip rows + x index + title": (lambda s: s["plot"].update(skipRows=2, title="タイトル"), [S(1)]),
+ "line log y limits": (lambda s: (s["axes"]["y"].update(scale="log", min=1, max=100), s["axes"]["x"].update(min=2, max=10)), [S(2, x="__idx__0")]),
+ "line log x / log y2": (lambda s: (s["axes"]["x"].update(scale="log"), s["axes"]["y2"].update(scale="log")), [S(1, x="__idx__0"), S(2, x="__idx__0", secondaryAxis=True)]),
+ "line grid major": (lambda s: s["plot"]["grid"].update(major=True), [S(1, x="__idx__0")]),
+ "line grid minor": (lambda s: s["plot"]["grid"].update(minor=True), [S(1, x="__idx__0")]),
+ "line grid both secondary": (lambda s: s["plot"]["grid"].update(major=True, minor=True), [S(1, x="__idx__0"), S(2, x="__idx__0", secondaryAxis=True)]),
+ "line legend upper left": (lambda s: s["plot"]["legend"].update(location="upper left"), [S(1, x="__idx__0", label="温度")]),
+ "line legend none": (lambda s: s["plot"]["legend"].update(location="none"), [S(1, x="__idx__0")]),
+ "line margins partial": (lambda s: s["plot"]["margins"].update(left=0.2, top=0.8), [S(1, x="__idx__0")]),
+ "line margins all": (lambda s: s["plot"]["margins"].update(left=0.2, right=0.9, bottom=0.2, top=0.8), [S(1, x="__idx__0")]),
+ "line labels and font": (lambda s: (s["plot"].update(fontSize=1.5, title="t"), s["axes"]["x"].update(label="X軸"), s["axes"]["y"].update(label="Y軸")), [S(1, x="__idx__0")]),
+ "line y auto": (lambda s: None, [{"x": "", "y": ""}, {"x": "", "y": ""}]),
+ "line figure size": (lambda s: s["plot"]["figure"].update(width=5, height=3), [S(1, x="__idx__0")]),
+ "scatter": (lambda s: s["plot"].update(type="scatter"), [S(1, x="__idx__0"), S(2, x="__idx__0", markerSize=60)]),
+ "scatter secondary": (lambda s: s["plot"].update(type="scatter"), [S(1, x="__idx__0"), S(2, x="__idx__0", secondaryAxis=True)]),
+ "bar single numeric x": (lambda s: s["plot"].update(type="bar", xColumn="__idx__0"), [S(2)]),
+ "bar single string x": (lambda s: s["plot"].update(type="bar", xColumn="__idx__3"), [S(2)]),
+ "bar single x index": (lambda s: s["plot"].update(type="bar"), [S(2)]),
+ "bar multi": (lambda s: s["plot"].update(type="bar", xColumn="__idx__3"), [S(1), S(2, color="#03AF7A")]),
+ "bar multi secondary": (lambda s: s["plot"].update(type="bar", xColumn="__idx__3"), [S(1), S(2, secondaryAxis=True)]),
+ "bar multi x index three": (lambda s: s["plot"].update(type="bar"), [S(1), S(2), S(4)]),
+ "string x line": (lambda s: None, [S(1, x="__idx__3")]),
+}
+
+
+@pytest.mark.parametrize("name", list(MATRIX))
+def test_script_compiles_runs_and_agrees(tmp_path, data, name):
+ mutator, series = MATRIX[name]
+ b = Built(tmp_path, data.head(12) if name.startswith("bar") else data, mutator, series)
+ compile(b.script.text, "plot.py", "exec")
+ full, auto = b.summaries()
+ assert full == auto
+ assert (tmp_path / "plot.png").is_file() # 全体を実行すると savefig まで進む
+ assert b.script.auto_render_text().count("\n") == b.script.text.count("\n") # 行番号が同じ
+
+
+def test_auto_render_variant_drops_only_load_and_output(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["plot"].update(skipRows=2), [S(1, x="__idx__0")])
+ auto = b.script.auto_render_text()
+ assert "read_csv" not in auto and "savefig" not in auto and "plt.show" not in auto and "DATA_FILE" not in auto
+ assert "read_csv" in b.script.text and "plt.show()" in b.script.text
+ assert "df = df.iloc[2:].reset_index(drop=True)" in auto
+ with pytest.raises(ScriptError) as info:
+ with run_script(auto): # df を渡さなければ動かない
+ pass
+ assert isinstance(info.value.exc, NameError)
+
+
+# ---------------------------------------------------------------- expected artists
+
+
+def test_line_two_series_artists(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1, x="__idx__0", color="#005AFF"), S(2, x="__idx__0", color="#03AF7A")])
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ assert len(r.fig.axes) == 1 and len(lines(ax)) == 2 and not ax.collections
+ assert lines(ax)[0].get_color().lower() == "#005aff" and lines(ax)[0].get_linewidth() == 2
+ assert lines(ax)[0].get_marker() in ("", "None", None)
+ assert [t.get_text() for t in ax.get_legend().get_texts()] == ["温度 [1]", "電圧 [2]"]
+ assert len(lines(ax)[0].get_xdata()) == 28 # NaN の2行は描かない
+
+
+def test_line_marker_size(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1, markerSize=9)])
+ with b.auto() as r:
+ ln = lines(r.fig.axes[0])[0]
+ assert ln.get_marker() == "o" and ln.get_markersize() == pytest.approx(3.0)
+ assert "markersize=9.0 ** 0.5" in b.script.text
+
+
+def test_scatter_artists(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["plot"].update(type="scatter"), [S(1, x="__idx__0"), S(2, x="__idx__0", markerSize=50)])
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ cols = [c for c in ax.collections if isinstance(c, PathCollection)]
+ assert len(cols) == 2 and not lines(ax)
+ assert cols[0].get_sizes()[0] == 24 and cols[1].get_sizes()[0] == 50
+
+
+def test_bar_single_and_multi(tmp_path, data):
+ b = Built(tmp_path, data.head(6), lambda s: s["plot"].update(type="bar", xColumn="__idx__0"), [S(2)])
+ with b.auto() as r:
+ assert len(bars(r.fig.axes[0])) == 6
+ b = Built(tmp_path, data.head(5), lambda s: s["plot"].update(type="bar"), [S(2), S(4)])
+ with b.auto() as r:
+ assert len(bars(r.fig.axes[0])) == 10
+
+
+def test_bar_multi_keeps_category_tick_labels(tmp_path, data):
+ b = Built(tmp_path, data.head(8), lambda s: s["plot"].update(type="bar", xColumn="__idx__3"), [S(1), S(2)])
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ assert [t.get_text() for t in ax.get_xticklabels()] == [f"区分{i % 6}" for i in range(8)]
+ assert all(t.get_rotation() == 45 for t in ax.get_xticklabels())
+
+
+def test_bar_multi_log_scale_still_keeps_tick_labels(tmp_path, data):
+ b = Built(
+ tmp_path, data.head(8),
+ lambda s: (s["plot"].update(type="bar", xColumn="__idx__3"), s["axes"]["y"].update(scale="log", min=0.5, max=1000)),
+ [S(2), S(4)],
+ )
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ assert ax.get_yscale() == "log" and len(ax.get_xticklabels()) == 8
+
+
+def test_bar_multi_thins_many_categories(tmp_path):
+ df = pd.DataFrame({"k": [f"c{i}" for i in range(60)], "a": range(60), "b": range(60)})
+ b = Built(tmp_path, df, lambda s: s["plot"].update(type="bar", xColumn="__idx__0"), [S(1), S(2)])
+ with b.auto() as r:
+ assert len(r.fig.axes[0].get_xticklabels()) == 30 # 60 // 25 = 2 個おき
+
+
+def test_secondary_axis(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["axes"]["y2"].update(label="右軸"),
+ [S(1, x="__idx__0"), S(2, x="__idx__0", secondaryAxis=True)])
+ with b.auto() as r:
+ assert len(r.fig.axes) == 2
+ ax, ax2 = r.fig.axes
+ assert len(lines(ax)) == 1 and len(lines(ax2)) == 1 and ax2.get_ylabel() == "右軸"
+ assert [t.get_text() for t in ax.get_legend().get_texts()] == ["温度 [1]", "電圧 [2]"]
+ assert b.script.text.index("ax2 = ax.twinx()") > b.script.text.index("# 系列2") # 第2軸の系列の直前に作る
+
+
+def test_no_second_axis_without_secondary_series(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1)])
+ assert "twinx" not in b.script.text and "ax2" not in b.script.text
+ with b.auto() as r:
+ assert len(r.fig.axes) == 1
+
+
+@pytest.mark.parametrize("loc, expected", [("best", ["温度 [1]"]), ("lower right", ["温度 [1]"]), ("none", None)])
+def test_legend(tmp_path, data, loc, expected):
+ b = Built(tmp_path, data, lambda s: s["plot"]["legend"].update(location=loc), [S(1, x="__idx__0")])
+ with b.auto() as r:
+ legend = r.fig.axes[0].get_legend()
+ assert (None if legend is None else [t.get_text() for t in legend.get_texts()]) == expected
+
+
+def test_grid(tmp_path, data):
+ def grids(ax):
+ return any(g.get_visible() for g in ax.get_xgridlines()), any(g.get_visible() for g in ax.get_ygridlines())
+
+ for major, minor, expect_major, expect_minor in [(False, False, False, False), (True, False, True, False), (False, True, False, True), (True, True, True, True)]:
+ b = Built(tmp_path, data, lambda s: s["plot"]["grid"].update(major=major, minor=minor), [S(1, x="__idx__0")])
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ assert grids(ax)[0] is expect_major
+ assert ax.xaxis.get_minor_ticks()[0].gridline.get_visible() is expect_minor if expect_minor else True
+ if not expect_minor:
+ assert not any(t.gridline.get_visible() for t in ax.xaxis.get_minor_ticks())
+
+
+def test_scales_and_limits(tmp_path, data):
+ def m(s):
+ s["axes"]["y"].update(scale="log", min=0.5, max=100)
+ s["axes"]["x"].update(min=2, max=10)
+
+ b = Built(tmp_path, data, m, [S(2, x="__idx__0")])
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ assert ax.get_yscale() == "log" and ax.get_xscale() == "linear"
+ assert ax.get_ylim() == (0.5, 100) and ax.get_xlim() == (2, 10)
+ assert 'set_xscale("linear")' not in b.script.text # 既定の線形は書かない
+
+
+def test_single_sided_limits(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["axes"]["y"].update(min=-5), [S(1, x="__idx__0")])
+ assert "ax.set_ylim(bottom=-5.0)" in b.script.text
+ with b.auto() as r:
+ assert r.fig.axes[0].get_ylim()[0] == -5
+
+
+def test_log_y2_limits(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["axes"]["y2"].update(scale="log", min=1, max=500),
+ [S(1, x="__idx__0"), S(2, x="__idx__0", secondaryAxis=True)])
+ with b.auto() as r:
+ assert r.fig.axes[1].get_yscale() == "log" and r.fig.axes[1].get_ylim() == (1, 500)
+
+
+def test_title_labels_font_and_size(tmp_path, data):
+ def m(s):
+ s["plot"].update(title="タイトル", fontSize=12)
+ s["plot"]["figure"].update(width=5, height=3)
+ s["axes"]["x"]["label"] = "X軸"
+ s["axes"]["y"]["label"] = "Y軸"
+
+ b = Built(tmp_path, data, m, [S(1, x="__idx__0")])
+ with b.auto() as r:
+ ax = r.fig.axes[0]
+ assert tuple(r.fig.get_size_inches()) == (5.0, 3.0)
+ assert ax.get_title() == "タイトル" and ax.title.get_fontsize() == 14
+ assert ax.get_xlabel() == "X軸" and ax.get_ylabel() == "Y軸" and ax.xaxis.label.get_fontsize() == 12
+ assert ax.get_legend().get_texts()[0].get_fontsize() == 11
+ assert ax.xaxis.get_ticklabels()[0].get_fontsize() == 11
+ assert "FONT_SIZE = 12.0" in b.script.text and "FONT_SIZE - 1" in b.script.text
+
+
+def test_font_size_clamp_is_a_literal(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["plot"].update(fontSize=1.5), [S(1, x="__idx__0")])
+ assert "labelsize=1.0" in b.script.text and "fontsize=1.0" in b.script.text and "FONT_SIZE - 1" not in b.script.text
+
+
+def test_default_labels_come_from_columns(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1, x="__idx__0")])
+ with b.auto() as r:
+ assert r.fig.axes[0].get_xlabel() == "時間" and r.fig.axes[0].get_ylabel() == "温度 [1]"
+ b = Built(tmp_path, data, None, [S(1), S(2)])
+ with b.auto() as r:
+ assert r.fig.axes[0].get_xlabel() == "index" and r.fig.axes[0].get_ylabel() == "values"
+
+
+def test_margins(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["plot"]["margins"].update(left=0.2, right=0.9), [S(1)])
+ with b.auto() as r:
+ assert r.fig.subplotpars.left == pytest.approx(0.2) and r.fig.subplotpars.right == pytest.approx(0.9)
+ assert "fig.tight_layout()" in b.script.text and "fig.subplots_adjust(left=0.2, right=0.9)" in b.script.text
+
+ b = Built(tmp_path, data, lambda s: s["plot"]["margins"].update(left=0.2, right=0.9, bottom=0.1, top=0.95), [S(1)])
+ assert "fig.tight_layout()" not in b.script.text and "fig.subplots_adjust(left=0.2, right=0.9, bottom=0.1, top=0.95)" in b.script.text
+ with b.auto() as r:
+ p = r.fig.subplotpars
+ assert (p.left, p.right, p.bottom, p.top) == (0.2, 0.9, 0.1, 0.95)
+
+ b = Built(tmp_path, data, None, [S(1)])
+ assert "subplots_adjust" not in b.script.text
+
+
+def test_skip_rows(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["plot"].update(skipRows=3), [S(0)])
+ with b.auto() as r:
+ assert list(lines(r.fig.axes[0])[0].get_ydata()) == list(data["時間"][3:])
+ assert "df.iloc[3:]" in b.script.text
+ b = Built(tmp_path, data, None, [S(0)])
+ assert ".iloc[0:]" not in b.script.text and "reset_index" not in b.script.text
+
+
+def test_x_index_vs_column(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: s["plot"].update(skipRows=2), [S(2)])
+ assert "pd.Series(df.index)" in b.script.text
+ with b.auto() as r:
+ assert list(lines(r.fig.axes[0])[0].get_xdata())[:2] == [0, 1]
+ b = Built(tmp_path, data, None, [S(1, x="__idx__0")])
+ assert "pd.Series(df.index)" not in b.script.text and "df.iloc[:, 0]" in b.script.text
+
+
+def test_x_numeric_strings_are_converted_and_text_x_is_positional(tmp_path):
+ df = pd.DataFrame({"x": ["1", "2", "3", "4"], "k": list("abcd"), "y": [1.0, 2.0, 3.0, 4.0]})
+ b = Built(tmp_path, df, None, [S(2, x="__idx__0")])
+ assert 'x = pd.to_numeric(df.iloc[:, 0], errors="coerce")' not in b.script.text # CSV から読むと数値列になる
+ b = Built(tmp_path, df.assign(x=["1", "2", "3", "x"]), None, [S(2, x="__idx__0")])
+ assert 'x = pd.to_numeric(df.iloc[:, 0], errors="coerce")' in b.script.text
+ b = Built(tmp_path, df, None, [S(2, x="__idx__1")])
+ assert 'ax.set_xscale("linear")' in b.script.text
+ b.summaries()
+
+
+def test_no_header_file(tmp_path):
+ df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]})
+ (tmp_path / "raw.csv").write_text("1,4\n2,5\n3,6\n", encoding="utf-8")
+ loaded = load_file((tmp_path / "raw.csv").read_bytes(), "raw.csv", "", False)
+ raw = default_settings()
+ raw["series"] = [S(1, x="__idx__0")]
+ settings = parse_settings(raw)
+ script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings))
+ assert "header=None" in script.text and "column_{i}" in script.text
+ with run_script(script.text, cwd=tmp_path) as r:
+ assert list(lines(r.fig.axes[0])[0].get_ydata()) == [4, 5, 6]
+ assert r.fig.axes[0].get_xlabel() == "column_0"
+
+
+def test_save_section_uses_save_settings(tmp_path, data):
+ def m(s):
+ s["save"].update(filename="結果.csv", format="jpg")
+
+ b = Built(tmp_path, data, m, [S(1)])
+ assert 'fig.savefig("結果.jpg", dpi=120, facecolor="white")' in b.script.text
+ b = Built(tmp_path, data, lambda s: s["save"].update(format="svg", transparent=True), [S(1)])
+ assert 'fig.savefig("plot.svg", transparent=True)' in b.script.text
+ b = Built(tmp_path, data, None, [S(1)])
+ assert 'fig.savefig("plot.png", dpi=120)' in b.script.text and b.script.text.rstrip().endswith("plt.show()")
+
+
+def test_font_list_comes_from_fonts_module(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1)])
+ expected = "[" + ", ".join(literal(n) for n in SANS_SERIF_PRIORITY) + "]"
+ assert f'plt.rcParams["font.sans-serif"] = {expected}' in b.script.text
+ assert SANS_SERIF_PRIORITY[0] == "Noto Sans CJK JP"
+
+
+def test_script_only_imports_pandas_matplotlib_numpy(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1), S(2, secondaryAxis=True)])
+ tree = ast.parse(b.script.text)
+ imported = {a.name.split(".")[0] for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names}
+ imported |= {n.module.split(".")[0] for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)}
+ assert imported == {"matplotlib", "numpy", "pandas"}
+ assert "mplgui" not in b.script.text
+
+
+def test_steps_cover_failure_prone_lines(tmp_path, data):
+ b = Built(tmp_path, data, lambda s: (s["plot"]["margins"].update(left=0.1), s["axes"]["y"].update(scale="log", min=1)), [S(1, x="__idx__0")])
+ text_lines = b.script.text.split("\n")
+
+ def step_text(line):
+ step = b.script.step_for_line(line)
+ return step.message if step else None
+
+ def line_of(snippet):
+ return next(i + 1 for i, line in enumerate(text_lines) if snippet in line)
+
+ assert step_text(line_of("ax.plot(")) == codegen.GENERIC_ERROR_MESSAGE
+ assert step_text(line_of("ax.set_yscale")) == codegen.axis_error_message("Y")
+ assert b.script.step_for_line(line_of("ax.set_yscale")).field == "Y軸の範囲"
+ assert step_text(line_of("fig.tight_layout()")) == codegen.TIGHT_LAYOUT_ERROR
+ assert b.script.step_for_line(line_of("fig.tight_layout()")).field == "余白"
+ assert step_text(line_of("fig.subplots_adjust")) == codegen.MARGIN_ERROR
+ assert step_text(line_of("import pandas")) is None and b.script.step_for_line(None) is None
+
+
+def test_section_line_ranges(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1)])
+ text_lines = b.script.text.split("\n")
+ s, e = b.script.load_lines
+ assert text_lines[s - 1].startswith("# ==== 1.") and text_lines[e].startswith("# ==== 2.") is False # 空行を挟む
+ assert "read_csv" in "\n".join(text_lines[s - 1:e])
+ s, e = b.script.output_lines
+ assert text_lines[s - 1].startswith("# ==== 8.") and "plt.show()" in text_lines[e - 1]
+
+
+# ---------------------------------------------------------------- source kinds
+
+
+def test_xlsx_load_section(tmp_path):
+ pytest.importorskip("openpyxl")
+ shutil.copy(FIXTURES / "multi_sheet.xlsx", tmp_path / "multi_sheet.xlsx")
+ loaded = load_file((tmp_path / "multi_sheet.xlsx").read_bytes(), "multi_sheet.xlsx", "", True)
+ assert loaded.source.kind == "xlsx" and loaded.source.sheet_name == "Sheet1" and loaded.encoding is None
+ raw = default_settings()
+ raw["series"] = [S(1, x="__idx__0")]
+ settings = parse_settings(raw)
+ script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings))
+ assert 'pd.read_excel(DATA_FILE, sheet_name="Sheet1", header=0)' in script.text and "openpyxl" in script.text
+ with run_script(script.text, cwd=tmp_path) as r:
+ assert len(lines(r.fig.axes[0])) == 1
+
+
+# ---------------------------------------------------------------- load equivalence with loader.load_file
+
+LOAD_CASES = [
+ ("utf8.csv", ""), ("utf8_bom.csv", ""), ("cp932.csv", ""), ("euc_jp.csv", ""), ("tab.txt", ""),
+ ("tab.txt", "\\t"), ("duplicate_columns.csv", ""), ("non_numeric.csv", ""), ("multi_sheet.xlsx", ""),
+]
+
+
+@pytest.mark.parametrize("has_header", [True, False])
+@pytest.mark.parametrize("name, delimiter", LOAD_CASES)
+def test_generated_load_section_matches_loader(tmp_path, name, delimiter, has_header):
+ if name.endswith(".xlsx"):
+ pytest.importorskip("openpyxl")
+ shutil.copy(FIXTURES / name, tmp_path / name)
+ loaded = load_file((FIXTURES / name).read_bytes(), name, delimiter, has_header)
+ raw = default_settings()
+ raw["series"] = [{"x": "", "y": ""}]
+ settings = parse_settings(raw)
+ try:
+ plan = plan_plot(loaded.df, settings)
+ except Exception:
+ plan = plan_plot(loaded.df, parse_settings({**default_settings(), "series": [S(0)]}))
+ script = generate_script(settings, loaded.source, plan)
+ start, end = script.load_lines
+ section = "import pandas as pd\n" + "\n".join(script.text.split("\n")[start - 1:end])
+ namespace: dict = {}
+ cwd = Path.cwd()
+ import os
+
+ os.chdir(tmp_path)
+ try:
+ exec(compile(section, "load.py", "exec"), namespace)
+ finally:
+ os.chdir(cwd)
+ pd.testing.assert_frame_equal(namespace["df"], loaded.df)
+
+
+# ---------------------------------------------------------------- security
+
+HOSTILE = [
+ 'a"\n__import__("os").system("echo hacked")\n#',
+ "x\nraise SystemExit",
+ "'''\nimport os; os.system('echo hacked')\n'''",
+ "
raise SystemExit
",
+ "a\\",
+ "t\x0craise SystemExit\x0b\x85",
+]
+
+
+def _calls(tree):
+ names = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Call):
+ f = node.func
+ names.add(f.id if isinstance(f, ast.Name) else f.attr if isinstance(f, ast.Attribute) else "")
+ return names
+
+
+@pytest.mark.parametrize("hostile", HOSTILE)
+def test_untrusted_strings_cannot_inject_code(tmp_path, hostile):
+ df = pd.DataFrame({hostile: [1.0, 2.0, 3.0], "v": [4.0, 5.0, 6.0]})
+ name = "d.csv"
+ df.to_csv(tmp_path / name, index=False)
+ loaded = load_file((tmp_path / name).read_bytes(), name, "", True)
+
+ def mut(s):
+ s["plot"]["title"] = hostile
+ s["axes"]["x"]["label"] = hostile
+ s["axes"]["y"]["label"] = hostile
+ s["save"]["filename"] = hostile
+ s["plot"]["legend"]["location"] = "best"
+
+ raw = default_settings()
+ raw["series"] = [{"x": "__idx__0", "y": "__idx__1", "label": hostile}, {"x": "__idx__0", "y": "__idx__1", "label": hostile, "secondaryAxis": True}]
+ mut(raw)
+ settings = parse_settings(raw)
+ plan = plan_plot(loaded.df, settings)
+ source = SourceInfo(filename=hostile + ".csv", kind="csv", encoding="utf-8", separator=",", has_header=True)
+ script = generate_script(settings, source, plan)
+
+ tree = ast.parse(script.text) # 文法として正しい
+ assert not ({"__import__", "system", "exit", "eval", "exec", "open"} & _calls(tree))
+ # 実行しても、注入されたコードは動かない
+ with run_script(script.auto_render_text(), injected={"df": loaded.df}) as run:
+ assert "hacked" not in run.output
+ assert run.fig.axes[0].get_title() == hostile.strip()
+ # 悪意のある文字列がコメントの中で行を壊していない(コメント内に改行が無い)
+ for line in script.text.split("\n"):
+ assert "\r" not in line and "
" not in line and "\x0b" not in line and "\x0c" not in line and "\x85" not in line
+
+
+def test_hostile_text_only_appears_inside_literals_and_comments():
+ hostile = 'q"\nimport os\n'
+ text = codegen.comment_text(hostile)
+ assert "\n" not in text
+ assert literal(hostile) == repr(hostile) and "\n" not in literal(hostile)
+ assert literal("plain") == '"plain"' and literal("it's") == '"it\'s"' and literal('say "hi"') == "'say \"hi\"'"
+ assert ast.literal_eval(literal("a\\")) == "a\\" and ast.literal_eval(literal("""a'b"c""")) == """a'b"c"""
+
+
+def test_literal_accepts_only_plain_values():
+ assert literal(None) == "None" and literal(True) == "True" and literal(3) == "3" and literal(2.5) == "2.5"
+ assert literal("a'b") == '"a\'b"'
+ for bad in (float("nan"), float("inf"), [1], (1,), {"a": 1}, b"x", object(), np.float64(1.0), np.int64(1)):
+ with pytest.raises((TypeError, ValueError)):
+ literal(bad)
+
+
+def test_comment_text_replaces_line_breaks_and_control_chars():
+ text = comment_text("a\nb\rc
d
e\x85f\x0bg\x0ch\x00i\x1bj\tk")
+ assert text == "a b c d e f g h i j k"
+ assert comment_text("温度 [1]") == "温度 [1]"
+
+
+def test_legend_is_not_emitted_when_no_label_can_be_shown(tmp_path, data):
+ series = [S(1, x="__idx__0", label="_hidden"), S(2, x="__idx__0", label="_also")]
+ b = Built(tmp_path, data, None, series)
+ assert "ax.legend(" not in b.script.text and "get_legend_handles_labels" not in b.script.text
+ assert "凡例は表示しない" in b.script.text or "凡例に出せるラベルが無い" in b.script.text
+ full, auto = b.summaries()
+ assert full[0]["legend"] == [] and auto[0]["legend"] == []
+
+
+def test_legend_is_kept_when_one_label_is_visible(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1, x="__idx__0", label="_hidden"), S(2, x="__idx__0", label="見える")])
+ assert "ax.legend(" in b.script.text
+ full, _ = b.summaries()
+ assert full[0]["legend"] == ["見える"]
+
+
+def test_series_blocks_are_separated_by_a_blank_line(tmp_path, data):
+ for plot_type in ("line", "scatter"):
+ b = Built(tmp_path, data, lambda s, t=plot_type: s["plot"].update(type=t), [S(1, x="__idx__0"), S(2, x="__idx__0")])
+ lines_ = b.script.text.split("\n")
+ i = next(n for n, line in enumerate(lines_) if line.startswith("# 系列2"))
+ assert lines_[i - 1] == "" and lines_[i - 2] != ""
+
+
+def test_comments_use_the_same_word_for_tick_marks(tmp_path, data):
+ b = Built(tmp_path, data, None, [S(1, x="__idx__3")])
+ assert "set_xscale" in b.script.text and "目盛り" not in b.script.text
diff --git a/tests/unit/test_dataprep.py b/tests/unit/test_dataprep.py
new file mode 100644
index 0000000..4fda425
--- /dev/null
+++ b/tests/unit/test_dataprep.py
@@ -0,0 +1,235 @@
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from mplgui.dataprep import (
+ NO_DRAWABLE_DATA,
+ coerce_numeric,
+ plan_plot,
+ resolve_column_index,
+ resolve_y_indices,
+)
+from mplgui.errors import UserError
+from mplgui.loader import load_file
+from mplgui.settings import default_settings, parse_settings
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def df():
+ t = np.arange(20, dtype=float)
+ return pd.DataFrame({"t": t, "a": np.sin(t), "b": t * 2.0, "name": [f"n{i}" for i in range(20)]})
+
+
+def settings_with(mutator=None, series=None):
+ raw = default_settings()
+ if series is not None:
+ raw["series"] = series
+ if mutator:
+ mutator(raw)
+ return parse_settings(raw)
+
+
+def plan(df, mutator=None, series=None):
+ return plan_plot(df, settings_with(mutator, series))
+
+
+def _csv_df(name="non_numeric.csv"):
+ path = Path(__file__).resolve().parents[1] / "fixtures" / name
+ return load_file(path.read_bytes(), name, "", True).df
+
+
+# ---------------------------------------------------------------- plan content
+
+
+def test_plan_line_defaults(df):
+ p = plan(df, series=[{"x": "__idx__0", "y": "__idx__1"}])
+ s = p.series[0]
+ assert (s.y_index, s.x_index, s.x_label, s.data_label, s.legend_label) == (1, 0, "t", "a [1]", "a [1]")
+ assert s.has_points and not s.convert_x and not s.categorical_x and s.marker_size == 0.0
+ assert (p.x_label, p.y_label, p.y2_label, p.plotted_count, p.uses_secondary) == ("t", "a [1]", None, 1, False)
+
+
+def test_plan_x_index_and_custom_legend(df):
+ p = plan(df, series=[{"y": "__idx__1", "label": "電圧"}])
+ s = p.series[0]
+ assert s.x_index is None and s.x_label == "index" and p.x_label == "index" and s.legend_label == "電圧"
+
+
+def test_plan_common_x_label_falls_back_to_x(df):
+ p = plan(df, series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__2", "y": "__idx__2"}])
+ assert p.x_label == "x"
+
+
+def test_plan_labels_multiple_series_are_values(df):
+ p = plan(df, series=[{"y": "__idx__1"}, {"y": "__idx__2"}, {"y": "__idx__2", "secondaryAxis": True}, {"y": "__idx__1", "secondaryAxis": True}])
+ assert p.y_label == "values" and p.y2_label == "values" and p.uses_secondary
+
+
+def test_plan_secondary_label_single(df):
+ p = plan(df, series=[{"y": "__idx__1"}, {"y": "__idx__2", "secondaryAxis": True}])
+ assert p.y_label == "a [1]" and p.y2_label == "b [2]"
+
+
+def test_plan_marker_sizes(df):
+ def kind(t):
+ return lambda s: s["plot"].__setitem__("type", t)
+
+ assert plan(df, kind("line"), [{"y": "__idx__1"}]).series[0].marker_size == 0.0
+ assert plan(df, kind("scatter"), [{"y": "__idx__1"}]).series[0].marker_size == 24.0
+ assert plan(df, kind("scatter"), [{"y": "__idx__1", "markerSize": 0}]).series[0].marker_size == 24.0
+ assert plan(df, kind("line"), [{"y": "__idx__1", "markerSize": 9}]).series[0].marker_size == 9.0
+
+
+def test_plan_series_without_points_is_skipped_but_counted_for_labels():
+ d = pd.DataFrame({"t": [1.0, 2.0], "bad": ["x", "y"], "ok": [1.0, 2.0]})
+ p = plan(d, series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__2", "secondaryAxis": True}])
+ assert [s.has_points for s in p.series] == [False, True]
+ assert p.plotted_count == 1 and p.uses_secondary
+
+
+def test_plan_secondary_without_points_creates_no_second_axis():
+ d = pd.DataFrame({"t": [1.0, 2.0], "bad": ["x", "y"], "ok": [1.0, 2.0]})
+ p = plan(d, series=[{"x": "__idx__0", "y": "__idx__2"}, {"x": "__idx__0", "y": "__idx__1", "secondaryAxis": True}])
+ assert not p.uses_secondary and p.y2_label is None
+
+
+def test_plan_x_conversion_flags():
+ d = pd.DataFrame({"x": ["1", "2", "3"], "d": ["a", "b", "c"], "n": [1, 2, 3], "y": [1.0, 2.0, 3.0]})
+ s = plan(d, series=[{"x": "__idx__0", "y": "__idx__3"}, {"x": "__idx__1", "y": "__idx__3"}, {"x": "__idx__2", "y": "__idx__3"}]).series
+ assert (s[0].convert_x, s[0].categorical_x) == (True, False)
+ assert (s[1].convert_x, s[1].categorical_x) == (False, True)
+ assert (s[2].convert_x, s[2].categorical_x) == (False, False)
+
+
+def test_plan_bar_single_and_multi(df):
+ p = plan(df, lambda s: s["plot"].update(type="bar", xColumn="__idx__3"), [{"y": "__idx__1"}])
+ assert p.bar_x_index == 3 and p.x_label == "name" and p.plotted_count == 1
+ p = plan(df, lambda s: s["plot"].update(type="bar"), [{"y": "__idx__1"}, {"y": "__idx__2"}])
+ assert p.bar_x_index is None and p.x_label == "index" and p.plotted_count == 2 and p.y_label == "values"
+
+
+def test_skip_rows_are_applied_before_resolution():
+ d = pd.DataFrame({"a": ["x", "y", "1", "2"]})
+ p = plan(d, lambda s: s["plot"].__setitem__("skipRows", 2), [{"y": "__idx__0"}])
+ assert p.skip_rows == 2 and p.warnings == ()
+
+
+# ---------------------------------------------------------------- errors (same messages as before)
+
+
+def test_skip_rows_too_large(df):
+ with pytest.raises(UserError) as info:
+ plan(df, lambda s: s["plot"].__setitem__("skipRows", 20))
+ assert "スキップ" in info.value.message and info.value.field == "除外する先頭行数"
+
+
+def test_no_plottable_data_raises():
+ with pytest.raises(UserError) as info:
+ plan(pd.DataFrame({"a": ["x", "y", "z"]}), series=[{"y": "__idx__0"}])
+ assert info.value.message.startswith("描画可能な数値データがありません。")
+
+
+def test_bar_single_without_points_raises():
+ d = pd.DataFrame({"x": [None, None], "y": [1.0, 2.0]})
+ with pytest.raises(UserError) as info:
+ plan(d, lambda s: s["plot"].update(type="bar", xColumn="__idx__0"), [{"y": "__idx__1"}])
+ assert info.value.message == NO_DRAWABLE_DATA
+
+
+def test_column_out_of_range_is_user_error(df):
+ with pytest.raises(UserError) as info:
+ plan(df, series=[{"y": "__idx__99"}])
+ assert "100列目" in info.value.message
+ with pytest.raises(UserError) as info:
+ plan(df, series=[{"x": "__idx__50", "y": "__idx__1"}])
+ assert "51列目" in info.value.message
+
+
+def test_malformed_and_unknown_column(df):
+ with pytest.raises(UserError) as info:
+ plan(df, series=[{"y": "__idx__x"}])
+ assert info.value.message == "列の指定が正しくありません。列を選び直してください。"
+ with pytest.raises(UserError) as info:
+ plan(df, series=[{"y": "nonexistent"}])
+ assert "「nonexistent」" in info.value.message
+ assert resolve_column_index(df, "b") == 2
+
+
+def test_duplicate_column_names_resolved_by_index():
+ d = pd.DataFrame([[1, 10, 100], [2, 20, 200], [3, 30, 300]], columns=["温度", "温度", "値"])
+ p = plan(d, series=[{"y": "__idx__0"}, {"y": "__idx__1"}])
+ assert [s.y_index for s in p.series] == [0, 1] and [s.data_label for s in p.series] == ["温度 [0]", "温度 [1]"]
+
+
+# ---------------------------------------------------------------- Y auto
+
+
+def test_auto_y_assigns_unused_numeric_columns(df):
+ p = plan(df, series=[{}, {}])
+ assert [s.y_index for s in p.series] == [0, 1]
+
+
+def test_resolve_y_indices(df):
+ assert resolve_y_indices(df, ["", "__idx__2", ""]) == ["__idx__0", "__idx__2", "__idx__1"]
+ assert resolve_y_indices(pd.DataFrame({"a": ["x"]}), ["", ""]) == ["__idx__0", "__idx__0"]
+
+
+# ---------------------------------------------------------------- coerce_numeric / A7 warnings
+
+
+def test_coerce_numeric_numeric_dtype_returns_immediately():
+ s = pd.Series([1.0, np.nan, 3.0])
+ out, count, examples = coerce_numeric(s)
+ assert out is s and count == 0 and examples == []
+ out, count, _ = coerce_numeric(pd.Series(["1", "x", ""]))
+ assert count == 1 and out.isna().sum() == 2
+
+
+def test_dropped_values_are_reported_per_series():
+ d = _csv_df()
+ p = plan(d, series=[
+ {"x": "__idx__0", "y": "__idx__2"}, # 電圧: 数値列(空欄・N/A は欠損値でありカウントしない)
+ {"x": "__idx__0", "y": "__idx__1"}, # 金額: "1,234" 形式 → 全件変換不可
+ {"x": "__idx__0", "y": "__idx__3"}, # 単位付き: "1 mV"
+ ])
+ assert p.plotted_count == 1
+ msgs = {w["series"]: w["message"] for w in p.warnings}
+ assert set(msgs) == {2, 3}
+ assert msgs[2].startswith("系列2(金額): 数値に変換できない値が20件あったため、その行を除外しました(例: ")
+ assert '"1,234"' in msgs[2] and '"2,468"' in msgs[2] and '"3,702"' in msgs[2] and '"4,936"' not in msgs[2]
+ assert '"1 mV"' in msgs[3] and msgs[3].endswith(")。")
+
+
+def test_blank_cells_are_not_counted_as_dropped():
+ d = pd.DataFrame({"t": [1, 2, 3, 4], "v": ["1", "", " ", "x"]})
+ p = plan(d, series=[{"x": "__idx__0", "y": "__idx__1"}])
+ assert len(p.warnings) == 1
+ assert "1件" in p.warnings[0]["message"] and '"x"' in p.warnings[0]["message"] and p.warnings[0]["series"] == 1
+
+
+def test_no_warning_for_clean_data(df):
+ assert plan(df).warnings == ()
+
+
+def test_x_column_dropped_values_are_reported():
+ d = pd.DataFrame({"x": ["1", "2", "3", "oops", "5"], "y": [1.0, 2.0, 3.0, 4.0, 5.0]})
+ p = plan(d, series=[{"x": "__idx__0", "y": "__idx__1"}])
+ assert p.plotted_count == 1
+ assert "系列1のX列(x)" in p.warnings[0]["message"] and '"oops"' in p.warnings[0]["message"]
+
+
+def test_all_values_dropped_error_mentions_cause():
+ d = _csv_df()
+ with pytest.raises(UserError) as info:
+ plan(d, series=[{"x": "__idx__0", "y": "__idx__1"}])
+ assert "数値に変換できない値" in info.value.message
+
+
+def test_bar_reports_dropped_values():
+ d = pd.DataFrame({"x": list("abcd"), "y": ["1", "2", "bad", "4"]})
+ p = plan(d, lambda s: s["plot"].update(type="bar"), [{"y": "__idx__1"}])
+ assert "系列1(y)" in p.warnings[0]["message"]
diff --git a/tests/unit/test_fonts.py b/tests/unit/test_fonts.py
new file mode 100644
index 0000000..638d466
--- /dev/null
+++ b/tests/unit/test_fonts.py
@@ -0,0 +1,28 @@
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import pytest
+from matplotlib import font_manager
+
+from mplgui import fonts
+
+pytestmark = pytest.mark.unit
+
+
+def test_configure_rcparams_uses_priority_list():
+ with mpl.rc_context():
+ fonts.configure_rcparams()
+ assert plt.rcParams["font.sans-serif"] == fonts.SANS_SERIF_PRIORITY
+ assert plt.rcParams["font.family"] == ["sans-serif"] and plt.rcParams["axes.unicode_minus"] is False
+
+
+def test_priority_starts_with_noto_sans_cjk_jp():
+ assert fonts.SANS_SERIF_PRIORITY[:2] == ["Noto Sans CJK JP", "Noto Sans JP"]
+ assert fonts.SANS_SERIF_PRIORITY[-1] == "DejaVu Sans"
+
+
+def test_register_font_file_leaves_the_same_order_as_generated_scripts():
+ path = font_manager.findfont("DejaVu Sans")
+ with mpl.rc_context():
+ plt.rcParams["font.sans-serif"] = ["Arial"] # 任意の状態から始めても
+ fonts.register_font_file(path)
+ assert plt.rcParams["font.sans-serif"] == fonts.SANS_SERIF_PRIORITY
diff --git a/tests/unit/test_loader.py b/tests/unit/test_loader.py
index 9eb784a..643aabb 100644
--- a/tests/unit/test_loader.py
+++ b/tests/unit/test_loader.py
@@ -210,3 +210,29 @@ def test_ideographic_comma_delimited_file_loads():
loaded = load_file(data, "comma.txt", "、", True)
assert list(loaded.df.columns) == ["時間", "電圧", "電流"]
assert loaded.df.shape == (2, 3)
+
+
+# ---------------------------------------------------------------- source info (codegen の読込部に使う)
+
+
+def test_source_info_for_csv_and_txt():
+ src = _load("cp932.csv").source
+ assert (src.filename, src.kind, src.encoding, src.separator, src.has_header, src.sheet_name) == (
+ "cp932.csv", "csv", "cp932", ",", True, None,
+ )
+ src = _load("tab.txt").source
+ assert (src.kind, src.separator) == ("txt", r"\s+")
+ assert _load("tab.txt", delimiter=r"\t", has_header=False).source.separator == "\t"
+ assert _load("tab.txt", has_header=False).source.has_header is False
+
+
+def test_source_info_filename_is_base_name():
+ assert load_file(b"a,b\n1,2\n", "dir/sub/x.csv", "", True).source.filename == "x.csv"
+
+
+def test_source_info_for_xlsx_uses_first_sheet():
+ pytest.importorskip("openpyxl")
+ loaded = _load("multi_sheet.xlsx")
+ src = loaded.source
+ assert (src.kind, src.encoding, src.separator, src.sheet_name) == ("xlsx", None, None, "Sheet1")
+ assert list(loaded.df.columns) == ["時間", "電圧", "電流"]
diff --git a/tests/unit/test_plotting.py b/tests/unit/test_plotting.py
deleted file mode 100644
index 31949d2..0000000
--- a/tests/unit/test_plotting.py
+++ /dev/null
@@ -1,399 +0,0 @@
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import pytest
-from matplotlib.collections import PathCollection
-from matplotlib.patches import Rectangle
-
-from mplgui.errors import UserError
-from mplgui.plotting import create_figure, get_per_series_xy_data, get_plot_data, resolve_series_requests
-from mplgui.settings import default_settings, parse_settings, series_to_legacy
-
-pytestmark = pytest.mark.unit
-
-
-@pytest.fixture
-def df():
- t = np.arange(20, dtype=float)
- return pd.DataFrame({"t": t, "a": np.sin(t), "b": t * 2.0, "name": [f"n{i}" for i in range(20)]})
-
-
-def settings_with(mutator=None, series=None):
- raw = default_settings()
- if series is not None:
- raw["series"] = series
- if mutator:
- mutator(raw)
- return parse_settings(raw)
-
-
-def build(df, mutator=None, series=None):
- result = create_figure(df, settings_with(mutator, series))
- return result
-
-
-def close(result):
- plt.close(result.fig)
-
-
-def lines(ax):
- return [ln for ln in ax.get_lines()]
-
-
-def bars(ax):
- return [p for p in ax.patches if isinstance(p, Rectangle)]
-
-
-def test_line_two_series():
- r = build(
- pd.DataFrame({"t": range(10), "a": range(10), "b": range(10, 20)}),
- series=[{"x": "__idx__0", "y": "__idx__1", "color": "#005AFF"}, {"x": "__idx__0", "y": "__idx__2", "color": "#03AF7A"}],
- )
- try:
- ax = r.fig.axes[0]
- assert len(r.fig.axes) == 1 and len(lines(ax)) == 2 and r.plotted_count == 2
- assert lines(ax)[0].get_color().lower() == "#005aff" and lines(ax)[0].get_linewidth() == 2
- assert lines(ax)[0].get_marker() in ("", "None", None) # 点サイズ自動(line)=0 は点なし
- assert not ax.collections
- finally:
- close(r)
-
-
-def test_line_marker_shown_when_size_positive():
- r = build(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), series=[{"markerSize": 9}])
- try:
- ln = lines(r.fig.axes[0])[0]
- assert ln.get_marker() == "o" and ln.get_markersize() == pytest.approx(3.0)
- finally:
- close(r)
-
-
-def test_line_style_and_width():
- r = build(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), series=[{"lineStyle": "dashed", "lineWidth": 3.5}])
- try:
- ln = lines(r.fig.axes[0])[0]
- assert ln.get_linestyle() == "--" and ln.get_linewidth() == 3.5
- finally:
- close(r)
-
-
-def test_scatter_creates_path_collections():
- r = build(
- pd.DataFrame({"x": range(5), "a": range(5), "b": range(5)}),
- mutator=lambda s: s["plot"].__setitem__("type", "scatter"),
- series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__2", "markerSize": 50}],
- )
- try:
- ax = r.fig.axes[0]
- cols = [c for c in ax.collections if isinstance(c, PathCollection)]
- assert len(cols) == 2 and not lines(ax)
- assert cols[0].get_sizes()[0] == 24 # 自動 → scatter の既定 24
- assert cols[1].get_sizes()[0] == 50
- finally:
- close(r)
-
-
-def test_scatter_size_zero_is_corrected_to_24():
- r = build(
- pd.DataFrame({"a": [1.0, 2.0]}),
- mutator=lambda s: s["plot"].__setitem__("type", "scatter"),
- series=[{"markerSize": 0}],
- )
- try:
- assert r.fig.axes[0].collections[0].get_sizes()[0] == 24
- finally:
- close(r)
-
-
-def test_bar_single_series_uses_x_column(df):
- r = build(
- df.head(6),
- mutator=lambda s: s["plot"].update(type="bar", xColumn="__idx__0"),
- series=[{"y": "__idx__1"}],
- )
- try:
- assert len(bars(r.fig.axes[0])) == 6
- finally:
- close(r)
-
-
-def test_bar_multiple_series_grouped(df):
- r = build(
- df.head(5),
- mutator=lambda s: s["plot"].update(type="bar"),
- series=[{"y": "__idx__1"}, {"y": "__idx__2"}],
- )
- try:
- ax = r.fig.axes[0]
- assert len(bars(ax)) == 10 and r.plotted_count == 2
- assert set(range(5)) <= set(ax.get_xticks())
- finally:
- close(r)
-
-
-def test_secondary_axis_uses_twinx():
- d = pd.DataFrame({"x": range(10), "a": range(10), "b": [v * 100 for v in range(10)]})
- r = build(
- d,
- series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__2", "secondaryAxis": True}],
- mutator=lambda s: s["axes"]["y2"].update(label="右軸"),
- )
- try:
- assert len(r.fig.axes) == 2
- ax, ax2 = r.fig.axes
- assert len(lines(ax)) == 1 and len(lines(ax2)) == 1
- assert ax2.get_ylabel() == "右軸"
- assert ax2.get_shared_x_axes().joined(ax, ax2)
- # 凡例は両軸のハンドルを統合する
- assert [t.get_text() for t in ax.get_legend().get_texts()] == ["a [1]", "b [2]"]
- # ヒゲ: 主軸は下・左、第2軸は右のみ
- assert ax2.yaxis.get_ticks_position() in ("right", "default") and ax.xaxis.get_ticks_position() == "bottom"
- finally:
- close(r)
-
-
-def test_no_second_axis_without_secondary_series(df):
- r = build(df, series=[{"y": "__idx__1"}])
- try:
- assert len(r.fig.axes) == 1
- finally:
- close(r)
-
-
-@pytest.mark.parametrize("loc, expect_legend", [("best", True), ("upper left", True), ("none", False)])
-def test_legend_presence(df, loc, expect_legend):
- r = build(df, mutator=lambda s: s["plot"]["legend"].__setitem__("location", loc), series=[{"y": "__idx__1"}])
- try:
- legend = r.fig.axes[0].get_legend()
- assert (legend is not None) is expect_legend
- if expect_legend:
- assert [t.get_text() for t in legend.get_texts()] == ["a [1]"]
- finally:
- close(r)
-
-
-def test_legend_custom_label(df):
- r = build(df, series=[{"y": "__idx__1", "label": "電圧"}])
- try:
- assert [t.get_text() for t in r.fig.axes[0].get_legend().get_texts()] == ["電圧"]
- finally:
- close(r)
-
-
-def test_grid_flags(df):
- def grids(ax):
- return (
- any(ln.get_visible() for ln in ax.get_xgridlines()),
- any(ln.get_visible() for ln in ax.get_ygridlines()),
- )
-
- r = build(df, mutator=lambda s: s["plot"]["grid"].update(major=True))
- try:
- assert grids(r.fig.axes[0]) == (True, True)
- finally:
- close(r)
- r = build(df)
- try:
- assert grids(r.fig.axes[0]) == (False, False)
- finally:
- close(r)
- r = build(df, mutator=lambda s: s["plot"]["grid"].update(minor=True))
- try:
- ax = r.fig.axes[0]
- assert ax.xaxis.get_minor_ticks()[0].gridline.get_visible()
- assert not any(ln.get_visible() for ln in ax.get_xgridlines()) # 主目盛線は出さない
- finally:
- close(r)
-
-
-def test_log_scale_and_limits(df):
- def m(s):
- s["axes"]["y"].update(scale="log", min=0.5, max=100)
- s["axes"]["x"].update(min=2, max=10)
-
- r = build(df.assign(a=np.arange(1, 21)), mutator=m, series=[{"y": "__idx__1"}])
- try:
- ax = r.fig.axes[0]
- assert ax.get_yscale() == "log" and ax.get_xscale() == "linear"
- assert ax.get_ylim() == (0.5, 100) and ax.get_xlim() == (2, 10)
- finally:
- close(r)
-
-
-def test_figure_size_title_and_labels(df):
- def m(s):
- s["plot"].update(title="タイトル", fontSize=12)
- s["plot"]["figure"].update(width=5, height=3)
- s["axes"]["x"]["label"] = "X軸"
- s["axes"]["y"]["label"] = "Y軸"
-
- r = build(df, mutator=m, series=[{"y": "__idx__1"}])
- try:
- ax = r.fig.axes[0]
- assert tuple(r.fig.get_size_inches()) == (5.0, 3.0)
- assert ax.get_title() == "タイトル" and ax.title.get_fontsize() == 14
- assert ax.get_xlabel() == "X軸" and ax.get_ylabel() == "Y軸" and ax.xaxis.label.get_fontsize() == 12
- finally:
- close(r)
-
-
-def test_default_labels_come_from_columns(df):
- r = build(df, series=[{"x": "__idx__0", "y": "__idx__1"}])
- try:
- ax = r.fig.axes[0]
- assert ax.get_xlabel() == "t" and ax.get_ylabel() == "a [1]"
- finally:
- close(r)
-
-
-def test_ticks_inward(df):
- r = build(df)
- try:
- ax = r.fig.axes[0]
- assert ax.xaxis.get_major_ticks()[0]._pad is not None
- assert not ax.xaxis.get_major_ticks()[0].tick2line.get_visible() # 上側のヒゲなし
- assert not ax.yaxis.get_major_ticks()[0].tick2line.get_visible() # 右側のヒゲなし
- finally:
- close(r)
-
-
-def test_margins_applied(df):
- r = build(df, mutator=lambda s: s["plot"]["margins"].update(left=0.2, right=0.9))
- try:
- assert r.fig.subplotpars.left == pytest.approx(0.2) and r.fig.subplotpars.right == pytest.approx(0.9)
- finally:
- close(r)
-
-
-def test_skip_rows_excludes_leading_rows():
- d = pd.DataFrame({"a": [100.0, 200.0, 1.0, 2.0, 3.0]})
- r = build(d, mutator=lambda s: s["plot"].__setitem__("skipRows", 2))
- try:
- assert r.skip_rows == 2
- assert list(lines(r.fig.axes[0])[0].get_ydata()) == [1.0, 2.0, 3.0]
- finally:
- close(r)
-
-
-def test_skip_rows_too_large(df):
- with pytest.raises(UserError) as info:
- build(df, mutator=lambda s: s["plot"].__setitem__("skipRows", 20))
- assert "スキップ" in info.value.message
-
-
-def test_no_plottable_data_raises_and_closes_figure():
- before = set(plt.get_fignums())
- with pytest.raises(UserError):
- build(pd.DataFrame({"a": ["x", "y", "z"]}), series=[{"y": "__idx__0"}])
- assert set(plt.get_fignums()) == before # 失敗しても Figure は残らない
-
-
-def test_column_out_of_range_is_user_error(df):
- with pytest.raises(UserError) as info:
- build(df, series=[{"y": "__idx__99"}])
- assert "100列目" in info.value.message
-
-
-def test_duplicate_column_names_resolved_by_index():
- d = pd.DataFrame([[1, 10, 100], [2, 20, 200], [3, 30, 300]], columns=["温度", "温度", "値"])
- r = build(d, series=[{"y": "__idx__0"}, {"y": "__idx__1"}])
- try:
- ys = [list(ln.get_ydata()) for ln in lines(r.fig.axes[0])]
- assert ys == [[1, 2, 3], [10, 20, 30]]
- finally:
- close(r)
-
-
-def test_auto_y_assigns_unused_numeric_columns(df):
- r = build(df, series=[{}, {}])
- try:
- ys = [list(ln.get_ydata()) for ln in lines(r.fig.axes[0])]
- assert ys[0] == list(df["t"]) and ys[1] == list(df["a"]) # 数値列を順に割り当てる
- finally:
- close(r)
-
-
-def test_legacy_helpers(df):
- s = settings_with(series=[{"y": ""}, {"y": "__idx__2"}])
- legacy = series_to_legacy(s)
- assert resolve_series_requests(df, legacy) == ["__idx__0", "__idx__2"]
- entries, xlabel = get_per_series_xy_data(df, legacy)
- assert xlabel == "index" and len(entries) == 2 and entries[1]["data_label"] == "b [2]"
- x, xl, es = get_plot_data(df, legacy, "__idx__0")
- assert xl == "t" and len(es) == 2 and list(x) == list(df["t"])
-
-
-# ---------------------------------------------------------------- A7: dropped values
-
-
-def _csv_df(name="non_numeric.csv"):
- from pathlib import Path
-
- from mplgui.loader import load_file
-
- path = Path(__file__).resolve().parents[1] / "fixtures" / name
- return load_file(path.read_bytes(), name, "", True).df
-
-
-def test_dropped_values_are_reported_per_series():
- df = _csv_df()
- r = build(df, series=[
- {"x": "__idx__0", "y": "__idx__2"}, # 電圧: 数値列(空欄・N/A は欠損値でありカウントしない)
- {"x": "__idx__0", "y": "__idx__1"}, # 金額: "1,234" 形式 → 全件変換不可
- {"x": "__idx__0", "y": "__idx__3"}, # 単位付き: "1 mV"
- ])
- try:
- assert r.plotted_count == 1
- msgs = {w["series"]: w["message"] for w in r.warnings}
- assert set(msgs) == {2, 3}
- assert msgs[2].startswith("系列2(金額): 数値に変換できない値が20件あったため、その行を除外しました(例: ")
- assert '"1,234"' in msgs[2] and '"2,468"' in msgs[2] and '"3,702"' in msgs[2] and '"4,936"' not in msgs[2]
- assert '"1 mV"' in msgs[3] and msgs[3].endswith(")。")
- finally:
- close(r)
-
-
-def test_blank_cells_are_not_counted_as_dropped():
- df = pd.DataFrame({"t": [1, 2, 3, 4], "v": ["1", "", " ", "x"]})
- r = build(df, series=[{"x": "__idx__0", "y": "__idx__1"}])
- try:
- assert len(r.warnings) == 1
- assert "1件" in r.warnings[0]["message"] and '"x"' in r.warnings[0]["message"]
- assert r.warnings[0]["series"] == 1
- finally:
- close(r)
-
-
-def test_no_warning_for_clean_data(df):
- r = build(df)
- try:
- assert r.warnings == []
- finally:
- close(r)
-
-
-def test_x_column_dropped_values_are_reported():
- df = pd.DataFrame({"x": ["1", "2", "3", "oops", "5"], "y": [1.0, 2.0, 3.0, 4.0, 5.0]})
- r = build(df, series=[{"x": "__idx__0", "y": "__idx__1"}])
- try:
- assert r.plotted_count == 1
- assert "系列1のX列(x)" in r.warnings[0]["message"] and '"oops"' in r.warnings[0]["message"]
- assert len(lines(r.fig.axes[0])[0].get_xdata()) == 4
- finally:
- close(r)
-
-
-def test_all_values_dropped_error_mentions_cause():
- df = _csv_df()
- with pytest.raises(UserError) as info:
- build(df, series=[{"x": "__idx__0", "y": "__idx__1"}])
- assert "数値に変換できない値" in info.value.message
-
-
-def test_bar_reports_dropped_values():
- df = pd.DataFrame({"x": list("abcd"), "y": ["1", "2", "bad", "4"]})
- r = build(df, lambda s: s["plot"].update(type="bar"), series=[{"y": "__idx__1"}])
- try:
- assert "系列1(y)" in r.warnings[0]["message"]
- finally:
- close(r)
diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py
index 0e0ba9f..7ad2c80 100644
--- a/tests/unit/test_runner.py
+++ b/tests/unit/test_runner.py
@@ -1,18 +1,17 @@
-import pandas as pd
+import matplotlib as mpl
import pytest
import matplotlib.pyplot as plt
from mplgui.errors import UserError
from mplgui.runner import (
- DEFAULT_CUSTOM_PLOT_CODE,
+ ScriptError,
build_filename,
figure_to_bytes,
figure_to_data_uri,
- make_figure,
- run_custom_code,
+ run_script,
+ run_to_image,
)
-from mplgui.settings import default_settings, parse_settings
pytestmark = pytest.mark.unit
@@ -25,17 +24,6 @@ def fig():
plt.close(f)
-@pytest.fixture
-def df():
- return pd.DataFrame({"t": [0.0, 1.0, 2.0, 3.0], "v": [1.0, 2.0, 3.0, 4.0], "w": [4.0, 3.0, 2.0, 1.0]})
-
-
-def settings(**plot):
- raw = default_settings()
- raw["plot"].update(plot)
- return parse_settings(raw)
-
-
@pytest.mark.parametrize(
"fmt, magic, mime, ext",
[
@@ -93,114 +81,24 @@ def test_build_filename():
assert build_filename(".png", "png") == "plot.png"
-def test_default_custom_code_runs(df):
- r = run_custom_code(DEFAULT_CUSTOM_PLOT_CODE, df, settings())
- try:
- assert r.plotted_count == 1 and r.skip_rows == 0
- assert len(r.fig.axes[0].get_lines()) == 1
- finally:
- plt.close(r.fig)
-
-
-def test_custom_code_namespace(df):
- code = """
-assert set(['df','current_df','settings','ctx','series_settings','get_plot_data','get_per_series_xy_data',
- 'resolve_series_requests','plt','pd','fig','plotted_count','skip_rows']) <= set(globals()) | set(dir())
-assert list(df.columns) == ['t', 'v', 'w'] and len(df) == 2 and len(current_df) == 4 and skip_rows == 2
-assert settings['plot_type'] == 'line' and 'series_settings' in settings and settings['skip_rows'] == 2
-assert series_settings[0]['y_request'] == '' and ctx['df'] is not None and 'get_plot_data' in ctx['helpers']
-assert resolve_series_requests(df, series_settings) == ['__idx__0']
-x, label, entries = get_plot_data(df, series_settings)
-fig, ax = plt.subplots()
-ax.plot(df['t'], df['v'])
-"""
- r = run_custom_code(code, df, settings(skipRows=2))
- plt.close(r.fig)
- assert r.skip_rows == 2
-
-
-def test_custom_code_gui_overrides_still_apply(df):
- raw = default_settings()
- raw["plot"]["title"] = "GUI題"
- raw["axes"]["x"]["label"] = "GUI X"
- raw["plot"]["legend"]["location"] = "none"
- raw["axes"]["y"].update(min=0, max=10)
- code = "fig, ax = plt.subplots()\nax.plot([1, 2], [3, 4], label='a')\nax.set_title('code')\nax.legend()\n"
- r = run_custom_code(code, df, parse_settings(raw))
- try:
- ax = r.fig.axes[0]
- assert ax.get_title() == "GUI題" and ax.get_xlabel() == "GUI X"
- assert ax.get_legend() is None and ax.get_ylim() == (0, 10)
- finally:
- plt.close(r.fig)
-
-
-def test_custom_code_errors(df):
- with pytest.raises(UserError) as info:
- run_custom_code(" ", df, settings())
- assert "空" in info.value.message
- with pytest.raises(UserError) as info:
- run_custom_code("x = 1", df, settings())
- assert "fig" in info.value.message
- with pytest.raises(UserError) as info:
- run_custom_code("fig = 3", df, settings())
- assert "Figure" in info.value.message
- before = set(plt.get_fignums())
- with pytest.raises(UserError) as info:
- run_custom_code("fig, ax = plt.subplots()\nraise ValueError('boom')", df, settings())
- assert "カスタムコードの実行に失敗しました" in info.value.message and "boom" in info.value.message
- assert info.value.detail and "Traceback" in info.value.detail
- assert set(plt.get_fignums()) == before # 失敗時に作りかけの Figure を残さない
- with pytest.raises(UserError):
- run_custom_code("fig, ax = plt.subplots()", df, settings(skipRows=4)) # スキップ行数 >= 行数
-
-
-def test_custom_code_empty_figure_is_error(df):
- before = set(plt.get_fignums())
- with pytest.raises(UserError):
- run_custom_code("fig, ax = plt.subplots()", df, settings())
- assert set(plt.get_fignums()) == before
-
-
-def test_make_figure_dispatch(df):
- r = make_figure(df, settings())
- plt.close(r.fig)
- assert r.plotted_count == 1
- r = make_figure(df, settings(), "fig, ax = plt.subplots()\nax.plot([0,1],[0,1])")
- plt.close(r.fig)
- assert r.plotted_count == 1
-
-
def test_filename_invalid_characters_are_replaced():
assert build_filename('a/b:c*?.png', "svg") == "a_b_c__.svg"
-def test_tight_layout_failure_is_japanese_user_error(monkeypatch, df):
- from matplotlib.figure import Figure
-
- def boom(self, *a, **k):
- raise RuntimeError("tight failed")
-
- monkeypatch.setattr(Figure, "tight_layout", boom)
- with pytest.raises(UserError) as info:
- make_figure(df, settings())
- assert "レイアウト" in info.value.message and "tight failed" in info.value.detail
-
-
# ---------------------------------------------------------------- PlaceHolderLayoutEngine の除去
-def _default_fig(df):
- from mplgui.plotting import create_figure
-
- return create_figure(df, parse_settings(default_settings())).fig
+def _tight_fig():
+ fig, ax = plt.subplots()
+ ax.plot([0, 1, 2], [0, 1, 0])
+ fig.tight_layout()
+ return fig
@pytest.mark.parametrize("fmt", ["png", "svg", "pdf"])
-def test_dropping_placeholder_engine_keeps_output_identical(df, fmt):
+def test_dropping_placeholder_engine_keeps_output_identical(fmt):
import io
- import matplotlib as mpl
from matplotlib.layout_engine import PlaceHolderLayoutEngine
from mplgui.runner import drop_placeholder_layout_engine
@@ -209,7 +107,7 @@ def test_dropping_placeholder_engine_keeps_output_identical(df, fmt):
kw = {"dpi": 120} if fmt == "png" else {}
def render(drop):
- fig = _default_fig(df)
+ fig = _tight_fig()
try:
assert isinstance(fig.get_layout_engine(), PlaceHolderLayoutEngine)
if drop:
@@ -225,14 +123,13 @@ def render(drop):
assert render(False) == render(True)
-def test_real_layout_engine_is_kept(df):
+def test_real_layout_engine_is_kept():
from mplgui.runner import drop_placeholder_layout_engine
fig, ax = plt.subplots(layout="constrained")
ax.plot([0, 1], [0, 1])
try:
drop_placeholder_layout_engine(fig)
- assert fig.get_layout_engine() is not None
assert fig.get_layout_engine().__class__.__name__ == "ConstrainedLayoutEngine"
figure_to_bytes(fig, "png")
assert fig.get_layout_engine().__class__.__name__ == "ConstrainedLayoutEngine"
@@ -240,7 +137,7 @@ def test_real_layout_engine_is_kept(df):
plt.close(fig)
-def test_figure_to_bytes_draws_fewer_times(df, monkeypatch):
+def test_figure_to_bytes_draws_fewer_times(monkeypatch):
from matplotlib.figure import Figure
calls = {"n": 0}
@@ -250,10 +147,248 @@ def counting(self, *a, **k):
calls["n"] += 1
return orig(self, *a, **k)
- fig = _default_fig(df)
+ fig = _tight_fig()
try:
monkeypatch.setattr(Figure, "draw", counting)
figure_to_bytes(fig, "png")
assert calls["n"] == 1
finally:
plt.close(fig)
+
+
+# ---------------------------------------------------------------- run_script
+
+
+SIMPLE = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([0, 1], [0, 1])\n"
+
+
+def test_run_script_returns_fig_and_closes_it_afterwards():
+ before = set(plt.get_fignums())
+ with run_script(SIMPLE) as run:
+ assert len(run.fig.axes[0].lines) == 1
+ assert set(plt.get_fignums()) - before # 実行中は存在する
+ assert set(plt.get_fignums()) == before
+
+
+def test_print_and_stderr_are_captured_in_order(capsys):
+ code = SIMPLE + "import sys\nprint('first')\nprint('second', file=sys.stderr)\nprint('third')\n"
+ with run_script(code) as run:
+ assert run.output == "first\nsecond\nthird\n"
+ assert capsys.readouterr().out == "" # 画面(標準出力)には出ない
+
+
+def test_injected_names_and_namespace():
+ code = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot(values)\nname = __name__\n"
+ with run_script(code, injected={"values": [1, 2, 3]}) as run:
+ assert run.namespace["name"] == "__main__" and list(run.fig.axes[0].lines[0].get_ydata()) == [1, 2, 3]
+
+
+def test_fig_variable_wins_over_last_figure():
+ code = (
+ "import matplotlib.pyplot as plt\n"
+ "fig, ax = plt.subplots(); ax.set_title('first')\n"
+ "other, bx = plt.subplots(); bx.set_title('second')\n"
+ )
+ with run_script(code) as run:
+ assert run.fig.axes[0].get_title() == "first"
+
+
+def test_last_new_figure_is_used_without_fig_variable():
+ code = "import matplotlib.pyplot as plt\nplt.figure().add_subplot().set_title('a')\nplt.figure().add_subplot().set_title('b')\n"
+ before = set(plt.get_fignums())
+ with run_script(code) as run:
+ assert run.fig.axes[0].get_title() == "b"
+ assert set(plt.get_fignums()) == before
+
+
+def test_no_figure_is_user_error():
+ with pytest.raises(UserError) as info:
+ with run_script("x = 1\nprint('hi')\n"):
+ pass
+ assert info.value.message == "図が作られませんでした。fig, ax = plt.subplots() などで図を作ってください。"
+ assert info.value.field == "Pythonコード" and info.value.output == "hi\n"
+
+
+def test_empty_figure_is_allowed():
+ with run_script("import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\n") as run:
+ assert len(run.fig.axes[0].lines) == 0
+
+
+def test_fig_variable_that_is_not_a_figure_falls_back_to_new_figure():
+ with run_script("import matplotlib.pyplot as plt\nfig = 3\nplt.figure().add_subplot()\n") as run:
+ assert len(run.fig.axes) == 1
+
+
+def test_rcparams_do_not_leak_between_runs():
+ before = mpl.rcParams["axes.titlesize"], mpl.rcParams["lines.linewidth"]
+ code = SIMPLE + "plt.rcParams['lines.linewidth'] = 9\nplt.rcParams['axes.titlesize'] = 30\n"
+ with run_script(code):
+ assert mpl.rcParams["lines.linewidth"] == 9
+ assert (mpl.rcParams["axes.titlesize"], mpl.rcParams["lines.linewidth"]) == before
+ with pytest.raises(ScriptError):
+ with run_script(code + "raise ValueError('x')"):
+ pass
+ assert (mpl.rcParams["axes.titlesize"], mpl.rcParams["lines.linewidth"]) == before
+
+
+def test_plt_show_emits_no_warning_but_other_warnings_pass():
+ import warnings
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ with run_script(SIMPLE + "plt.show()\n"):
+ pass
+ assert not [w for w in caught if "non-interactive" in str(w.message)]
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ with run_script(SIMPLE + "import warnings\nwarnings.warn('keep me')\n"):
+ pass
+ assert [str(w.message) for w in caught] == ["keep me"]
+
+
+def test_cwd_is_changed_and_restored(tmp_path):
+ import os
+
+ (tmp_path / "data.txt").write_text("1 2 3", encoding="utf-8")
+ code = SIMPLE + "import os\nvalues = open('data.txt').read()\nprint(os.getcwd() == %r)\n" % str(tmp_path.resolve())
+ before = os.getcwd()
+ with run_script(code, cwd=tmp_path) as run:
+ assert run.output.strip() == "True" and run.namespace["values"] == "1 2 3"
+ assert os.getcwd() == before
+
+
+def test_cwd_is_restored_on_error(tmp_path):
+ import os
+
+ before = os.getcwd()
+ with pytest.raises(ScriptError):
+ with run_script("raise ValueError('x')", cwd=tmp_path):
+ pass
+ assert os.getcwd() == before
+
+
+# ---------------------------------------------------------------- error reporting
+
+
+def _error(code, **kw):
+ with pytest.raises(ScriptError) as info:
+ with run_script(code, **kw):
+ pass
+ return info.value
+
+
+def test_runtime_error_reports_user_frames_line_and_source():
+ code = "import matplotlib.pyplot as plt\nimport pandas as pd\nfig, ax = plt.subplots()\n\ndef f(v):\n return v + undefined_name\n\nf(1)\n"
+ err = _error(code)
+ assert err.line == 6 and err.field == "Pythonコード" and isinstance(err.exc, NameError)
+ assert 'File "plot.py", line 8, in ' in err.traceback_text and " f(1)" in err.traceback_text
+ assert 'File "plot.py", line 6, in f' in err.traceback_text and " return v + undefined_name" in err.traceback_text
+ assert err.traceback_text.rstrip().endswith("NameError: name 'undefined_name' is not defined")
+ assert "runner.py" not in err.traceback_text and "contextlib" not in err.traceback_text
+ assert "runner.py" in err.detail # 全体のトレースバックは別に持つ
+
+
+def test_error_in_library_called_from_user_code_points_at_user_line():
+ err = _error("import pandas as pd\n\npd.DataFrame({'a': [1]})['missing']\n")
+ assert err.line == 3 and isinstance(err.exc, KeyError)
+ assert "pandas" not in err.traceback_text.split("KeyError")[0].replace("import pandas", "").replace("pd.DataFrame", "")
+
+
+def test_status_message_is_japanese_with_line_and_hint():
+ err = _error("x = 1\ny = 2\nprint(zzz)\n")
+ assert err.message == (
+ "Pythonコードの実行中にエラーが発生しました(3行目、NameError: 定義されていない名前を使っています)。"
+ "詳細は「Pythonコード」タブに表示しています。"
+ )
+ assert "is not defined" not in err.message # 英語の例外文はトレースバックだけに出す
+
+
+def test_syntax_error_shows_line_and_caret():
+ err = _error("import matplotlib.pyplot as plt\nfig, ax = plt.subplots(\nx = = 1\n")
+ assert err.line is not None and isinstance(err.exc, SyntaxError)
+ assert 'File "plot.py"' in err.traceback_text and "SyntaxError" in err.traceback_text and "^" in err.traceback_text
+ assert f"{err.line}行目" in err.message and "SyntaxError: 書き方(文法)が正しくありません" in err.message
+
+
+def test_indentation_error_hint():
+ err = _error("if True:\nprint(1)\n")
+ assert "IndentationError: インデント" in err.message and err.line == 2
+
+
+@pytest.mark.parametrize(
+ "code, kind, hint",
+ [
+ ("open('nope.txt')", "FileNotFoundError", "ファイルが見つかりません"),
+ ("import not_a_real_module", "ModuleNotFoundError", "読み込めないライブラリ"),
+ ("from os import nothing_here", "ImportError", "読み込めないライブラリや名前"),
+ ("{}['a']", "KeyError", "存在しないキー"),
+ ("[][1]", "IndexError", "範囲の外"),
+ ("1 + 'a'", "TypeError", "型が合わない"),
+ ("int('x')", "ValueError", "値が正しくありません"),
+ ("object().foo", "AttributeError", "存在しない属性"),
+ ("1 / 0", "ZeroDivisionError", "0で割り算"),
+ ],
+)
+def test_hint_table(code, kind, hint):
+ err = _error(code)
+ assert type(err.exc).__name__ == kind
+ assert f"{kind}: " in err.message and hint in err.message and err.line == 1
+
+
+def test_unknown_exception_type_has_no_hint():
+ err = _error("class MyError(Exception):\n pass\n\nraise MyError('x')\n")
+ assert err.message.startswith("Pythonコードの実行中にエラーが発生しました(4行目、MyError)。")
+
+
+def test_system_exit_is_reported_not_propagated():
+ err = _error("import sys\nsys.exit(2)\n")
+ assert isinstance(err.exc, SystemExit) and err.line == 2 and "SystemExit" in err.message
+
+
+def test_error_keeps_output_printed_before_failure():
+ err = _error("print('before')\n1/0\n")
+ assert err.output == "before\n"
+ assert err.to_dict()["line"] == 2 and "traceback" in err.to_dict()
+
+
+def test_failed_run_leaks_no_figures_and_no_linecache():
+ import linecache
+
+ before = set(plt.get_fignums())
+ _error("import matplotlib.pyplot as plt\nplt.figure()\nraise ValueError('x')\n")
+ assert set(plt.get_fignums()) == before and "plot.py" not in linecache.cache
+
+
+# ---------------------------------------------------------------- run_to_image / summary
+
+
+def test_run_to_image_returns_png_output_and_summary():
+ res = run_to_image(SIMPLE + "ax.set_title('T')\nprint('hello')\n", file_format="png", dpi=100)
+ assert res.data.startswith(b"\x89PNG") and res.output == "hello\n" and res.mime == "image/png"
+ assert res.summary[0]["title"] == "T" and res.summary[0]["lines"] == 1
+
+
+def test_run_to_image_conversion_error_carries_output():
+ with pytest.raises(UserError) as info:
+ run_to_image(SIMPLE + "print('x')\n", file_format="jpg", transparent=True)
+ assert "背景透過" in info.value.message and info.value.output == "x\n"
+
+
+def test_figure_summary_content():
+ import json
+
+ code = (
+ "import matplotlib.pyplot as plt\n"
+ "fig, ax = plt.subplots()\n"
+ "ax.plot([1, 2], [3, 4], label='線')\nax.scatter([1], [1])\nax.bar(['a', 'b'], [1, 2])\n"
+ "ax.set_title('T'); ax.set_xlabel('X'); ax.set_ylabel('Y'); ax.set_yscale('log'); ax.set_ylim(1, 10)\n"
+ "ax.legend()\nax2 = ax.twinx()\n"
+ )
+ res = run_to_image(code)
+ json.dumps(res.summary)
+ first, second = res.summary
+ assert first["title"] == "T" and first["xlabel"] == "X" and first["ylabel"] == "Y"
+ assert first["yscale"] == "log" and first["xscale"] == "linear" and first["ylim"] == [1.0, 10.0]
+ assert (first["lines"], first["collections"], first["patches"]) == (1, 1, 2)
+ assert first["legend"] == ["線"] and second["legend"] == []
+ assert all(isinstance(v, float) for v in first["xlim"])
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index dfe1487..d91ba0e 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -8,8 +8,8 @@
SCHEMA_VERSION,
default_settings,
parse_load_settings,
+ parse_save_settings,
parse_settings,
- to_legacy_dict,
)
pytestmark = pytest.mark.unit
@@ -150,6 +150,8 @@ def test_unknown_version_rejected():
assert "バージョン" in info.value.message
with pytest.raises(UserError):
parse_load_settings({"version": 99})
+ with pytest.raises(UserError):
+ parse_save_settings({"version": 99})
def test_marker_size_auto_and_backstop():
@@ -173,6 +175,20 @@ def test_parse_load_settings_ignores_other_invalid_fields():
assert load.delimiter == ";" and load.has_header is False
+def test_parse_save_settings_ignores_other_invalid_fields():
+ raw = default_settings()
+ raw["plot"]["fontSize"] = "bad"
+ raw["axes"]["x"].update(min=5, max=1)
+ raw["save"].update(filename=" out ", format="SVG", transparent=True)
+ save = parse_save_settings(raw)
+ assert save.filename == "out" and save.format == "svg" and save.transparent is True
+ assert parse_save_settings({}) == parse_settings(default_settings()).save
+ raw["save"]["format"] = "bmp"
+ with pytest.raises(UserError) as info:
+ parse_save_settings(raw)
+ assert info.value.field == "保存形式"
+
+
def test_settings_are_frozen():
s = parse_settings(default_settings())
with pytest.raises(Exception):
@@ -181,33 +197,6 @@ def test_settings_are_frozen():
s.series[0].color = "#000000"
-def test_to_legacy_dict_shape():
- raw = default_settings()
- raw["plot"].update(title="T", skipRows=2, xColumn="__idx__1")
- raw["plot"]["margins"]["left"] = 0.1
- raw["axes"]["x"].update(label="X", min=0, max=5)
- raw["series"][0].update(label="L", secondaryAxis=True, y="__idx__2")
- d = to_legacy_dict(parse_settings(raw))
- expected_keys = {
- "skip_rows", "plot_type", "title", "x_label", "y_label", "y2_label", "x_scale", "y_scale", "y2_scale",
- "x_min", "x_max", "y_min", "y_max", "y2_min", "y2_max", "show_major_grid", "show_minor_grid",
- "legend_location", "font_size", "fig_width", "fig_height", "subplot_margins", "subplot_left",
- "subplot_right", "subplot_bottom", "subplot_top", "x_column_request", "series_settings", "series_settings_error",
- }
- assert set(d) == expected_keys
- assert d["skip_rows"] == 2 and d["plot_type"] == "line" and d["title"] == "T" and d["x_label"] == "X"
- assert d["x_min"] == 0 and d["x_max"] == 5 and d["subplot_left"] == 0.1
- assert d["subplot_margins"] == {"left": 0.1, "right": None, "bottom": None, "top": None}
- assert d["x_column_request"] == "__idx__1" and d["font_size"] == 15 and d["fig_width"] == 8
- assert d["series_settings"] == [
- {
- "y_request": "__idx__2", "x_request": "", "color": "#FF4B00", "line_width": 2.0, "line_style": "solid",
- "marker_size": 0.0, "legend_name": "L", "use_secondary_axis": True,
- }
- ]
- json.dumps(d)
-
-
def test_parse_does_not_mutate_input():
raw = default_settings()
before = copy.deepcopy(raw)