From 16f24484ce8147495945b82633db0f1646d75cd3 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 01:54:45 +0900 Subject: [PATCH 01/12] Add dataprep, codegen and formats modules and record the load source dataprep.plan_plot decides everything that needs the DataFrame, codegen turns settings into a readable matplotlib script, and the loader now keeps the encoding, separator and sheet name for the script's load section. Co-Authored-By: Claude Opus 5.5 --- py/mplgui/codegen.py | 494 ++++++++++++++++++++++++++++++++++++ py/mplgui/dataprep.py | 301 ++++++++++++++++++++++ py/mplgui/fonts.py | 13 +- py/mplgui/formats.py | 41 +++ py/mplgui/loader.py | 29 ++- pyscript.toml | 3 + tests/unit/test_dataprep.py | 235 +++++++++++++++++ tests/unit/test_fonts.py | 28 ++ tests/unit/test_loader.py | 26 ++ 9 files changed, 1159 insertions(+), 11 deletions(-) create mode 100644 py/mplgui/codegen.py create mode 100644 py/mplgui/dataprep.py create mode 100644 py/mplgui/formats.py create mode 100644 tests/unit/test_dataprep.py create mode 100644 tests/unit/test_fonts.py diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py new file mode 100644 index 0000000..0873c7e --- /dev/null +++ b/py/mplgui/codegen.py @@ -0,0 +1,494 @@ +"""設定(と読み込んだファイルの情報・描画計画)から、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 s, p in zip(settings.series, plan.series): + 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 s, p in zip(settings.series, plan.series): + 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 + 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/pyscript.toml b/pyscript.toml index 035789b..23bc539 100644 --- a/pyscript.toml +++ b/pyscript.toml @@ -13,6 +13,9 @@ packages_cache = "passthrough" "./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/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) == ["時間", "電圧", "電流"] From 3b2ff7ea70a4f28f029033ca74f6d495a0d49447 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 01:54:52 +0900 Subject: [PATCH 02/12] Render the GUI through generated code and drop plotting.py Both the preview and the saved image now run the script codegen produces (runner.run_script), the Python API returns the script, output and a figure summary, and edit-mode scripts run against the uploaded file in the work folder. The legacy custom-code namespace and GUI overrides are removed. Co-Authored-By: Claude Opus 5.5 --- js/bridge.js | 22 +- js/main.js | 5 +- js/ui/customCode.js | 12 +- py/main.py | 5 +- py/mplgui/api.py | 158 ++++++++--- py/mplgui/plotting.py | 522 ---------------------------------- py/mplgui/runner.py | 448 ++++++++++++++--------------- py/mplgui/settings.py | 57 ---- pyscript.toml | 1 - tests/unit/test_api.py | 169 ++++++++++- tests/unit/test_codegen.py | 553 ++++++++++++++++++++++++++++++++++++ tests/unit/test_plotting.py | 399 -------------------------- tests/unit/test_runner.py | 369 ++++++++++++++++-------- tests/unit/test_settings.py | 28 -- 14 files changed, 1321 insertions(+), 1427 deletions(-) delete mode 100644 py/mplgui/plotting.py create mode 100644 tests/unit/test_codegen.py delete mode 100644 tests/unit/test_plotting.py diff --git a/js/bridge.js b/js/bridge.js index bea9838..3c3d40b 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -31,6 +31,7 @@ let loadSeq = 0; let loadCount = 0; let loadWarnings = []; let customCodeProvider = () => null; // 有効なカスタムコードの文字列、無効なら null +let generatedCodeListener = () => {}; // GUI 設定から生成したスクリプトを受け取る(Pythonコードタブ用) let readyCallbacks = []; let fontState = "idle"; // idle | loading | ready | failed let fontPercent = null; @@ -60,13 +61,16 @@ export const canAutoRender = () => dataReady; export const setCustomCodeProvider = (fn) => { customCodeProvider = fn; }; +export const onGeneratedCode = (fn) => { + generatedCodeListener = fn; +}; 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.traceback || error.detail || ""); }; const reportException = (err, context) => { const detail = `context: ${context}\n${err && err.stack ? err.stack : String(err)}`; @@ -151,11 +155,16 @@ const executeRender = async (generation) => { 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), - ]); + if (typeof result.code === "string") generatedCodeListener(result.code); + 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 { reportError(result.error); } @@ -319,7 +328,6 @@ export const saveNow = async () => { } }; -export const getDefaultCustomCode = () => (api ? api.defaultCustomCode() : ""); export const onPythonReady = (fn) => { if (api) fn(); else readyCallbacks.push(fn); diff --git a/js/main.js b/js/main.js index ff548c9..1b781e1 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 { bindCustomCodeEvents } from "./ui/customCode.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"; @@ -41,4 +41,3 @@ applyStateToForm(); setColumns([]); syncSaveFormatOptions(); initBridge(); -onPythonReady(() => initDefaultCode(getDefaultCustomCode())); diff --git a/js/ui/customCode.js b/js/ui/customCode.js index 574a9be..39b005c 100644 --- a/js/ui/customCode.js +++ b/js/ui/customCode.js @@ -1,5 +1,5 @@ // Pythonコード(beta)タブ。コード本文と「使用する」は設定オブジェクトには入れず、描画要求のたびに別引数で渡す。 -import { canAutoRender, renderNow, scheduleRender, setCustomCodeProvider } from "../bridge.js"; +import { canAutoRender, onGeneratedCode, renderNow, scheduleRender, setCustomCodeProvider } from "../bridge.js"; const customCodeInput = () => document.getElementById("customPyCode"); const useCustomCodeInput = () => document.getElementById("useCustomCode"); @@ -11,16 +11,18 @@ export const getCustomCode = () => { return use && use.checked && editor ? String(editor.value || "") : null; }; -// Python 起動後に既定コードを入れる(未入力のときだけ) -export const initDefaultCode = (defaultCode) => { +// GUI 設定から生成したスクリプトを表示する。「使用する」が無効のときだけ上書きする(編集中のコードは守る) +export const showGeneratedCode = (code) => { const editor = customCodeInput(); if (!editor) return; - editor.dataset.defaultCode = defaultCode; - if (!String(editor.value || "").trim()) editor.value = defaultCode; + editor.dataset.defaultCode = code; + const use = useCustomCodeInput(); + if (!use || !use.checked) editor.value = code; }; export const bindCustomCodeEvents = () => { setCustomCodeProvider(getCustomCode); + onGeneratedCode(showGeneratedCode); const use = useCustomCodeInput(); const editor = customCodeInput(); const applyBtn = document.getElementById("applyCustomCodeBtn"); diff --git a/py/main.py b/py/main.py index ad3c104..7428978 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。 @@ -97,7 +101,6 @@ def _register_font(data): "save": create_proxy(_save), "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..c2bf76e 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_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,80 @@ 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) - return { - "ok": True, - "image": uri, - "seriesCount": result.plotted_count, - "skipRows": result.skip_rows, - "warnings": result.warnings, - } +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, custom_code: str | None = None) -> dict: +def save_json(settings_json: str, 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} + settings = parse_settings(_parse_json(settings_json)) + _, _, result = _run(settings, code, file_format=settings.save.format, transparent=settings.save.transparent, dpi=SAVE_DPI) + return { + "ok": True, + "filename": build_filename(settings.save.filename, result.ext), + "mime": result.mime, + "dataUri": _data_uri(result), + "output": result.output, + } def register_font(data: bytes) -> str: @@ -133,7 +209,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/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..a21ec2f 100644 --- a/py/mplgui/settings.py +++ b/py/mplgui/settings.py @@ -403,60 +403,3 @@ def parse_settings(raw: dict | None) -> Settings: 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": "", - } diff --git a/pyscript.toml b/pyscript.toml index 23bc539..08787ef 100644 --- a/pyscript.toml +++ b/pyscript.toml @@ -12,7 +12,6 @@ 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" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index ffd7b60..3a94358 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,157 @@ 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_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 +363,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() diff --git a/tests/unit/test_codegen.py b/tests/unit/test_codegen.py new file mode 100644 index 0000000..778b8f6 --- /dev/null +++ b/tests/unit/test_codegen.py @@ -0,0 +1,553 @@ +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]" 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..b8cbb31 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -9,7 +9,6 @@ default_settings, parse_load_settings, parse_settings, - to_legacy_dict, ) pytestmark = pytest.mark.unit @@ -181,33 +180,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) From e7f8e488fdc1a65e45a8df607580769a02bf12c3 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 01:44:12 +0900 Subject: [PATCH 03/12] Show the selected file name inside the file picker Co-Authored-By: Claude Opus 5.5 --- css/style.css | 54 +++++++++++++++++++++++++-------- index.html | 7 +++-- js/ui/fileInfo.js | 10 +++++- tests/e2e/test_data_and_file.py | 12 ++++++++ 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/css/style.css b/css/style.css index 13946ed..38bb54f 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"] { @@ -653,7 +681,7 @@ button.series-color-chip.active { overflow: auto; } .layout { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); height: auto; } .panel { diff --git a/index.html b/index.html index 3693f77..bac438c 100644 --- a/index.html +++ b/index.html @@ -48,8 +48,11 @@

Matplotlib GUI

- -
+
+ + 選択されていません + +
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/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) From bc5b8216f9b543618f180c6b3452ae51ee708708 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:02:17 +0900 Subject: [PATCH 04/12] Tidy generated script comments, hide empty legends, add scriptFilename API Co-Authored-By: Claude Opus 5.5 --- py/main.py | 5 +++++ py/mplgui/api.py | 5 +++++ py/mplgui/codegen.py | 13 ++++++++++--- tests/unit/test_api.py | 11 +++++++++++ tests/unit/test_codegen.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/py/main.py b/py/main.py index 7428978..395f934 100644 --- a/py/main.py +++ b/py/main.py @@ -93,12 +93,17 @@ 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), } diff --git a/py/mplgui/api.py b/py/mplgui/api.py index c2bf76e..6b0c588 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -199,6 +199,11 @@ def save_json(settings_json: str, code: str | None = None) -> dict: } +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: """JS が取得した日本語フォントを登録する。""" try: diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py index 0873c7e..f146eb2 100644 --- a/py/mplgui/codegen.py +++ b/py/mplgui/codegen.py @@ -261,7 +261,9 @@ def _style_args(s: SeriesSettings) -> str: def _emit_line_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None: ax2 = _Axes2State() - for s, p in zip(settings.series, plan.series): + 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 とも数値の行)が無いため、この系列は描かない") @@ -280,7 +282,9 @@ def _emit_line_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None: def _emit_scatter_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None: ax2 = _Axes2State() - for s, p in zip(settings.series, plan.series): + 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 とも数値の行)が無いため、この系列は描かない") @@ -382,7 +386,7 @@ def _emit_axes(b: _Builder, settings: Settings, plan: PlotPlan) -> None: 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 が文字列・日時の列: 目盛りは位置の番号になる(値を目盛りにするには、この行を消す)') + lines.insert(0, 'ax.set_xscale("linear") # X が文字列・日時の列: 目盛は位置の番号になる(値を目盛にするには、この行を消す)') if lines: with b.step(axis_error_message(label), f"{label}軸の範囲"): b.add(*lines) @@ -428,6 +432,9 @@ def _emit_grid_and_legend(b: _Builder, settings: Settings, plan: PlotPlan) -> No 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: diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 3a94358..b3104cf 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -375,3 +375,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 index 778b8f6..7e456b7 100644 --- a/tests/unit/test_codegen.py +++ b/tests/unit/test_codegen.py @@ -551,3 +551,32 @@ 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 From 4ededd136ff798e9d5ea5206c775545736560cda Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:07:05 +0900 Subject: [PATCH 05/12] Rebuild the Python code tab with sync and edit modes Co-Authored-By: Claude Opus 5.5 --- css/style.css | 103 ++++++++++++++++++++++++++--- index.html | 23 ++++--- js/bridge.js | 109 +++++++++++++++++++++++++------ js/code-state.js | 38 +++++++++++ js/main.js | 4 +- js/ui/codeOutput.js | 11 ++++ js/ui/codeTab.js | 156 ++++++++++++++++++++++++++++++++++++++++++++ js/ui/customCode.js | 47 ------------- js/ui/plotView.js | 5 +- js/ui/tabs.js | 2 +- 10 files changed, 406 insertions(+), 92 deletions(-) create mode 100644 js/code-state.js create mode 100644 js/ui/codeOutput.js create mode 100644 js/ui/codeTab.js delete mode 100644 js/ui/customCode.js diff --git a/css/style.css b/css/style.css index 38bb54f..05803e9 100644 --- a/css/style.css +++ b/css/style.css @@ -491,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 { @@ -504,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; diff --git a/index.html b/index.html index bac438c..65d7e56 100644 --- a/index.html +++ b/index.html @@ -287,7 +287,7 @@

Matplotlib GUI

- +
@@ -299,17 +299,22 @@

Matplotlib GUI

diff --git a/js/bridge.js b/js/bridge.js index 3c3d40b..b218d11 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,8 +34,8 @@ let renderDirty = false; let loadSeq = 0; let loadCount = 0; let loadWarnings = []; -let customCodeProvider = () => null; // 有効なカスタムコードの文字列、無効なら null -let generatedCodeListener = () => {}; // GUI 設定から生成したスクリプトを受け取る(Pythonコードタブ用) +let editCodeProvider = () => ""; // 編集モードのコード(Pythonコードタブの textarea の内容) +let editHasRun = false; // 編集モードに入ってから「実行」したことがある(フォント取得後の描き直しの判断に使う) let readyCallbacks = []; let fontState = "idle"; // idle | loading | ready | failed let fontPercent = null; @@ -57,20 +61,26 @@ const setFontState = (state) => { }; export const isPythonReady = () => api !== null; -export const canAutoRender = () => dataReady; -export const setCustomCodeProvider = (fn) => { - customCodeProvider = fn; -}; -export const onGeneratedCode = (fn) => { - generatedCodeListener = 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.traceback || 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)}`; @@ -113,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); @@ -145,17 +155,19 @@ 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); - if (typeof result.code === "string") generatedCodeListener(result.code); + 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", "", [ @@ -166,6 +178,8 @@ const executeRender = async (generation) => { 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) { @@ -189,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) { @@ -204,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; @@ -272,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"); @@ -302,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, "info"); + 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"); @@ -316,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; @@ -355,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 1b781e1..1e3e8d5 100644 --- a/js/main.js +++ b/js/main.js @@ -3,7 +3,7 @@ import { subscribe } from "./state.js"; import { initBridge, saveNow, selectFile } from "./bridge.js"; import { bindColorPanelOutsideClick } from "./ui/colorPicker.js"; -import { bindCustomCodeEvents } 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 が購読して行う) 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 39b005c..0000000 --- a/js/ui/customCode.js +++ /dev/null @@ -1,47 +0,0 @@ -// Pythonコード(beta)タブ。コード本文と「使用する」は設定オブジェクトには入れず、描画要求のたびに別引数で渡す。 -import { canAutoRender, onGeneratedCode, 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; -}; - -// GUI 設定から生成したスクリプトを表示する。「使用する」が無効のときだけ上書きする(編集中のコードは守る) -export const showGeneratedCode = (code) => { - const editor = customCodeInput(); - if (!editor) return; - editor.dataset.defaultCode = code; - const use = useCustomCodeInput(); - if (!use || !use.checked) editor.value = code; -}; - -export const bindCustomCodeEvents = () => { - setCustomCodeProvider(getCustomCode); - onGeneratedCode(showGeneratedCode); - 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/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"], From 40197c0451d62cd0774544fda877fd64f4402b15 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:07:05 +0900 Subject: [PATCH 06/12] Add E2E tests for the Python code tab Co-Authored-By: Claude Opus 5.5 --- tests/e2e/helpers.py | 83 +++++++++++++++ tests/e2e/test_code_tab.py | 202 +++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 tests/e2e/test_code_tab.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/test_code_tab.py b/tests/e2e/test_code_tab.py new file mode 100644 index 0000000..61a4b8f --- /dev/null +++ b/tests/e2e/test_code_tab.py @@ -0,0 +1,202 @@ +"""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" From f0d871ffef13947f9b34a27f4c9a7c101b7dce42 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:07:05 +0900 Subject: [PATCH 07/12] Check generated scripts against CPython Co-Authored-By: Claude Opus 5.5 --- tests/e2e/run_script_summary.py | 12 +++ tests/e2e/test_codegen_consistency.py | 116 ++++++++++++++++++++++++++ tests/fixtures/README.md | 2 + tests/fixtures/categories.csv | 7 ++ tests/fixtures/growth.csv | 31 +++++++ tests/fixtures/make_fixtures.py | 6 ++ 6 files changed, 174 insertions(+) create mode 100644 tests/e2e/run_script_summary.py create mode 100644 tests/e2e/test_codegen_consistency.py create mode 100644 tests/fixtures/categories.csv create mode 100644 tests/fixtures/growth.csv 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_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/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" From 59b66f9d9befaf8b0909c118f6a91af492f5a3ac Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:10:58 +0900 Subject: [PATCH 08/12] Fit the tab bar on narrow screens Co-Authored-By: Claude Opus 5.5 --- css/style.css | 11 +++++++++++ tests/e2e/test_code_tab.py | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/css/style.css b/css/style.css index 05803e9..3844322 100644 --- a/css/style.css +++ b/css/style.css @@ -787,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/tests/e2e/test_code_tab.py b/tests/e2e/test_code_tab.py index 61a4b8f..ad0ff2c 100644 --- a/tests/e2e/test_code_tab.py +++ b/tests/e2e/test_code_tab.py @@ -200,3 +200,11 @@ def test_save_in_edit_mode_uses_edited_code(app): 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 From 88beb5043797c7f6de7f24199ae2fc9481844b67 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:12:12 +0900 Subject: [PATCH 09/12] Use the warning kind when Python is not ready to run code Co-Authored-By: Claude Opus 5.5 --- js/bridge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/bridge.js b/js/bridge.js index b218d11..70ee820 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -355,7 +355,7 @@ export const leaveEditMode = () => { // 編集モードのコードを実行する(同期モードでは GUI の設定で再描画する) export const runCode = () => { if (!api) { - setStatus(PYTHON_NOT_READY_MESSAGE, "info"); + setStatus(PYTHON_NOT_READY_MESSAGE, "warning"); return; } if (isEditMode()) editHasRun = true; From 3b12b420c2ea0705dfeb440ee7f1c04bacb6aa57 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:18:07 +0900 Subject: [PATCH 10/12] Update README and AGENTS.md for Phase 2 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 101 ++++++++++++++++++++++++++++++++++++++++++------------ README.md | 19 +++++++++- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45186fd..ebba3a7 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` は設定を解釈したうえで、保存形式と背景透過だけを使う。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 など)とフォントの取得だけです。通信先は次のとおりです。 From 6c1b95177b9de16a8d2fb07125f7ee5eb85a08b5 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:19:03 +0900 Subject: [PATCH 11/12] Record Phase 2 performance Add an edit-mode run row to the benchmark and the Phase 2 results. Co-Authored-By: Claude Opus 5.5 --- docs/PERFORMANCE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ tests/perf/bench.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 4 deletions(-) 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/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(): From 87ad68f1c9ab1fc2473565f51eeef4c36ddb91d5 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 02:20:27 +0900 Subject: [PATCH 12/12] Validate only the save settings when saving edited code Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- py/mplgui/api.py | 11 +++++++---- py/mplgui/settings.py | 16 +++++++++++++--- tests/unit/test_api.py | 12 ++++++++++++ tests/unit/test_settings.py | 17 +++++++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebba3a7..800add7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 `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` は設定を解釈したうえで、保存形式と背景透過だけを使う。GUI の設定をコードの結果に上書き適用することはしない(A8)。 +- `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?, traceback?, line?}, output?}`。 diff --git a/py/mplgui/api.py b/py/mplgui/api.py index 6b0c588..c19ed22 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -17,7 +17,7 @@ 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_settings +from .settings import Settings, parse_load_settings, parse_save_settings, parse_settings PREVIEW_DPI = 100 INTERNAL_ERROR_MESSAGE = "内部エラーが発生しました。もう一度操作してください。" @@ -188,11 +188,14 @@ def render_json(settings_json: str, code: str | None = None) -> dict: @_guard("save") def save_json(settings_json: str, code: str | None = None) -> dict: """保存設定の形式で図を書き出し、data URI とファイル名を返す(ダウンロードは JS が行う)。""" - settings = parse_settings(_parse_json(settings_json)) - _, _, result = _run(settings, code, file_format=settings.save.format, transparent=settings.save.transparent, dpi=SAVE_DPI) + 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, - "filename": build_filename(settings.save.filename, result.ext), + "filename": build_filename(save.filename, result.ext), "mime": result.mime, "dataUri": _data_uri(result), "output": result.output, diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py index a21ec2f..685b822 100644 --- a/py/mplgui/settings.py +++ b/py/mplgui/settings.py @@ -396,10 +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) + + +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/tests/unit/test_api.py b/tests/unit/test_api.py index b3104cf..b81dd1c 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -200,6 +200,18 @@ def test_save_with_code_uses_save_settings(workdir): 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 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index b8cbb31..d91ba0e 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -8,6 +8,7 @@ SCHEMA_VERSION, default_settings, parse_load_settings, + parse_save_settings, parse_settings, ) @@ -149,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(): @@ -172,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):