From 1bbbd15838b0af4c024f67f740d2db2224bc52a1 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 18:13:03 +0900 Subject: [PATCH 01/18] Narrow the escape-decoding warning filter and record Phase 3 manual checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _decode_escapes ignored every warning while decoding delimiter escapes. Only the DeprecationWarning for invalid escape sequences is actually raised there, so ignore just that one and document it in AGENTS.md §8. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +++- py/mplgui/loader.py | 3 ++- tests/unit/test_loader.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81fa763..3194547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,11 +172,12 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 ## 8. 警告フィルタ -無視する警告は、次の3つだけ。それ以外の警告は無視しない(A10)。 +無視する警告は、次の4つだけ。それ以外の警告は無視しない(A10)。 - 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()` の範囲で無視する(スクリプトの実行が終われば元に戻る。グローバルには設定しない)。 +- 区切り文字のエスケープ表記(`loader._decode_escapes`)で、`\d` のような未定義のエスケープを `unicode_escape` で解釈するときに出る `DeprecationWarning`(`... is an invalid escape sequence`)。文字はそのまま残る。`warnings.catch_warnings()` の中で、この種類とメッセージだけを無視する。 ## 9. parentNode エラー @@ -242,6 +243,7 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー ### 13.2 Phase 3(2026-10-08) +- 手動確認(2026-10-08、オーナーが確認。いずれも問題なし): Illustrator で PDF の文字を編集できること。Safari と Firefox の実機で、クリップボードへのコピーが動くこと。 - 保存 DPI の既定値は 300(U1)。選択肢は 72 / 150 / 300 / 600 と任意の値。プレビューは今までどおり dpi 100 で描く。 - 図サイズの既定の単位は inch(8×6 を維持。U2)。cm と mm も選べる。単位を切り替えたら入力欄の数値を換算する(図の大きさは変えない。例: 8 inch → 20.32 cm)。設定には「選んだ単位の値」と単位を持つ(インチに直して持たない)。 - 欧文フォントは Arimo(Arial 互換)と Tinos(Times 互換。U3)。ライセンスはどちらも SIL OFL 1.1(要件定義書の当初の「Apache 2.0」は誤り)。選んだときだけ、バージョン固定の jsDelivr から取得する。日本語の文字は Noto Sans JP で描く(matplotlib の文字単位のフォールバック)。Tinos のときは数式(mathtext)を STIX にする。 diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py index 2590062..67d14a8 100644 --- a/py/mplgui/loader.py +++ b/py/mplgui/loader.py @@ -45,7 +45,8 @@ def repl(match: re.Match) -> str: if not token.isascii(): return token with warnings.catch_warnings(): - warnings.simplefilter("ignore") + # 「\d」のような未定義のエスケープは、文字のまま残る("\\d")が DeprecationWarning が出る。これだけを無視する + warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*invalid escape sequence") return token.encode("ascii").decode("unicode_escape") return _ESCAPE.sub(repl, text) diff --git a/tests/unit/test_loader.py b/tests/unit/test_loader.py index 643aabb..69dbc90 100644 --- a/tests/unit/test_loader.py +++ b/tests/unit/test_loader.py @@ -205,6 +205,16 @@ def test_decode_delimiter_invalid_escape_still_japanese_error(): assert info.value.field == "区切り文字" and not info.value.message.isascii() +def test_decode_delimiter_unknown_escape_is_kept_without_warning(): + # 「\\d」のような未定義のエスケープは文字のまま残す。出る DeprecationWarning は loader の中で無視する(漏らさない) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert decode_delimiter("\\d", ".txt") == "\\d" + assert decode_delimiter("a\\q\\t", ".txt") == "a\\q\t" + + def test_ideographic_comma_delimited_file_loads(): data = "時間、電圧、電流\n1、2、3\n4、5、6\n".encode("utf-8") loaded = load_file(data, "comma.txt", "、", True) From 75ea175fd57c5a18c036ba3d16eb9aeed7cf2e36 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 18:21:38 +0900 Subject: [PATCH 02/18] Add settings schema v3 for data import options Co-Authored-By: Claude Opus 5.5 --- js/defaults.js | 16 +++- py/mplgui/settings.py | 139 +++++++++++++++++++++++++-- tests/unit/test_js_modules.py | 3 + tests/unit/test_settings.py | 170 ++++++++++++++++++++++++++++++++-- 4 files changed, 311 insertions(+), 17 deletions(-) diff --git a/js/defaults.js b/js/defaults.js index a7a0755..6e83898 100644 --- a/js/defaults.js +++ b/js/defaults.js @@ -1,7 +1,7 @@ // 設定オブジェクトの既定値。純粋な ES module(DOM に触れない)。 // py/mplgui/settings.py の default_settings() と同一でなければならない(tests/unit/test_defaults_parity.py が比較する)。 -export const SCHEMA_VERSION = 2; +export const SCHEMA_VERSION = 3; // UDカラーセット(系列の既定色の候補。この順で未使用の色を割り当てる) export const PALETTE = [ @@ -28,11 +28,21 @@ export const DEFAULT_SERIES = { markerSize: null, label: "", secondaryAxis: false, + source: "", }; export const DEFAULT_SETTINGS = { version: SCHEMA_VERSION, - load: { delimiter: "", hasHeader: true }, + load: { + delimiter: "", + hasHeader: true, + skipLines: 0, + thousands: "", + decimal: ".", + comment: "", + parseDates: true, + files: [], + }, plot: { type: "line", skipRows: 0, @@ -47,7 +57,7 @@ export const DEFAULT_SETTINGS = { margins: { left: null, right: null, bottom: null, top: null }, }, axes: { - x: { label: "", scale: "linear", min: null, max: null }, + x: { label: "", scale: "linear", min: null, max: null, dateFormat: "" }, y: { label: "", scale: "linear", min: null, max: null }, y2: { label: "", scale: "linear", min: null, max: null }, }, diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py index 0edda33..190fc5e 100644 --- a/py/mplgui/settings.py +++ b/py/mplgui/settings.py @@ -1,4 +1,4 @@ -"""設定オブジェクト(JSON, camelCase, version=2)の既定値・検証・型付きデータクラス。 +"""設定オブジェクト(JSON, camelCase, version=3)の既定値・検証・型付きデータクラス。 JS 側の js/defaults.js と default_settings() は同一でなければならない(単体テストで比較する)。 このモジュールは js / pyodide / pyscript に依存しない。 @@ -7,6 +7,8 @@ import copy import math +import re +from datetime import datetime from dataclasses import dataclass, field from typing import Any @@ -14,7 +16,7 @@ from .errors import UserError -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 PLOT_TYPES = ("line", "scatter", "bar") LINE_STYLES = ("solid", "dashed", "dashdot", "dotted") @@ -43,6 +45,12 @@ "none", ) DEFAULT_SCATTER_MARKER_SIZE = 24.0 +THOUSANDS_CHOICES = ("", ",", ".", " ", "'") +DECIMAL_CHOICES = (".", ",") +MAX_SKIP_LINES = 1_000_000 +MAX_FILES = 10 +MAX_DATE_FORMAT_LENGTH = 64 +_FILE_ID_RE = re.compile(r"^d[0-9]{1,4}$") _DEFAULT_SERIES = { "id": "s1", @@ -54,6 +62,7 @@ "markerSize": None, "label": "", "secondaryAxis": False, + "source": "", } @@ -61,7 +70,16 @@ def default_settings() -> dict: """既定の設定(JSON 互換の dict)。呼ぶたびに新しい dict を返す。""" return { "version": SCHEMA_VERSION, - "load": {"delimiter": "", "hasHeader": True}, + "load": { + "delimiter": "", + "hasHeader": True, + "skipLines": 0, + "thousands": "", + "decimal": ".", + "comment": "", + "parseDates": True, + "files": [], + }, "plot": { "type": "line", "skipRows": 0, @@ -76,7 +94,7 @@ def default_settings() -> dict: "margins": {"left": None, "right": None, "bottom": None, "top": None}, }, "axes": { - "x": {"label": "", "scale": "linear", "min": None, "max": None}, + "x": {"label": "", "scale": "linear", "min": None, "max": None, "dateFormat": ""}, "y": {"label": "", "scale": "linear", "min": None, "max": None}, "y2": {"label": "", "scale": "linear", "min": None, "max": None}, }, @@ -88,10 +106,23 @@ def default_settings() -> dict: # ---------------------------------------------------------------- dataclasses +@dataclass(frozen=True) +class FileEntry: + id: str + name: str = "" + sheet: str = "" # "" = 先頭シート + + @dataclass(frozen=True) class LoadSettings: delimiter: str = "" has_header: bool = True + skip_lines: int = 0 + thousands: str = "" + decimal: str = "." + comment: str = "" + parse_dates: bool = True + files: tuple[FileEntry, ...] = () @dataclass(frozen=True) @@ -100,6 +131,7 @@ class AxisSettings: scale: str = "linear" min: float | None = None max: float | None = None + date_format: str = "" # X 軸だけ。"" = 自動 @dataclass(frozen=True) @@ -162,6 +194,7 @@ class SeriesSettings: marker_size: float | None = None # None = プロット種別ごとの自動値 label: str = "" secondary_axis: bool = False + source: str = "" # データ元のファイル id("" = load.files の先頭) def effective_marker_size(self, plot_type: str) -> float: """自動(None)は line=0 / それ以外=24。scatter で 0 以下なら 24 に補正する。""" @@ -294,19 +327,29 @@ def migrate_settings(raw: dict) -> dict: バージョンが無いものは現在のバージョンとして扱う。対応していないバージョンは UserError。 - 1 → 2: 保存 DPI を追加する。v1 は常に 120 dpi で保存していたので、同じ出力になるよう 120 にする。 図の単位・欧文フォント・カラーパレット・SVG の文字は、既定値で補われる。 + - 2 → 3: データ取り込みの項目(読み飛ばす行数・桁区切り・小数点・コメント記号・複数ファイル・ + 系列のデータ元・X 軸の日時の書式)を追加する。日時の自動認識(load.parseDates)は、v2 までの + 出力を保つため false にする(v3 で新しく作る設定の既定値は true)。他の項目は既定値で補われる。 """ version = raw.get("version", SCHEMA_VERSION) - if isinstance(version, bool) or version not in (1, SCHEMA_VERSION): + if isinstance(version, bool) or version not in (1, 2, SCHEMA_VERSION): raise UserError(f"設定のバージョン({version})に対応していません。ページを再読み込みしてください。", field="version") out = copy.deepcopy(raw) if version == 1: - out["version"] = SCHEMA_VERSION save = out.get("save") if not isinstance(save, dict): save = {} if "dpi" not in save: save["dpi"] = 120 out["save"] = save + if version in (1, 2): + out["version"] = SCHEMA_VERSION + load = out.get("load") + if not isinstance(load, dict): + load = {} + if "parseDates" not in load: + load["parseDates"] = False + out["load"] = load return out @@ -317,18 +360,95 @@ def parse_load_settings(raw: dict | None) -> LoadSettings: """読み込み設定だけを検証する(描画設定の不備でファイル読込を止めないため)。""" raw = migrate_settings(raw if isinstance(raw, dict) else {}) load = _merge(default_settings()["load"], raw.get("load")) + skip_lines = _integer(load["skipLines"], "ヘッダより前に読み飛ばす行数", default=0, minimum=0) + if skip_lines > MAX_SKIP_LINES: + raise UserError(f"「ヘッダより前に読み飛ばす行数」は{MAX_SKIP_LINES}以下の整数で入力してください。", field="ヘッダより前に読み飛ばす行数") + thousands = _choice(load["thousands"], "桁区切り", THOUSANDS_CHOICES) + decimal = _choice(load["decimal"], "小数点", DECIMAL_CHOICES) + if thousands and thousands == decimal: + raise UserError("「桁区切り」と「小数点」に同じ記号は使えません。", field="桁区切り") + comment = _parse_comment(load["comment"], thousands, decimal) return LoadSettings( delimiter=_string(load["delimiter"], "区切り文字"), has_header=_boolean(load["hasHeader"], "先頭行をヘッダとして扱う"), + skip_lines=skip_lines, + thousands=thousands, + decimal=decimal, + comment=comment, + parse_dates=_boolean(load["parseDates"], "日時の列を自動で認識する"), + files=_parse_files(load["files"]), ) +def _parse_comment(value: Any, thousands: str, decimal: str) -> str: + label = "コメント記号" + if value is None: + return "" + if not isinstance(value, str): + raise UserError(f"「{label}」は文字列で指定してください。", field=label) + if value == "": + return "" + if len(value) != 1: + raise UserError(f"「{label}」は1文字で指定してください(例: #)。", field=label) + if value == '"' or value.isspace(): + raise UserError(f"「{label}」に \" や空白は使えません。", field=label) + if value in (thousands, decimal): + raise UserError(f"「{label}」に、桁区切り・小数点と同じ記号は使えません。", field=label) + return value + + +def _parse_files(raw_files: Any) -> tuple[FileEntry, ...]: + label = "読み込むファイル" + if not isinstance(raw_files, list): + raise UserError(f"「{label}」の値が不正です。", field=label) + if len(raw_files) > MAX_FILES: + raise UserError(f"読み込めるファイルは{MAX_FILES}個までです。", field=label) + out = [] + seen: set[str] = set() + for item in raw_files: + if not isinstance(item, dict): + raise UserError(f"「{label}」の値が不正です。", field=label) + fid = item.get("id") + if not isinstance(fid, str) or not _FILE_ID_RE.match(fid): + raise UserError(f"「{label}」のIDが不正です。", field=label) + if fid in seen: + raise UserError(f"「{label}」のIDが重複しています。", field=label) + seen.add(fid) + out.append( + FileEntry( + id=fid, + name=_string(item.get("name", ""), "ファイル名", owner="読み込むファイルの"), + sheet=_string(item.get("sheet", ""), "シート", owner="読み込むファイルの"), + ) + ) + return tuple(out) + + def _parse_axis(raw: dict, key: str, name: str) -> AxisSettings: axis = raw[key] lo = _number(axis["min"], f"{name}軸の最小値", default=None) hi = _number(axis["max"], f"{name}軸の最大値", default=None) scale = _choice(axis["scale"], f"{name}軸スケール", SCALES) - return AxisSettings(label=_string(axis["label"], f"{name}軸ラベル").strip(), scale=scale, min=lo, max=hi) + date_format = _date_format(axis["dateFormat"]) if key == "x" else "" + return AxisSettings(label=_string(axis["label"], f"{name}軸ラベル").strip(), scale=scale, min=lo, max=hi, date_format=date_format) + + +def _date_format(value: Any) -> str: + label = "X軸の日時の書式" + if value is None: + return "" + bad = UserError(f"「{label}」が正しくありません。%Y/%m/%d のように、% で始まる指定を含めてください。", field=label) + if not isinstance(value, str) or len(value) > MAX_DATE_FORMAT_LENGTH: + raise bad + if value == "": + return "" + if "%" not in value: + raise bad + try: + datetime(2024, 1, 31, 13, 45, 30).strftime(value) + except (ValueError, TypeError, OverflowError): + raise bad from None + return value def _check_axis_range(axis: AxisSettings, name: str) -> None: @@ -367,6 +487,7 @@ def _parse_series(raw_series: Any) -> tuple[SeriesSettings, ...]: marker_size=marker, label=_string(s["label"], "凡例名", owner=owner).strip(), secondary_axis=_boolean(s["secondaryAxis"], "第2軸を使用", owner=owner), + source=_string(s["source"], "データ元", owner=owner), ) ) return tuple(out) @@ -433,6 +554,10 @@ def parse_settings(raw: dict | None) -> Settings: y2=_parse_axis(ax, "y2", "第2Y"), ) series = _parse_series(raw.get("series", d["series"])) + file_ids = {f.id for f in load.files} + for i, s in enumerate(series): + if s.source and s.source not in file_ids: + raise UserError(f"系列{i + 1}のデータ元が見つかりません。データ元を選び直してください。", field=f"系列{i + 1}のデータ元") _check_axis_range(axes.x, "X") _check_axis_range(axes.y, "Y") if any(s.secondary_axis for s in series): diff --git a/tests/unit/test_js_modules.py b/tests/unit/test_js_modules.py index 85e2185..d725dbb 100644 --- a/tests/unit/test_js_modules.py +++ b/tests/unit/test_js_modules.py @@ -45,6 +45,8 @@ def test_new_series_color_is_first_unused_palette_color(tmp_path): out.second = nextSeriesColor(); // #03AF7A(#FF4B00 #005AFF は使用済み) while (getSettings().series.length < PALETTE.length) addSeries(); out.allUsed = new Set(getSettings().series.map((s) => s.color)).size; + out.newSource = getSettings().series[getSettings().series.length - 1].source; // v3: データ元は空 + out.version = getSettings().version; out.cycled = nextSeriesColor(); // すべて使用済み → 系列数で循環 out.cycledExpected = PALETTE[getSettings().series.length % PALETTE.length]; console.log(JSON.stringify(out)); @@ -54,6 +56,7 @@ def test_new_series_color_is_first_unused_palette_color(tmp_path): assert res["first"] == "#FF4B00" assert res["second"] == "#03AF7A" assert res["allUsed"] == 11 + assert res["newSource"] == "" and res["version"] == 3 assert res["cycled"] == res["cycledExpected"] diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index b03649e..6e9cb30 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -18,7 +18,7 @@ def test_defaults_are_complete_and_parse(): d = default_settings() - assert d["version"] == SCHEMA_VERSION == 2 + assert d["version"] == SCHEMA_VERSION == 3 assert set(d) == {"version", "load", "plot", "axes", "series", "save"} assert set(d["axes"]) == {"x", "y", "y2"} s = parse_settings(d) @@ -36,7 +36,7 @@ def test_default_settings_returns_fresh_objects(): def test_missing_keys_are_filled_from_defaults(): - s = parse_settings({"version": 2, "plot": {"title": " T "}, "series": [{"y": "__idx__2"}]}) + s = parse_settings({"version": 3, "plot": {"title": " T "}, "series": [{"y": "__idx__2"}]}) assert s.plot.title == "T" # 前後の空白は取り除く assert s.plot.font_size == 15 and s.plot.legend_location == "best" assert s.series[0].y == "__idx__2" and s.series[0].line_width == 2 and s.series[0].id == "s1" @@ -143,7 +143,7 @@ def test_unknown_version_rejected(): raw = default_settings() del raw["version"] parse_settings(raw) # version 欠落は現行版として扱う - for bad in (3, 0, "1", None, True, "2"): + for bad in (4, 0, "1", None, True, "2", "3"): raw = default_settings() raw["version"] = bad with pytest.raises(UserError) as info: @@ -305,6 +305,15 @@ def _v1(): for key in ("latinFont", "palette"): del raw["plot"][key] raw["save"] = {"filename": "x", "format": "jpg", "transparent": False} + return _strip_v3(raw) + + +def _strip_v3(raw): + """v3 で足した項目を取り除く(v1 / v2 の設定の形にする)。""" + raw["load"] = {"delimiter": "", "hasHeader": True} + del raw["axes"]["x"]["dateFormat"] + for item in raw["series"]: + del item["source"] return raw @@ -313,10 +322,10 @@ def test_migrate_v1_to_v2(): before = copy.deepcopy(raw) out = migrate_settings(raw) assert raw == before # 引数は変更しない - assert out["version"] == 2 and out["save"]["dpi"] == 120 # v1 は 120 dpi で保存していた + assert out["version"] == 3 and out["save"]["dpi"] == 120 and out["load"]["parseDates"] is False # v1 は 120 dpi で保存していた assert out["save"]["filename"] == "x" s = parse_settings(raw) - assert s.version == 2 and s.save.dpi == 120 and s.save.format == "jpg" + assert s.version == 3 and s.save.dpi == 120 and s.save.format == "jpg" and s.load.parse_dates is False assert s.plot.figure.unit == "in" and s.plot.figure.width == 7 assert s.plot.latin_font == "default" and s.plot.palette == "ud" and s.save.svg_text == "path" @@ -335,12 +344,159 @@ def test_migrate_v1_through_save_and_load_parsers(): assert parse_load_settings(_v1()).has_header is True -def test_migrate_v2_passthrough_and_unknown_versions(): +def test_migrate_v3_passthrough_and_unknown_versions(): raw = default_settings() out = migrate_settings(raw) assert out == raw and out is not raw assert migrate_settings({}) == {} # バージョンの欠落は現行版として扱う(何も足さない) - for bad in (3, 0, "2", True, None): + for bad in (4, 0, "2", "3", True, None): with pytest.raises(UserError) as info: migrate_settings({"version": bad}) assert "バージョン" in info.value.message + + +# ---------------------------------------------------------------- Phase 4: schema version 3 + + +def _load(**kw): + raw = default_settings() + raw["load"].update(kw) + return raw + + +def _err(raw, parser=parse_settings): + with pytest.raises(UserError) as info: + parser(raw) + return info.value + + +def test_v3_defaults(): + d = default_settings() + assert d["load"] == { + "delimiter": "", "hasHeader": True, "skipLines": 0, "thousands": "", "decimal": ".", + "comment": "", "parseDates": True, "files": [], + } + assert d["axes"]["x"]["dateFormat"] == "" and "dateFormat" not in d["axes"]["y"] and "dateFormat" not in d["axes"]["y2"] + assert d["series"][0]["source"] == "" + s = parse_settings(d) + assert s.load.skip_lines == 0 and s.load.thousands == "" and s.load.decimal == "." and s.load.comment == "" + assert s.load.parse_dates is True and s.load.files == () and s.axes.x.date_format == "" and s.series[0].source == "" + assert s.axes.y.date_format == "" and s.axes.y2.date_format == "" + + +def test_v3_valid_load_values(): + raw = _load(skipLines="3", thousands="'", decimal=",", comment="#", parseDates=False, + files=[{"id": "d1", "name": "a.csv", "sheet": ""}, {"id": "d12", "name": "b.xlsx", "sheet": "S2"}]) + s = parse_load_settings(raw) + assert s.skip_lines == 3 and s.thousands == "'" and s.decimal == "," and s.comment == "#" and s.parse_dates is False + assert [(f.id, f.name, f.sheet) for f in s.files] == [("d1", "a.csv", ""), ("d12", "b.xlsx", "S2")] + for t in ("", ",", ".", " ", "'"): + assert parse_load_settings(_load(thousands=t, decimal="." if t != "." else ",")).thousands == t + assert parse_load_settings(_load(skipLines=1_000_000)).skip_lines == 1_000_000 + assert parse_load_settings(_load(skipLines="")).skip_lines == 0 + + +@pytest.mark.parametrize("parser", [parse_settings, parse_load_settings]) +def test_v3_invalid_load_values(parser): + label = "ヘッダより前に読み飛ばす行数" + for bad in (-1, 1.5, "x"): + e = _err(_load(skipLines=bad), parser) + assert e.field == label and e.message == f"「{label}」は0以上の整数で入力してください。" + assert _err(_load(skipLines=1_000_001), parser).field == label + assert _err(_load(thousands="x"), parser).field == "桁区切り" + assert _err(_load(decimal=";"), parser).field == "小数点" + e = _err(_load(thousands=",", decimal=","), parser) + assert e.field == "桁区切り" and e.message == "「桁区切り」と「小数点」に同じ記号は使えません。" + assert _err(_load(comment="##"), parser).message == "「コメント記号」は1文字で指定してください(例: #)。" + for bad in ('"', " ", "\t"): + e = _err(_load(comment=bad), parser) + assert e.field == "コメント記号" and e.message == "「コメント記号」に \" や空白は使えません。" + e = _err(_load(comment=",", thousands=","), parser) + assert e.message == "「コメント記号」に、桁区切り・小数点と同じ記号は使えません。" and e.field == "コメント記号" + assert _err(_load(comment="."), parser).field == "コメント記号" # 小数点の既定値と同じ + assert _err(_load(comment=5), parser).field == "コメント記号" + assert _err(_load(parseDates="yes"), parser).field == "日時の列を自動で認識する" + + +@pytest.mark.parametrize("parser", [parse_settings, parse_load_settings]) +def test_v3_invalid_files(parser): + assert _err(_load(files="d1"), parser).field == "読み込むファイル" + assert _err(_load(files=["d1"]), parser).field == "読み込むファイル" + for bad_id in ("x1", "d", "d12345", "D1", 1, None): + assert _err(_load(files=[{"id": bad_id, "name": "a", "sheet": ""}]), parser).field == "読み込むファイル" + dup = [{"id": "d1", "name": "a", "sheet": ""}, {"id": "d1", "name": "b", "sheet": ""}] + assert _err(_load(files=dup), parser).field == "読み込むファイル" + ten = [{"id": f"d{i}", "name": "a", "sheet": ""} for i in range(1, 11)] + assert len(parse_load_settings(_load(files=ten)).files) == 10 + eleven = ten + [{"id": "d11", "name": "a", "sheet": ""}] + assert _err(_load(files=eleven), parser).message == "読み込めるファイルは10個までです。" + assert _err(_load(files=[{"id": "d1", "name": 1, "sheet": ""}]), parser).field == "ファイル名" + + +def test_v3_series_source(): + raw = _load(files=[{"id": "d1", "name": "a.csv", "sheet": ""}, {"id": "d2", "name": "b.csv", "sheet": ""}]) + raw["series"] = [{"y": "__idx__1", "source": "d2"}, {"y": "__idx__1"}] + s = parse_settings(raw) + assert [x.source for x in s.series] == ["d2", ""] + raw["series"].append({"y": "__idx__1", "source": "d9"}) + e = _err(raw) + assert e.message == "系列3のデータ元が見つかりません。データ元を選び直してください。" and e.field == "系列3のデータ元" + raw = default_settings() + raw["series"][0]["source"] = "d1" # files が空 + assert _err(raw).field == "系列1のデータ元" + raw["series"][0]["source"] = 3 + assert _err(raw).field == "データ元" + + +def test_v3_date_format(): + for ok in ("", "%Y/%m/%d", "%m月%d日", "%H:%M", "%Y-%m-%d %H:%M:%S"): + raw = default_settings() + raw["axes"]["x"]["dateFormat"] = ok + assert parse_settings(raw).axes.x.date_format == ok + msg = "「X軸の日時の書式」が正しくありません。%Y/%m/%d のように、% で始まる指定を含めてください。" + for bad in ("Y/m/d", "%Y" + "x" * 64, 5, ["%Y"]): + raw = default_settings() + raw["axes"]["x"]["dateFormat"] = bad + e = _err(raw) + assert e.message == msg and e.field == "X軸の日時の書式", bad + raw = default_settings() + raw["axes"]["y"]["dateFormat"] = "bad" # Y 軸には書式の項目が無い(無視される) + assert parse_settings(raw).axes.y.date_format == "" + + +def test_migrate_v2_to_v3(): + raw = _strip_v3(default_settings()) + raw["version"] = 2 + before = copy.deepcopy(raw) + out = migrate_settings(raw) + assert raw == before # 引数は変更しない + assert out["version"] == 3 and out["load"]["parseDates"] is False + s = parse_settings(raw) + assert s.version == 3 and s.load.parse_dates is False and s.load.skip_lines == 0 and s.load.decimal == "." + assert s.load.files == () and s.axes.x.date_format == "" and s.series[0].source == "" + assert s.save.dpi == 300 # v2 の dpi はそのまま + assert parse_load_settings(raw).parse_dates is False + assert parse_save_settings(raw).dpi == 300 + + +def test_migrate_v2_keeps_an_existing_parse_dates(): + raw = _strip_v3(default_settings()) + raw["version"] = 2 + raw["load"]["parseDates"] = True + assert migrate_settings(raw)["load"]["parseDates"] is True + raw = {"version": 2} + assert migrate_settings(raw) == {"version": 3, "load": {"parseDates": False}} + assert parse_settings({"version": 2}).load.parse_dates is False + + +def test_v1_migrates_through_to_v3(): + out = migrate_settings(_v1()) + assert out["version"] == 3 and out["save"]["dpi"] == 120 and out["load"]["parseDates"] is False + + +def test_v3_without_parse_dates_defaults_to_true(): + assert parse_settings({"version": 3}).load.parse_dates is True + assert parse_settings({}).load.parse_dates is True + raw = default_settings() + del raw["load"]["parseDates"] + assert migrate_settings(raw) == raw and parse_load_settings(raw).parse_dates is True From e4f1dc5a699592220503fe7f715d64a6a3581d60 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 18:27:03 +0900 Subject: [PATCH 03/18] Add sheet, preamble, thousands/decimal and comment options to the loader and generated read section Co-Authored-By: Claude Opus 5.5 --- py/main.py | 8 +- py/mplgui/api.py | 54 +++- py/mplgui/codegen.py | 52 ++-- py/mplgui/dataprep.py | 31 ++- py/mplgui/loader.py | 178 +++++++++++-- py/mplgui/settings.py | 4 +- tests/fixtures/README.md | 5 + tests/fixtures/comments.csv | 13 + tests/fixtures/european.csv | 14 ++ tests/fixtures/make_fixtures.py | 43 ++++ tests/fixtures/preamble.csv | 34 +++ tests/fixtures/preamble.xlsx | Bin 0 -> 5907 bytes tests/fixtures/thousands.csv | 13 + tests/unit/test_api.py | 2 +- tests/unit/test_codegen.py | 2 +- tests/unit/test_load_options.py | 428 ++++++++++++++++++++++++++++++++ tests/unit/test_loader.py | 3 +- 17 files changed, 821 insertions(+), 63 deletions(-) create mode 100644 tests/fixtures/comments.csv create mode 100644 tests/fixtures/european.csv create mode 100644 tests/fixtures/preamble.csv create mode 100644 tests/fixtures/preamble.xlsx create mode 100644 tests/fixtures/thousands.csv create mode 100644 tests/unit/test_load_options.py diff --git a/py/main.py b/py/main.py index 82a66c6..292179b 100644 --- a/py/main.py +++ b/py/main.py @@ -72,8 +72,12 @@ async def _ensure_excel(): return json.dumps({"ok": False, "error": err.to_dict()}, ensure_ascii=False) -def _load_file(name, data, load_settings_json): - return api.load_file_json(str(name), data.to_py(), str(load_settings_json)) +def _load_file(name, data, load_settings_json, source_id=None, pasted=None): + # source_id / pasted は JS が渡さない(null / undefined)ときは既定値 + return api.load_file_json( + str(name), data.to_py(), str(load_settings_json), + source_id if isinstance(source_id, str) else "d1", pasted is True, + ) def _code(value): diff --git a/py/mplgui/api.py b/py/mplgui/api.py index d047712..786ab54 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -6,12 +6,13 @@ from __future__ import annotations import base64 +import hashlib import json import struct import traceback from pathlib import Path -from . import fonts +from . import fonts, loader from .codegen import GENERIC_ERROR_MESSAGE, GeneratedScript, generate_script from .dataprep import plan_plot from .errors import UserError @@ -37,12 +38,34 @@ def __init__(self): self.source: SourceInfo | None = None self.workdir: Path | None = None # 設定すると、アップロードしたファイルを元の名前でここに置く self._written: Path | None = None + self.excel = None # 開いた pd.ExcelFile(同じバイト列の間だけ保持して、シートの切り替えを速くする) + self.excel_key: str | None = None - def forget(self) -> None: + def forget(self, keep_excel: bool = False) -> None: self.df = None self.filename = None self.source = None self._remove_written() + if not keep_excel: + self.drop_excel() + + def drop_excel(self) -> None: + if self.excel is not None: + try: + self.excel.close() + except Exception: # noqa: BLE001 - 閉じられなくても続行する + pass + self.excel = None + self.excel_key = None + + def excel_for(self, raw: bytes): + """raw(xlsx のバイト列)の ExcelFile。同じバイト列なら開き直さない。""" + key = hashlib.sha256(raw).hexdigest() + if self.excel is None or self.excel_key != key: + self.drop_excel() + self.excel = loader.open_excel(raw) + self.excel_key = key + return self.excel def _remove_written(self) -> None: if self._written is not None: @@ -109,22 +132,37 @@ def _parse_json(text: str) -> dict: @_guard("load_file") -def load_file_json(name: str, data: bytes, load_settings_json: str) -> dict: - """ファイルを読み込み、列の選択肢とプレビューを返す。失敗時は読込済みデータを破棄する。""" - SESSION.forget() +def load_file_json(name: str, data: bytes, load_settings_json: str, source_id: str = "d1", pasted: bool = False) -> dict: + """ファイルを読み込み、列の選択肢とプレビューを返す。失敗時は読込済みデータを破棄する。 + + xlsx のシートは、読み込み設定の load.files のうち source_id の項目から決める(無ければ先頭のシート)。""" + SESSION.forget(keep_excel=True) load = parse_load_settings(_parse_json(load_settings_json)) raw = bytes(data) - loaded = load_file(raw, name, load.delimiter, load.has_header) + sheet = next((f.sheet for f in load.files if f.id == source_id), "") + excel = None + if Path(name).suffix.lower() == ".xlsx": + excel = SESSION.excel_for(raw) + else: + SESSION.drop_excel() + loaded = load_file( + raw, name, load.delimiter, load.has_header, + skip_lines=load.skip_lines, thousands=load.thousands, decimal=load.decimal, comment=load.comment, + sheet=sheet, excel=excel, + ) SESSION.df = loaded.df SESSION.filename = name SESSION.source = loaded.source SESSION.write_upload(name, raw) + source = loaded.source return { "ok": True, "encoding": loaded.encoding, "columns": column_options(loaded.df), - "preview": build_preview(loaded.df, PREVIEW_ROWS), - "warnings": [], + "preview": build_preview(loaded.df, PREVIEW_ROWS, preamble=(loaded.preamble_lines, loaded.preamble_total)), + "sheets": list(source.sheets) if source.kind == "xlsx" else [], + "sheet": source.sheet_name if source.kind == "xlsx" else None, + "warnings": list(loaded.warnings), } diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py index 72de227..1c22fca 100644 --- a/py/mplgui/codegen.py +++ b/py/mplgui/codegen.py @@ -194,26 +194,42 @@ def _font_list_literal() -> str: return "[" + ", ".join(literal(name) for name in SANS_SERIF_PRIORITY) + "]" +# 読込の引数ごとのコメント(SourceInfo.read_kwargs の全てのキーに必要。読込部はこの順・この内容で、全ての引数を明記する) +_READ_COMMENTS = { + "sep": "区切り文字", + "header": "先頭行をヘッダにする(None: ヘッダなし)", + "skiprows": "ヘッダより前に読み飛ばす行数", + "thousands": "桁区切り(None: なし)", + "decimal": "小数点", + "comment": "コメント記号(None: なし)。この記号から行末までを読まない", + "engine": None, + "sheet_name": "シート", +} +_READ_COMMENTS_XLSX = { + "thousands": "桁区切り(文字列のセルだけに効く。None: なし)", + "decimal": "小数点(文字列のセルだけに効く)", +} +_KIND_NOTES = {"csv": "CSV(.csv)", "txt": "テキスト(.txt)", "xlsx": "Excel(.xlsx)"} + + 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")', - ) + xlsx = source.kind == "xlsx" + b.add(f"# 形式: {_KIND_NOTES.get(source.kind, comment_text(source.kind))}") + if xlsx: + b.add("# 読み込みには openpyxl が必要です(pip install openpyxl)") + if source.sheets: + b.add(f"# ファイル内のシート: {comment_text(', '.join(source.sheets))}") + b.add(f"DATA_FILE = {literal(source.filename)}") + func = "pd.read_excel" if xlsx else "pd.read_csv" + b.add(f"df = {func}(", " DATA_FILE,") + if not xlsx: + b.add(f" encoding={literal(source.encoding)}, # 文字コード(自動判定)") + for key, value in source.read_kwargs().items(): + note = (_READ_COMMENTS_XLSX.get(key) if xlsx else None) or _READ_COMMENTS[key] + line = f" {key}={literal(value)}," + b.add(f"{line} # {note}" if note else line) + b.add(")") 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)) diff --git a/py/mplgui/dataprep.py b/py/mplgui/dataprep.py index 80a3fbb..188f19b 100644 --- a/py/mplgui/dataprep.py +++ b/py/mplgui/dataprep.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import re from dataclasses import dataclass import pandas as pd @@ -52,9 +53,29 @@ def coerce_numeric(raw: pd.Series) -> tuple[pd.Series, int, list[str]]: return numeric, count, examples -def dropped_warning(series_no: int, what: str, count: int, examples: list[str]) -> dict: +_THOUSANDS_LIKE = re.compile(r"[+-]?\d{1,3}(,\d{3})+(\.\d+)?") # 1,234 / -1,234,567.5 +_DECIMAL_COMMA_LIKE = re.compile(r"[+-]?(\d+,\d+|\d{1,3}(\.\d{3})+,\d+)") # 1,5 / 1.234,5 +THOUSANDS_HINT = "(桁区切りのカンマなら、「データ読み込み」の「桁区切り」をカンマにしてください)" +DECIMAL_HINT = "(小数点がカンマなら、「データ読み込み」の「小数点」をカンマにしてください)" + + +def load_hint(examples: list[str], load) -> str: + """除外した値の例が、桁区切り・小数点カンマの数に見えるのに読込設定がそうなっていないとき、設定の案内を返す。""" + if load is None: + return "" + for text in examples: + value = text.strip() + if _THOUSANDS_LIKE.fullmatch(value): # 両方に当てはまる 1,234 は桁区切りとして案内する + if load.thousands != ",": + return THOUSANDS_HINT + elif _DECIMAL_COMMA_LIKE.fullmatch(value) and load.decimal != ",": + return DECIMAL_HINT + return "" + + +def dropped_warning(series_no: int, what: str, count: int, examples: list[str], load=None) -> dict: ex = ", ".join(f'"{e}"' for e in examples) - message = f"{what}: 数値に変換できない値が{count}件あったため、その行を除外しました(例: {ex})。" + message = f"{what}: 数値に変換できない値が{count}件あったため、その行を除外しました(例: {ex})。{load_hint(examples, load)}" return {"series": series_no, "message": message} @@ -200,10 +221,10 @@ def _plan_xy(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Plo 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)) + warnings.append(dropped_warning(n, f"系列{n}のX列({x_label})", x_dropped, x_examples, settings.load)) 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)) + warnings.append(dropped_warning(n, f"系列{n}({y_name})", y_dropped, y_examples, settings.load)) has_points = bool((y_numeric.notna() & pd.notna(x_series)).any()) plans.append( @@ -253,7 +274,7 @@ def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Pl 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)) + warnings.append(dropped_warning(n, f"系列{n}({y_name})", dropped, examples, settings.load)) plans.append( SeriesPlan( number=n, diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py index 67d14a8..0161fc9 100644 --- a/py/mplgui/loader.py +++ b/py/mplgui/loader.py @@ -5,7 +5,7 @@ import io import re import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import pandas as pd @@ -14,6 +14,7 @@ SUPPORTED = {".xlsx", ".csv", ".txt"} PREVIEW_ROWS = 100 +PREAMBLE_LINES = 20 # 前置き行として画面に出す最大の行数 @dataclass(frozen=True) @@ -25,7 +26,30 @@ class SourceInfo: encoding: str | None = None # xlsx は None separator: str | None = None # デコード後の区切り文字(csv / txt)。xlsx は None has_header: bool = True - sheet_name: str | None = None # xlsx のみ(先頭シート) + sheet_name: str | None = None # xlsx のみ(読んだシート) + skip_lines: int = 0 # ヘッダより前に読み飛ばす行数 + thousands: str | None = None # None = なし + decimal: str = "." + comment: str | None = None # None = なし + sheets: tuple[str, ...] = () # xlsx のシート一覧 + datetime_columns: tuple = () # D5 用(未使用) + pasted: bool = False # D1 用(未使用) + + def read_kwargs(self) -> dict: + """pd.read_csv / pd.read_excel に渡す引数(文字コードとファイルを除く)。順序は生成スクリプトの並びと同じ。 + + loader と codegen が同じ定義を使う(読み方が食い違わないようにする)。 + """ + common = { + "header": 0 if self.has_header else None, + "skiprows": self.skip_lines, + "thousands": self.thousands, + "decimal": self.decimal, + "comment": self.comment, + } + if self.kind == "xlsx": + return {"sheet_name": self.sheet_name, **common} + return {"sep": self.separator, **common, "engine": "python"} @dataclass @@ -33,6 +57,9 @@ class LoadedData: df: pd.DataFrame encoding: str | None # xlsx は None source: SourceInfo | None = None + warnings: list[str] = field(default_factory=list) # 読込時の警告(画面の警告に出す) + preamble_lines: tuple[str, ...] = () # 読み飛ばした行(最大 PREAMBLE_LINES 行) + preamble_total: int = 0 # 読み飛ばした行数 # バックスロッシュ表記(\t, \x41, \u3001 など)だけを解釈する。それ以外(「、」などの非 ASCII 文字)は変更しない。 @@ -124,61 +151,159 @@ def detect_encoding(data: bytes) -> tuple[str, str]: return min(candidates, key=lambda c: _suspicion(c[1])) # min は同点で先頭を返す -def load_file(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> LoadedData: +SKIP_LINES_HINT = "ファイルの先頭に説明の行があるときは、「ヘッダより前に読み飛ばす行数」を指定してください。" +SKIP_LINES_TOO_LARGE_HINT = "「ヘッダより前に読み飛ばす行数」が大きすぎないか確認してください。" +DELIMITER_HINT = "列が1つしか読み込めませんでした。区切り文字(; やタブなど)を「区切り文字」に指定してください。" +_EXCEL_OPEN_ERROR = ( + "Excelファイル(.xlsx)を読み込めませんでした。ファイルが壊れていないか、パスワードが掛かっていないかを確認し、" + "Excelで開いて .xlsx で保存し直してください。" +) + + +def open_excel(file_bytes: bytes) -> pd.ExcelFile: + """xlsx を開く(シートの切り替えで開き直さないように、呼び出し側が保持する)。""" + try: + return pd.ExcelFile(io.BytesIO(file_bytes)) + except Exception as exc: + raise UserError(_EXCEL_OPEN_ERROR, field="入力ファイル", detail=f"{type(exc).__name__}: {exc}") from exc + + +def _check_conflicts(sep: str, thousands: str, comment: str) -> None: + if thousands == " " and sep == r"\s+": + raise UserError("区切り文字が空白のときは、桁区切りに空白は使えません。", field="桁区切り") + if comment and comment == sep: + raise UserError("「コメント記号」に、区切り文字と同じ記号は使えません。", field="コメント記号") + + +def _first_data_line(text: str, skip_lines: int, comment: str) -> str: + for line in text.splitlines()[skip_lines:]: + if line.strip() and not (comment and line.lstrip().startswith(comment)): + return line + return "" + + +def _delimiter_warning(text: str, sep: str, skip_lines: int, comment: str) -> str | None: + """1 列しか読めなかったとき、先頭のデータ行に ; かタブがあれば、区切り文字の指定漏れとして案内する。""" + line = _first_data_line(text, skip_lines, comment) + for candidate in (";", "\t"): + if candidate in line and sep != candidate: + return DELIMITER_HINT + return None + + +def _excel_preamble(excel: pd.ExcelFile, sheet: str, skip_lines: int) -> tuple[str, ...]: + head = excel.parse(sheet_name=sheet, header=None, nrows=min(skip_lines, PREAMBLE_LINES)) + lines = [] + for row in head.itertuples(index=False, name=None): + cells = ["" if pd.isna(v) else str(v) for v in row] + lines.append("\t".join(cells).rstrip("\t")) + return tuple(lines) + + +def load_file( + file_bytes: bytes, + filename: str, + delimiter: str = "", + has_header: bool = True, + *, + skip_lines: int = 0, + thousands: str = "", + decimal: str = ".", + comment: str = "", + sheet: str = "", + excel: pd.ExcelFile | None = None, +) -> LoadedData: suffix = Path(filename).suffix.lower() if suffix not in SUPPORTED: raise UserError("対応していない拡張子です。xlsx/csv/txtを選択してください。", field="入力ファイル") - header = 0 if has_header else None used_encoding: str | None = None sep: str | None = None sheet_name: str | None = None + sheets: tuple[str, ...] = () + text = "" + warnings_out: list[str] = [] + preamble: tuple[str, ...] = () + + def make_source() -> SourceInfo: + return SourceInfo( + filename=Path(filename).name, + kind=suffix.lstrip("."), + encoding=used_encoding, + separator=sep, + has_header=has_header, + sheet_name=sheet_name, + skip_lines=skip_lines, + thousands=thousands or None, + decimal=decimal, + comment=comment or None, + sheets=sheets, + ) try: if suffix == ".xlsx": + if excel is None: + excel = open_excel(file_bytes) + sheets = tuple(str(n) for n in excel.sheet_names) + if sheet == "": + sheet_name = sheets[0] + elif sheet in sheets: + sheet_name = sheet + else: + raise UserError(f"シート「{sheet}」がファイルにありません。シートを選び直してください。", field="シート") + kwargs = make_source().read_kwargs() try: - 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) + df = excel.parse(**kwargs) + if skip_lines > 0 and not df.empty: + preamble = _excel_preamble(excel, sheet_name, skip_lines) except Exception as exc: - raise UserError( - "Excelファイル(.xlsx)を読み込めませんでした。ファイルが壊れていないか、パスワードが掛かっていないかを確認し、" - "Excelで開いて .xlsx で保存し直してください。", - field="入力ファイル", - detail=f"{type(exc).__name__}: {exc}", - ) from exc + raise UserError(_EXCEL_OPEN_ERROR, field="入力ファイル", detail=f"{type(exc).__name__}: {exc}") from exc else: sep = decode_delimiter(delimiter, suffix) + _check_conflicts(sep, thousands, comment) used_encoding, text = detect_encoding(file_bytes) - df = pd.read_csv(io.StringIO(text), sep=sep, header=header, engine="python") + kwargs = make_source().read_kwargs() + df = pd.read_csv(io.StringIO(text), **kwargs) + if skip_lines > 0: + preamble = tuple(text.splitlines()[:skip_lines][:PREAMBLE_LINES]) except UserError: raise except pd.errors.EmptyDataError as exc: - raise UserError("ファイルにデータがありません。内容のあるファイルを選択してください。", field="入力ファイル") from exc + message = "ファイルにデータがありません。内容のあるファイルを選択してください。" + if skip_lines > 0: + message += SKIP_LINES_TOO_LARGE_HINT + raise UserError(message, field="入力ファイル") from exc except (pd.errors.ParserError, ValueError, re.error, csv.Error) as exc: # 列数の不整合、区切り文字が正規表現として不正、など raise UserError( "ファイルを表として読み込めませんでした。「区切り文字」の設定(正規表現として正しいか)や、" - "行ごとの列数が揃っているかを確認してください。", + "行ごとの列数が揃っているかを確認してください。" + SKIP_LINES_HINT, field="区切り文字", detail=f"{type(exc).__name__}: {exc}", ) from exc if df.empty: - raise UserError("ファイルにデータ行がありません。ヘッダ行だけでなく、データ行を含むファイルを選択してください。", field="入力ファイル") + message = "ファイルにデータ行がありません。ヘッダ行だけでなく、データ行を含むファイルを選択してください。" + if skip_lines > 0: + message += SKIP_LINES_TOO_LARGE_HINT + raise UserError(message, field="入力ファイル") if not has_header: df.columns = [f"column_{i}" for i in range(len(df.columns))] - source = SourceInfo( - filename=Path(filename).name, - kind=suffix.lstrip("."), + if suffix != ".xlsx" and len(df.columns) == 1: + hint = _delimiter_warning(text, sep, skip_lines, comment) + if hint: + warnings_out.append(hint) + + return LoadedData( + df=df, encoding=used_encoding, - separator=sep, - has_header=has_header, - sheet_name=sheet_name, + source=make_source(), + warnings=warnings_out, + preamble_lines=preamble, + preamble_total=skip_lines if preamble else 0, ) - return LoadedData(df=df, encoding=used_encoding, source=source) def load_dataframe(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> pd.DataFrame: @@ -201,9 +326,12 @@ def _format_cells(preview_df: pd.DataFrame) -> list[list[str]]: return [[str(v) for v in row] for row in preview_df.itertuples(index=False, name=None)] -def build_preview(df: pd.DataFrame, limit: int = PREVIEW_ROWS) -> dict: +def build_preview(df: pd.DataFrame, limit: int = PREVIEW_ROWS, preamble: tuple[tuple[str, ...], int] | None = None) -> dict: + """プレビュー。preamble は (読み飛ばした行, 行数)。行数が 0 のとき preview["preamble"] は None。""" preview_df = df.head(limit) + lines, total = preamble if preamble else ((), 0) return { + "preamble": {"lines": list(lines[:PREAMBLE_LINES]), "total": int(total)} if total > 0 else None, "columns": [str(c) for c in df.columns], "rows": _format_cells(preview_df), "totalRows": int(len(df)), diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py index 190fc5e..d298dd3 100644 --- a/py/mplgui/settings.py +++ b/py/mplgui/settings.py @@ -50,7 +50,7 @@ MAX_SKIP_LINES = 1_000_000 MAX_FILES = 10 MAX_DATE_FORMAT_LENGTH = 64 -_FILE_ID_RE = re.compile(r"^d[0-9]{1,4}$") +_FILE_ID_RE = re.compile(r"d[0-9]{1,4}") _DEFAULT_SERIES = { "id": "s1", @@ -409,7 +409,7 @@ def _parse_files(raw_files: Any) -> tuple[FileEntry, ...]: if not isinstance(item, dict): raise UserError(f"「{label}」の値が不正です。", field=label) fid = item.get("id") - if not isinstance(fid, str) or not _FILE_ID_RE.match(fid): + if not isinstance(fid, str) or not _FILE_ID_RE.fullmatch(fid): raise UserError(f"「{label}」のIDが不正です。", field=label) if fid in seen: raise UserError(f"「{label}」のIDが重複しています。", field=label) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index f5ac6ca..d146505 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -14,3 +14,8 @@ | `non_numeric.csv` | 「1,234」形式の数値、「12 mV」のような単位付き、N/A、空欄が混在 | | `growth.csv` | 時間 / 指数 / 線形。値はすべて正(対数軸で描ける)。生成コードの一致確認用 | | `categories.csv` | 品目(文字列)/ 売上A / 売上B。文字列カテゴリの棒グラフ用 | +| `preamble.csv` | 先頭に説明の行が3行(3行目はカンマの数が違う。skipLines 無しでは列数の不整合で読めない)。skipLines=3 で読める | +| `european.csv` | 区切り `;`、小数点 `,`、桁区切り `.`(`1.234,5`、負の値あり) | +| `thousands.csv` | カンマ区切りで、引用符付きの桁区切り(`"1,234"`、負の値あり) | +| `comments.csv` | `#` の行全体のコメントと、データ行末尾の `# note` | +| `preamble.xlsx` | 2 シート(データ / 説明つき)。2 枚目は見出しの前に説明が2行 | diff --git a/tests/fixtures/comments.csv b/tests/fixtures/comments.csv new file mode 100644 index 0000000..ad64b48 --- /dev/null +++ b/tests/fixtures/comments.csv @@ -0,0 +1,13 @@ +# 全体のコメント +x,y +0,0 +1,1 +2,4 +3,9 +4,16 # note +5,25 +6,36 +# 途中のコメント行 +7,49 +8,64 +9,81 diff --git a/tests/fixtures/european.csv b/tests/fixtures/european.csv new file mode 100644 index 0000000..bde4923 --- /dev/null +++ b/tests/fixtures/european.csv @@ -0,0 +1,14 @@ +番号;値;比 +1;1.234,5;0,00 +2;2.469,0;0,25 +3;3.703,5;0,50 +4;4.938,0;0,75 +5;6.172,5;1,00 +6;7.407,0;1,25 +7;8.641,5;1,50 +8;9.876,0;1,75 +9;11.110,5;2,00 +10;12.345,0;2,25 +11;13.579,5;2,50 +12;14.814,0;2,75 +13;-1.234.567,5;-0,5 diff --git a/tests/fixtures/make_fixtures.py b/tests/fixtures/make_fixtures.py index 9fd1bb3..abe3cf0 100644 --- a/tests/fixtures/make_fixtures.py +++ b/tests/fixtures/make_fixtures.py @@ -89,6 +89,49 @@ def main(): wb.save(OUT / "multi_sheet.xlsx") _normalize_zip(OUT / "multi_sheet.xlsx") + # 前置き行(装置の説明)が3行。3行目はカンマの数が違うので、skipLines 無しでは表として読めない + pre = [ + "装置: テスト用ロガー,型番: X-100,単位: V", + "備考: この行はデータではありません", + "測定日: 2026-01-01,担当: 合成データ,単位: V と mA,備考: 5列,終", + ] + (OUT / "preamble.csv").write_bytes(("\n".join(pre) + "\n" + text).encode("utf-8")) + + # ヨーロッパ式の数値: 区切り ;、小数点 ,、桁区切り .(1.234,5) + eu = [(i + 1, f"{(i + 1) * 1234.5:,.1f}".replace(",", "_").replace(".", ",").replace("_", "."), f"{i * 0.25:.2f}".replace(".", ",")) for i in range(12)] + eu.append((13, "-1.234.567,5", "-0,5")) + (OUT / "european.csv").write_bytes(csv_text(["番号", "値", "比"], eu, sep=";").encode("utf-8")) + + # 引用符付きの桁区切り(カンマ区切りで "1,234")。負の値も含む + th = [(i + 1, f'"{(i + 1) * 1234:,}"', f'"{-(i + 1) * 98765.5:,.1f}"') for i in range(12)] + (OUT / "thousands.csv").write_bytes(csv_text(["番号", "金額", "差額"], th).encode("utf-8")) + + # コメント: 行全体の # と、データ行の末尾の # note + cm = ["# 全体のコメント", "x,y"] + for i in range(10): + cm.append(f"{i},{i * i}" + (" # note" if i == 4 else "")) + if i == 6: + cm.append("# 途中のコメント行") + (OUT / "comments.csv").write_bytes(("\n".join(cm) + "\n").encode("utf-8")) + + # 2 シート。2 枚目は見出しの前に説明の行が 2 行ある + wb2 = openpyxl.Workbook() + p1 = wb2.active + p1.title = "データ" + p1.append(["t", "v"]) + for i in range(15): + p1.append([i, i * 2]) + p2 = wb2.create_sheet("説明つき") + p2.append(["測定条件: 合成データ"]) + p2.append(["単位: V"]) + p2.append(["t", "v", "w"]) + for i in range(15): + p2.append([i, i * i, 100 - i]) + wb2.properties.created = fixed + wb2.properties.modified = fixed + wb2.save(OUT / "preamble.xlsx") + _normalize_zip(OUT / "preamble.xlsx") + dup = ["温度", "温度", "値"] # 同名列(温度)で値が異なる d = [(20 + i * 0.5, 100 - i * 2, round(math.sqrt(i + 1), 4)) for i in range(N)] diff --git a/tests/fixtures/preamble.csv b/tests/fixtures/preamble.csv new file mode 100644 index 0000000..e88d6d9 --- /dev/null +++ b/tests/fixtures/preamble.csv @@ -0,0 +1,34 @@ +装置: テスト用ロガー,型番: X-100,単位: V +備考: この行はデータではありません +測定日: 2026-01-01,担当: 合成データ,単位: V と mA,備考: 5列,終 +時間,電圧,電流 +0.0,0.0,1.5 +0.1,0.1987,1.4777 +0.2,0.3894,1.4127 +0.3,0.5646,1.3108 +0.4,0.7174,1.1812 +0.5,0.8415,1.0354 +0.6,0.932,0.8864 +0.7,0.9854,0.7476 +0.8,0.9996,0.6313 +0.9,0.9738,0.548 +1.0,0.9093,0.505 +1.1,0.8085,0.5063 +1.2,0.6755,0.5516 +1.3,0.5155,0.637 +1.4,0.335,0.7549 +1.5,0.1411,0.8946 +1.6,-0.0584,1.0437 +1.7,-0.2555,1.189 +1.8,-0.4425,1.3173 +1.9,-0.6119,1.4174 +2.0,-0.7568,1.4801 +2.1,-0.8716,1.4999 +2.2,-0.9516,1.4751 +2.3,-0.9937,1.4079 +2.4,-0.9962,1.3042 +2.5,-0.9589,1.1733 +2.6,-0.8835,1.027 +2.7,-0.7728,0.8782 +2.8,-0.6313,0.7404 +2.9,-0.4646,0.6257 diff --git a/tests/fixtures/preamble.xlsx b/tests/fixtures/preamble.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bff5f957fd83938b81a177595861065a1de6db8f GIT binary patch literal 5907 zcmZ`-2RNJG+m2l+6~w4fYF35XwP)>7YHuxS3u3F9wW-*aV{4Ke^8P=E{}(&U?;tpXYhbt*(rP4F&)JH_+p*kzBW`LL&OpXY>G}hXvGJ z-396dd+^lBiPO`;UKOc&vyF=YccJ--drfM@oxEF*#3Hg9k(_R?s@WWZ&vv)5B^nrQ)Kr!R~&eGx(Ox68PCM~`?4dlSA0sjX9J=>QW53R-N9_NWlnU};~{;}ibKN>@BX0|c#kGmA%(DhnmE<6K2*_LFI5ed~i> zSl~3{)l;bdH3gzm7uz-TqxWL~0L1^Ef;rU1^4A>l;jOmqTm&Je`6pRDirIL}XB8DA zxIttB?!47gX`SI8`Qw@{Y06X|R!>V(Y+M~FN)KZ{4GDYfe1o$0sex+KW<1x^eziwZ z04A7pnPw)fFFBFSVVVooYY@U~LOwVNMd-=oz;BZhpsg3S6^M8)74mwL%%9(%)_aRh zO7o!;ibU?sPy2g)OBr#7M$3ZZ%k?J*a)gcI;-bl`>=UfRpQH-%_}dJoh8}QhIjIZ8f@A#Cp;8UKa3Rzh-#>P=6BPcARQ`x+pee<9tw7er;XQ3$?@f zY&|3_;lR`E>x1d>hMEU+3%Wc^#8OQj+$Wz{nfQp5Xp* zqXk2%2aMz25!16e!8Z2Ul=2p4z9tZB$uC+O22fLa#3~){30c2Zv}G;^sjd`DT^Qov z)V}j#YEIk|xgku;6X3{2tw--S2rXPuGYDJMYU^mRwUW@W>mC|b@DJ?uO~9#FJkA{< zyTL;!BAlr==>FokWBW~1-;^t#1J%OvzNcVy|8S%e4fcXYLsnbnp?!0n6h0!?=8MJT z@l+|ipeS1N@yHL~`OS3e3}Ce3;}JWL*^!v=u97dFgiZU8L)%NkyNbWKZrZT0UY6SB zZID|_%PntulaN)dN{-M2^qkzs2ZphdOxVuc5~#k?Hq1&H?dk0Zg%khm>ck$m8NQvw zC+CtGi*pO`uwzI?{Y9Bnb;Lqj+^|vXIFhYOo$5wfPr#9U;N8xdvdg37hTc40?@V9e zLAwM$n_`T@?S^z@`~_@%kM(?yAIm#3PP}iaWSB^hW0HN-!%@FNELf#T$U`b| zBQqq$R-Fbj51%_JJLCk>^?`VS)VHSTM$de7*pFgpnwW03p5j#S2}q7=DFgNmHe`Q} zmoGPTtlc=1o2*BMkxV(ySHutL9QRCY*)Pcur6nc#oNyCYDkPbSh2*`^OH~EZpX~Wl zKQl%o_Yxj3ohiXz+f*_Zy0Uy;=cQ_uE2DM`wH_Ar{)uy%!4IT?Q`2HvQCG^{4Yq06 zyHmy#lUCA3%q}hd?6v@vOBH!Qwe$O6GXs*YAy2ho1o4Ib2mW|*v!1QbaJRv6_$}f% zlZWWv4o1cjH{S^nR7%^pe|5LDz?VF-dKJj;Vcz`(jb$7&9y*+^sle!w!O9Oo{u7;)_wiTZQwg~xg`Xcjp z>kh@Thie`(`dYP)L>kV?IU07HmvT={@NwQV*LC^a)2&q&&o(Lare*8WT0Coi`|K-I za9Nr@2P1i`azLdNQ|(1$-`@QW{B;XMX8eAw5zB`WBw5nbjvJasN{JJzWp(#zMGaRr z%CcR>A;?ov(>28Bo4hhLF3R7l6Pl`61eFdy4mhdZ=_e4e5{;v+_IOLne|~K2zQUN# zrI{>KOC{Zeb7Rn8oOeLwqUa?Z#79WW#jUD}^xAlmcID-T@1Z(kFj8XK-gtvZMSc*9212FABv&P- zM+vQu3HfN?I40bw)dIOIYCzGMpSy(RZ5=U-+mX!}e;1IPk4sdXMbx*y||%LIUME(J(6Z5~E;9-Ecu;ML|*z z(pX);oa+XI&OzhBNbR%_+y>%U)WiM)ZUSRh=DeaU2RSSST~+O;nevV~l=GFIlFh4M zmb&>1q^@S5NQzk~5?S}-PUaW(0U8v;br-!K9eSpCp)l}LJEa6NoGvA&yd|E8pp?$z zMOzNnJ>$ML$)oNMEODjHdq`s}S6}(Lh4_1OsLDPPzcnAL#y!Jq6WMdD(B!Eo=9g;` zg&)Bste>X~_In{JO4}*A1M}W(%^6n8Yf4%3;&=mDw%y7jpGo8vc=6YSizMg2Mbjm(-&>e zye+|T2uD4~&7p$0`dBH7SY3*R+m?UX`NX^({=;1k{q-SrptC)e6>kW4J_F>CrspA` z!%reqgP8p6)^dW(`p%AIu5IlXA4+*zmP&ED<8Ug@L%J@n(5mxB+^H3qo^X)!dVre1 zK=;d>5am!VHStF~_>|UKglmN@J%m=Jd!|MvsG@yfq~~ zci5zNX;X#c-W8-*O{ZK#xTRYw8qw6eVizgz&!Z(K!P^OAl*a4`-IziKWV-cM8valp zJKjCLd_#Ip7i!m6TAToK{Pf_Uf@+FwEizzPD?S0N=q5*z2ey#YhdT{f!U_$z;#4*j zWp_MFH$`OQ65YY$=4%k(M%t8A!M}Dtj z7JuwX@+zv7gGHD673tU5I0i2ERn7Vpl_)FeaQOz)`5K9)t)`bgCzmiYhh3;g&5!i- z$LZ-3>94P(LnDVR-trPlz!AM${HbCx-?nGhLYqCXHoregQj5bBPdZWJb= zNe`T0{r+9R&296mOIZoSLsy;hC{joWK^=#-g?F?HQHUK6s~*Aau5RrMxlX*HtO87Z zUCjUjyl(BYXeGKw9(-gy#e$Vu*h(oq zBf=RpbNr&7-h|yNuO+65{?$^c6i^QXbKln(*jD;80vsWN`+l4{7HHC$VKU^3}OZd_i zVoly3EWb;7lkLHMavK7B9a>n7;in@trdR7e_6Sq8Ox=$)TYRQ3RG^fLi~XZr0yA?} z)H3ObDw)N4@;#l-edzDT7(#L!-yhf$xEa2egUae#SxNA%TjuvlRX zvOt$Vjr;Sudcd?R_i6Lmc1N-E8@&10TR)Mlf(_uH z#p2=?@Jk`|?29+YGR&gXm^|nhB*i|C6|}_11&lM?5(NutFkz17XkdL1Erf*t7wyC` z5}AXo6c7QTBflBZf)*V)T7WmvcT*Qjd)NccU)RhyWk*bI0?9HGOH-Bi9hlr7Cd*~QZq|mc z<)(JP%{vZ{qbFPFt%;Sdva&eJi1^;R)4-D@$@@ z^TmESngl_g9MJI zWtM%|x+vt|D;Pi>(Z}Y<%Q`_0;dv^N?g?e$S+h-fsC@Hh2+#TTE7X^FV6oHYOs@R! z;R#uM7HIh?&fgPVgg81rMW6GX(Gw;5y~}JozB5g zuAzuD0FkkODr~5n%~pJ_SW$AUVEf9&=0|ny2r+RkJ>%=9fd7uhJh{K z{HdPIIZ;2D%G1^bz%CmUM?*$^%YBg34OGou5qpx{_&t%Wt?z>1ghwx@^)?Vu!(F_K zsYkDo-NB#79jRyLVvrfv#n0B?$uz|DXh2xi960stKsn1Zed5J)+s=cpiAb7ClNJBl zw&t7RX~@i8=Gw)A`y8~nBs@Iprq%G|HZN;6^Bi=~sO!tA>uvHqqTov@Ib=%ZT5Qg>`2GF;yu-#^E)bWthSw*wC}cQ z!iUxao_ytL5CJ4|P{{_k2@X80I+EL`@x@Y0{A3GS;%W?-=e0=MO3g{3`6-QXn@v0^ zU+rILF>TqaZYm^)=if_!#%ki{&UIn;6XWpeM>HzsBa0P)Cp8)`xFV2z3|V}`1(n}q zu2u(LX3a0@Gsg{?MCCvDj z`Z~4x)!Mm07l}O+_uV%Fux!1vjk57xwlJl4MVKBqDxdwZCo#a+GAN>V4kz2)8u08s zI5+}fR1WIDeq|lD6!b(-V1cg>)C$YL)oZgfkI>dgE}0877+CvJM3B{@sU{Yx$1$P4 znVG-!e%XPqm4YEa7A_XkVp0qema|h*HPZDl_0{Z^AY<~lMQN|naU1JV!JraBJ{?(1 z^aCG-UaeRM*Bq;D$^zly*jwc4Cf@{?c}A<)vVp6E&XFA{lDduqmdM5q@EJORq|NG?s zmzS@DuP2T_U@?+^!2czUT<5vIDgW@Wp*_$4utn9CanQ$E004-72cxr=*DpB&{s%U1 BI2-@~ literal 0 HcmV?d00001 diff --git a/tests/fixtures/thousands.csv b/tests/fixtures/thousands.csv new file mode 100644 index 0000000..65b2c6d --- /dev/null +++ b/tests/fixtures/thousands.csv @@ -0,0 +1,13 @@ +番号,金額,差額 +1,"1,234","-98,765.5" +2,"2,468","-197,531.0" +3,"3,702","-296,296.5" +4,"4,936","-395,062.0" +5,"6,170","-493,827.5" +6,"7,404","-592,593.0" +7,"8,638","-691,358.5" +8,"9,872","-790,124.0" +9,"11,106","-888,889.5" +10,"12,340","-987,655.0" +11,"13,574","-1,086,420.5" +12,"14,808","-1,185,186.0" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 64a6202..8ec0cb0 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -98,7 +98,7 @@ 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 r["code"].startswith("# matplotlib GUI が生成したスクリプト") and "pd.read_csv(\n 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]" diff --git a/tests/unit/test_codegen.py b/tests/unit/test_codegen.py index 67f020c..ba3a141 100644 --- a/tests/unit/test_codegen.py +++ b/tests/unit/test_codegen.py @@ -443,7 +443,7 @@ def test_xlsx_load_section(tmp_path): 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 + assert "pd.read_excel(\n DATA_FILE,\n" in script.text and ' sheet_name="Sheet1", # シート\n' 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 diff --git a/tests/unit/test_load_options.py b/tests/unit/test_load_options.py new file mode 100644 index 0000000..d8444e6 --- /dev/null +++ b/tests/unit/test_load_options.py @@ -0,0 +1,428 @@ +"""Phase 4 のデータ取り込み(D2 シート / D3 ヘッダ前の行 / D4 桁区切り・小数点・コメント)の単体テスト。""" +import json +import os +import shutil +from pathlib import Path + +import pandas as pd +import pytest + +from mplgui import api, loader +from mplgui.codegen import generate_script +from mplgui.dataprep import DECIMAL_HINT, THOUSANDS_HINT, plan_plot +from mplgui.errors import UserError +from mplgui.loader import SourceInfo, build_preview, load_file +from mplgui.settings import default_settings, parse_settings + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" + + +def L(name, delimiter="", has_header=True, **kw): + return load_file((FIXTURES / name).read_bytes(), name, delimiter, has_header, **kw) + + +def csv_bytes(text): + return text.encode("utf-8") + + +# ---------------------------------------------------------------- loader + + +def test_existing_positional_call_still_works(): + assert load_file((FIXTURES / "utf8.csv").read_bytes(), "utf8.csv", "", True).df.shape == (30, 3) + + +def test_skip_lines_csv_and_preamble(): + r = L("preamble.csv", skip_lines=3) + assert list(r.df.columns) == ["時間", "電圧", "電流"] and len(r.df) == 30 + assert r.preamble_total == 3 and len(r.preamble_lines) == 3 and r.preamble_lines[0].startswith("装置") + assert r.source.skip_lines == 3 + p = build_preview(r.df, preamble=(r.preamble_lines, r.preamble_total)) + assert p["preamble"] == {"lines": list(r.preamble_lines), "total": 3} + + +def test_no_skip_lines_gives_no_preamble(): + r = L("utf8.csv") + assert r.preamble_total == 0 and build_preview(r.df)["preamble"] is None + assert build_preview(r.df, preamble=((), 0))["preamble"] is None + + +def test_preamble_lines_are_capped_at_20(): + text = "\n".join(f"memo {i}" for i in range(30)) + "\nx,y\n1,2\n" + r = load_file(csv_bytes(text), "m.csv", "", True, skip_lines=30) + assert len(r.preamble_lines) == 20 and r.preamble_total == 30 + assert build_preview(r.df, preamble=(r.preamble_lines, r.preamble_total))["preamble"]["total"] == 30 + + +def test_preamble_without_skip_lines_is_a_parse_error_with_hint(): + with pytest.raises(UserError) as e: + L("preamble.csv") + assert "ヘッダより前に読み飛ばす行数" in e.value.message and e.value.field == "区切り文字" + + +def test_skip_lines_too_large(): + with pytest.raises(UserError) as e: + L("preamble.csv", skip_lines=500) + assert "ヘッダより前に読み飛ばす行数" in e.value.message + with pytest.raises(UserError) as e: + L("preamble.xlsx", skip_lines=500) + assert "ヘッダより前に読み飛ばす行数" in e.value.message + + +def test_comment_characters(): + r = L("comments.csv", comment="#") + assert list(r.df.columns) == ["x", "y"] and len(r.df) == 10 + assert pd.api.types.is_numeric_dtype(r.df["y"]) and r.df["y"].tolist() == [i * i for i in range(10)] + assert r.source.comment == "#" + + +def test_european_numbers(): + r = L("european.csv", ";", thousands=".", decimal=",") + assert r.df["値"].iloc[0] == 1234.5 and r.df["値"].iloc[-1] == -1234567.5 + assert r.df["比"].iloc[-1] == -0.5 and r.df["比"].iloc[1] == 0.25 + assert r.source.thousands == "." and r.source.decimal == "," + + +def test_thousands_with_negative_and_quotes(): + r = L("thousands.csv", thousands=",") + assert r.df["金額"].tolist()[:2] == [1234, 2468] and r.df["差額"].iloc[0] == -98765.5 + assert pd.api.types.is_numeric_dtype(r.df["金額"]) + + +def test_thousands_vs_decimal_ambiguity(): + # 1.234,5(ヨーロッパ式)と 1,234.5(英米式)は、設定どおりに読まれる + eu = load_file(csv_bytes("v\n1.234,5\n"), "a.csv", ";", True, thousands=".", decimal=",").df["v"][0] + us = load_file(csv_bytes('v\n"1,234.5"\n'), "a.csv", ";", True, thousands=",", decimal=".").df["v"][0] + assert eu == us == 1234.5 + + +def test_empty_trailing_lines_are_ignored(): + r = load_file(csv_bytes("a,b\n1,2\n3,4\n\n\n"), "a.csv", "", True) + assert len(r.df) == 2 + + +def test_thousands_space_with_whitespace_separator_is_an_error(): + with pytest.raises(UserError) as e: + load_file(csv_bytes("a b\n1 2\n"), "a.txt", "", True, thousands=" ") + assert e.value.field == "桁区切り" and "空白" in e.value.message + # 区切りが空白でなければ構わない + r = load_file(csv_bytes("a;b\n1 234;2\n"), "a.txt", ";", True, thousands=" ") + assert r.df["a"][0] == 1234 + + +def test_comment_equal_to_separator_is_an_error(): + with pytest.raises(UserError) as e: + load_file(csv_bytes("a;b\n1;2\n"), "a.csv", ";", True, comment=";") + assert e.value.field == "コメント記号" + + +def test_delimiter_hint_warning(): + r = load_file(csv_bytes("a;b;c\n1;2;3\n"), "a.csv", "", True) + assert r.df.shape[1] == 1 and len(r.warnings) == 1 and "区切り文字" in r.warnings[0] + assert load_file(csv_bytes("a;b;c\n1;2;3\n"), "a.csv", ";", True).warnings == [] + assert load_file(csv_bytes("a\n1\n"), "a.csv", "", True).warnings == [] + tabbed = load_file(csv_bytes("a\tb\n1\t2\n"), "a.csv", "", True) + assert tabbed.warnings + # 説明の行を読み飛ばしたあとの先頭行で判定する + r = load_file(csv_bytes("memo;memo\na;b\n1;2\n"), "a.csv", "", True, skip_lines=1) + assert r.warnings + + +def test_no_header_with_options(): + r = load_file(csv_bytes("memo\n1,2\n3,4\n"), "a.csv", "", False, skip_lines=1) + assert list(r.df.columns) == ["column_0", "column_1"] and len(r.df) == 2 + + +# ---------------------------------------------------------------- xlsx + + +def test_xlsx_sheets_and_default_sheet(): + r = L("multi_sheet.xlsx") + assert r.source.sheets == ("Sheet1", "二枚目") and r.source.sheet_name == "Sheet1" + r2 = L("multi_sheet.xlsx", sheet="二枚目") + assert r2.source.sheet_name == "二枚目" and list(r2.df.columns) == ["x", "y", "z"] + assert r2.source.read_kwargs()["sheet_name"] == "二枚目" + + +def test_xlsx_missing_sheet(): + with pytest.raises(UserError) as e: + L("multi_sheet.xlsx", sheet="ない") + assert e.value.field == "シート" and "「ない」" in e.value.message + + +def test_xlsx_skip_lines_and_preamble(): + r = L("preamble.xlsx", sheet="説明つき", skip_lines=2) + assert list(r.df.columns) == ["t", "v", "w"] and len(r.df) == 15 + assert r.preamble_lines == ("測定条件: 合成データ", "単位: V") and r.preamble_total == 2 + + +def test_xlsx_other_options_are_accepted(): + r = L("multi_sheet.xlsx", thousands=",", decimal=",", comment="#") + assert r.df.shape[0] == 30 + + +def test_xlsx_uses_given_excel_object(): + excel = loader.open_excel((FIXTURES / "multi_sheet.xlsx").read_bytes()) + r = L("multi_sheet.xlsx", sheet="二枚目", excel=excel) + assert r.df.shape == (20, 3) + + +def test_read_kwargs_is_shared_definition(): + src = SourceInfo(filename="a.csv", kind="csv", encoding="utf-8", separator=",", skip_lines=2, thousands=",", decimal=".", comment="#") + assert list(src.read_kwargs()) == ["sep", "header", "skiprows", "thousands", "decimal", "comment", "engine"] + xl = SourceInfo(filename="a.xlsx", kind="xlsx", sheet_name="S", has_header=False) + assert xl.read_kwargs() == {"sheet_name": "S", "header": None, "skiprows": 0, "thousands": None, "decimal": ".", "comment": None} + + +# ---------------------------------------------------------------- generated load section equals the loader + +CASES = [ + ("preamble.csv", "", dict(skip_lines=3)), + ("preamble.csv", "", dict(skip_lines=3, has_header=False)), + ("european.csv", ";", dict(thousands=".", decimal=",")), + ("thousands.csv", "", dict(thousands=",")), + ("comments.csv", "", dict(comment="#")), + ("multi_sheet.xlsx", "", dict(sheet="二枚目")), + ("preamble.xlsx", "", dict(sheet="説明つき", skip_lines=2)), + ("preamble.xlsx", "", dict(sheet="データ", has_header=False)), +] + + +def load_section(script): + start, end = script.load_lines + return "import pandas as pd\n" + "\n".join(script.text.split("\n")[start - 1:end]) + + +def exec_in(tmp_path, code): + ns = {} + cwd = Path.cwd() + os.chdir(tmp_path) + try: + exec(compile(code, "load.py", "exec"), ns) + finally: + os.chdir(cwd) + return ns + + +@pytest.mark.parametrize("name, delimiter, opts", CASES) +def test_generated_load_section_matches_loader(tmp_path, name, delimiter, opts): + opts = dict(opts) + has_header = opts.pop("has_header", True) + shutil.copy(FIXTURES / name, tmp_path / name) + loaded = L(name, delimiter, has_header, **opts) + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}) + script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings)) + ns = exec_in(tmp_path, load_section(script)) + pd.testing.assert_frame_equal(ns["df"], loaded.df) + # 自動描画用の変種では、読込部がすべて空行になる + auto = script.auto_render_text() + assert "read_" not in auto and "DATA_FILE" not in auto and "skiprows" not in auto and "df.columns" not in auto + + +def test_read_section_text_has_every_option(): + loaded = L("european.csv", ";", thousands=".", decimal=",", skip_lines=0, comment="%") + settings = parse_settings(default_settings()) + text = generate_script(settings, loaded.source, plan_plot(loaded.df, parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}))).text + assert ( + 'df = pd.read_csv(\n' + ' DATA_FILE,\n' + ' encoding="utf-8", # 文字コード(自動判定)\n' + ' sep=";", # 区切り文字\n' + ' header=0, # 先頭行をヘッダにする(None: ヘッダなし)\n' + ' skiprows=0, # ヘッダより前に読み飛ばす行数\n' + ' thousands=".", # 桁区切り(None: なし)\n' + ' decimal=",", # 小数点\n' + ' comment="%", # コメント記号(None: なし)。この記号から行末までを読まない\n' + ' engine="python",\n' + ')\n' + ) in text + xl = L("multi_sheet.xlsx", sheet="二枚目") + text = generate_script(settings, xl.source, plan_plot(xl.df, parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}))).text + assert "# ファイル内のシート: Sheet1, 二枚目\n" in text + assert 'df = pd.read_excel(\n DATA_FILE,\n sheet_name="二枚目", # シート\n' in text + assert " thousands=None, # 桁区切り(文字列のセルだけに効く。None: なし)\n" in text + assert " decimal=\".\", # 小数点(文字列のセルだけに効く)\n" in text + + +# ---------------------------------------------------------------- injection + +HOSTILE_NAMES = ['x"); import os; os.system("echo") #', "a\nimport os\nos.system('echo')\n", "s\r\nraise SystemExit", "'''\nraise SystemExit"] + + +@pytest.mark.parametrize("hostile", HOSTILE_NAMES) +def test_hostile_sheet_and_file_names_do_not_inject(hostile): + import ast + + src = SourceInfo( + filename=hostile + ".xlsx", kind="xlsx", sheet_name=hostile, sheets=(hostile, "b\nc"), has_header=False, + thousands=",", comment="#", + ) + df = pd.DataFrame({"a": [1.0, 2.0]}) + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__0"}]}) + script = generate_script(settings, src, plan_plot(df, settings)) + tree = ast.parse(script.text) + calls = {n.func.attr if isinstance(n.func, ast.Attribute) else getattr(n.func, "id", "") for n in ast.walk(tree) if isinstance(n, ast.Call)} + assert "system" not in calls and "__import__" not in calls + assert not any(isinstance(n, (ast.Import, ast.ImportFrom)) and any(a.name == "os" for a in getattr(n, "names", [])) for n in ast.walk(tree)) + assert "SystemExit" not in {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)} + # 読込部のコメントは1行に収まる + start, end = script.load_lines + for line in script.text.split("\n")[start - 1:end]: + assert "\r" not in line + + +@pytest.mark.parametrize("comment", ["#", "%", ";", "\\", "'", "x"]) +def test_comment_character_is_a_literal(comment): + src = SourceInfo(filename="a.csv", kind="csv", encoding="utf-8", separator=",", comment=comment) + df = pd.DataFrame({"a": [1.0, 2.0]}) + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__0"}]}) + script = generate_script(settings, src, plan_plot(df, settings)) + compile(script.text, "plot.py", "exec") + start, end = script.load_lines + section = "\n".join(script.text.split("\n")[start - 1:end]) + ns = {} + code = section.replace("df = pd.read_csv(", "kw = dict(", 1).replace(" DATA_FILE,\n", "") + exec("import pandas as pd\n" + code, ns) + assert ns["kw"]["comment"] == comment + + +# ---------------------------------------------------------------- dataprep hints + + +def _plan_warning(text, **load): + raw = default_settings() + raw["load"].update(load) + raw["series"] = [{"x": "", "y": "__idx__1"}] + settings = parse_settings(raw) + df = pd.DataFrame({"a": [1, 2, 3, 4], "b": text}) + return plan_plot(df, settings).warnings + + +def test_thousands_hint(): + w = _plan_warning(["1,234", "2,345", "5", "6"]) + assert w and THOUSANDS_HINT in w[0]["message"] + w = _plan_warning(["-1,234,567.5", "2", "5", "6"]) + assert w and THOUSANDS_HINT in w[0]["message"] + # すでにカンマを桁区切りにしているなら案内しない + assert not any(THOUSANDS_HINT in x["message"] for x in _plan_warning(["1,234x", "2", "5", "6"], thousands=",")) + + +def test_decimal_hint(): + w = _plan_warning(["1,5", "2", "5", "6"]) + assert w and DECIMAL_HINT in w[0]["message"] and THOUSANDS_HINT not in w[0]["message"] + w = _plan_warning(["1.234,5", "2", "5", "6"]) + assert w and DECIMAL_HINT in w[0]["message"] + assert not any(DECIMAL_HINT in x["message"] for x in _plan_warning(["1,5x", "2", "5", "6"], decimal=",")) + + +def test_no_hint_for_ordinary_text(): + w = _plan_warning(["abc", "2", "5", "6"]) + assert w and "桁区切り" not in w[0]["message"] and "小数点" not in w[0]["message"] + + +# ---------------------------------------------------------------- api + + +@pytest.fixture(autouse=True) +def fresh_session(): + api.SESSION.forget() + api.SESSION.workdir = None + yield + api.SESSION.forget() + api.SESSION.workdir = None + + +def api_load(name, **load): + s = default_settings() + sheet = load.pop("sheet", None) + s["load"].update(load) + if sheet is not None: + s["load"]["files"] = [{"id": "d1", "name": name, "sheet": sheet}] + return json.loads(api.load_file_json(name, (FIXTURES / name).read_bytes(), json.dumps(s))) + + +def test_api_sheets_and_sheet(): + r = api_load("multi_sheet.xlsx") + assert r["ok"] and r["sheets"] == ["Sheet1", "二枚目"] and r["sheet"] == "Sheet1" + r = api_load("multi_sheet.xlsx", sheet="二枚目") + assert r["sheet"] == "二枚目" and [c["label"] for c in r["columns"]] == ["x [0]", "y [1]", "z [2]"] + r = api_load("utf8.csv") + assert r["sheets"] == [] and r["sheet"] is None and r["preview"]["preamble"] is None + + +def test_api_missing_sheet_is_an_error(): + r = api_load("multi_sheet.xlsx", sheet="ない") + assert r["ok"] is False and r["error"]["field"] == "シート" + + +def test_api_preamble_and_warnings(): + r = api_load("preamble.csv", skipLines=3) + assert r["ok"] and r["preview"]["preamble"]["total"] == 3 and r["preview"]["preamble"]["lines"][0].startswith("装置") + r = api_load("preamble.csv") + assert r["ok"] is False and "ヘッダより前に読み飛ばす行数" in r["error"]["message"] + r = json.loads(api.load_file_json("a.csv", b"a;b\n1;2\n", json.dumps(default_settings()))) + assert r["ok"] and len(r["warnings"]) == 1 and "区切り文字" in r["warnings"][0] + + +def test_api_load_options_reach_the_script(): + api_load("european.csv", delimiter=";", thousands=".", decimal=",") + s = default_settings() + s["load"].update(delimiter=";", thousands=".", decimal=",") + s["series"] = [{"x": "", "y": "__idx__1"}] + r = json.loads(api.render_json(json.dumps(s))) + assert r["ok"] and 'thousands=".",' in r["code"] and 'decimal=",",' in r["code"] and not r["warnings"] + + +def test_api_reuses_excel_object_for_same_bytes(monkeypatch): + opened = [] + real = loader.open_excel + monkeypatch.setattr(loader, "open_excel", lambda raw: opened.append(1) or real(raw)) + api_load("multi_sheet.xlsx") + api_load("multi_sheet.xlsx", sheet="二枚目") + api_load("multi_sheet.xlsx", sheet="Sheet1") + assert len(opened) == 1 + first = api.SESSION.excel + # 別のファイルを読むとキャッシュを捨てる + api_load("utf8.csv") + assert api.SESSION.excel is None and first is not None + api_load("multi_sheet.xlsx") + assert len(opened) == 2 + api.SESSION.forget() + assert api.SESSION.excel is None + api_load("preamble.xlsx") + assert len(opened) == 3 + + +def test_api_excel_cache_survives_failed_load(): + api_load("multi_sheet.xlsx") + excel = api.SESSION.excel + r = api_load("multi_sheet.xlsx", sheet="ない") + assert r["ok"] is False and api.SESSION.excel is excel + assert api_load("multi_sheet.xlsx", sheet="二枚目")["ok"] + assert api.SESSION.excel is excel + + +def test_settings_file_id_rejects_trailing_newline(): + raw = default_settings() + raw["load"]["files"] = [{"id": "d1\n", "name": "a.csv", "sheet": ""}] + with pytest.raises(UserError): + parse_settings(raw) + + +@pytest.mark.parametrize("name, delimiter, opts", CASES[:7]) +def test_full_script_and_auto_render_agree(tmp_path, name, delimiter, opts): + from mplgui.runner import figure_summary, run_script + + opts = dict(opts) + has_header = opts.pop("has_header", True) + shutil.copy(FIXTURES / name, tmp_path / name) + loaded = L(name, delimiter, has_header, **opts) + settings = parse_settings({**default_settings(), "series": [{"x": "__idx__0", "y": "__idx__1"}]}) + script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings)) + with run_script(script.text, cwd=tmp_path) as run: + full = figure_summary(run.fig) + with run_script(script.auto_render_text(), injected={"df": loaded.df}) as run: + auto = figure_summary(run.fig) + assert full == auto diff --git a/tests/unit/test_loader.py b/tests/unit/test_loader.py index 69dbc90..6d6ad59 100644 --- a/tests/unit/test_loader.py +++ b/tests/unit/test_loader.py @@ -157,7 +157,8 @@ def test_non_numeric_fixture_loads_as_text_columns(): def test_preview_payload_shape(): df = _load("utf8.csv").df p = build_preview(df) - assert set(p) == {"columns", "rows", "totalRows", "totalColumns", "limit", "truncated"} + assert set(p) == {"columns", "rows", "totalRows", "totalColumns", "limit", "truncated", "preamble"} + assert p["preamble"] is None assert p["columns"] == ["時間", "電圧", "電流"] assert p["totalRows"] == 30 and p["totalColumns"] == 3 and p["truncated"] is False assert len(p["rows"]) == 30 and all(len(r) == 3 for r in p["rows"]) From bd1703a5ace8cb3d21fd82e725ca4a88ae952d6a Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 18:49:56 +0900 Subject: [PATCH 04/18] Add load option inputs, sheet selector, preamble view and stale-code note to the UI Co-Authored-By: Claude Opus 5.5 --- css/style.css | 37 +++++++++++++++++++++++++++++++++++++ index.html | 38 ++++++++++++++++++++++++++++++++++++++ js/bridge.js | 23 +++++++++++++++++++---- js/code-state.js | 12 +++++++++++- js/main.js | 2 ++ js/state.js | 8 ++++++++ js/ui/codeTab.js | 17 ++++++++++++++++- js/ui/dataPreview.js | 14 +++++++++++++- js/ui/forms.js | 4 ++++ js/ui/loadSection.js | 28 ++++++++++++++++++++++++++++ 10 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 js/ui/loadSection.js diff --git a/css/style.css b/css/style.css index c55598e..ad11296 100644 --- a/css/style.css +++ b/css/style.css @@ -491,6 +491,43 @@ button.secondary:hover { margin-bottom: 8px; font-size: 13px; } +#dataPreamble { + margin-bottom: 8px; + padding: 8px 10px; + border: 1px dashed var(--line); + border-radius: 8px; + background: var(--surface); + font-size: 12px; +} +#dataPreamble .preamble-title { + font-weight: 700; + margin-bottom: 4px; +} +#dataPreamble .preamble-lines { + margin: 0; + max-height: 9em; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre; + color: var(--text-muted); +} +#dataPreamble .preamble-more { + margin-top: 4px; + color: var(--text-muted); +} +.code-stale-note { + margin-bottom: 8px; + padding: 6px 10px; + border-left: 3px solid var(--status-warning); + border-radius: 6px; + background: var(--warning-bg); + color: var(--status-warning); + font-size: 12px; + font-weight: 700; +} +.code-stale-note[hidden] { + display: none; +} #codePanel { overflow: auto; } diff --git a/index.html b/index.html index d54c59f..dd08f47 100644 --- a/index.html +++ b/index.html @@ -64,6 +64,42 @@

Matplotlib GUI

+ + + +
+ + +
ファイル先頭の説明の行など。ヘッダ行より前にある行の数です。
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
@@ -82,6 +118,7 @@

Matplotlib GUI

+
ヘッダの後のデータ行を数えます。除外する行はデータ確認タブでグレーになります。
@@ -392,6 +429,7 @@

Matplotlib GUI

+
diff --git a/js/bridge.js b/js/bridge.js index 4fb8f6a..0742e06 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -5,20 +5,23 @@ // - Python の起動前に選んだファイル・変えた設定は保持され、起動後に最新の内容で1回読込・描画する。 // - Pythonコードタブのモード(code-state.js): sync は GUI の設定から生成したコードで描く。edit は利用者が編集したコードを // 「実行」したときだけ描き、GUI の変更では再描画しない(読込は行うが自動実行しない)。 -import { subscribe, toJson, getSettings } from "./state.js"; +import { subscribe, toJson, getSettings, setPath } from "./state.js"; import { addStickyWarning, removeStickyWarning, setStatus, warningTexts } from "./ui/notify.js"; import { showPlot } from "./ui/plotView.js"; import { showCodeOutput } from "./ui/codeOutput.js"; -import { getGeneratedGeneration, isEditMode, setCodeMode, setGeneratedCode } from "./code-state.js"; +import { getGeneratedCode, getGeneratedGeneration, isEditMode, setCodeMode, setCodeStale, 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"; +import { showSheets } from "./ui/loadSection.js"; import { setBusy, showProgress } from "./ui/progress.js"; import { RUNTIME_MESSAGE, watchStartup } from "./startup-progress.js"; import { FONT_FIRST_RENDER_WAIT_MS, isFontCached, loadFont } from "./font-cache.js"; const RENDER_DELAY_MS = 250; const LOAD_DELAY_MS = { file: 0, header: 0, delimiter: 450 }; +const SLOW_LOAD_PATHS = new Set(["load.delimiter", "load.skipLines", "load.comment"]); // 入力欄(打鍵のたびに読み直さない) +const SOURCE_ID = "d1"; // 読み込むファイルは1つ(複数ファイルは D6 で対応) const FONT_FAILED_MESSAGE = "日本語フォントを取得できませんでした。日本語が正しく表示されない場合があります。"; const FONT_LOADING_MESSAGE = "日本語フォントを取得中…"; const LATIN_LOADING_MESSAGE = "欧文フォントを取得中…"; @@ -212,6 +215,11 @@ const waitForFonts = async () => { } }; +// 同期モードで新しいコードを作れなかったとき、表示中のコードが最新でないことを示す(表示中のコードがあるときだけ) +const markCodeStale = () => { + if (!isEditMode() && getGeneratedCode()) setCodeStale(true); +}; + // ---- 描画 ---- const executeRender = async (generation) => { const stale = () => generation !== latestGeneration || !canRender(); @@ -240,6 +248,7 @@ const executeRender = async (generation) => { const traceback = result.error && result.error.traceback ? result.error.traceback : ""; showCodeOutput({ output: result.output, traceback, kind: traceback ? "error" : "ok" }); reportError(result.error); + markCodeStale(); } } catch (err) { if (generation === latestGeneration) reportException(err, "render"); @@ -321,6 +330,7 @@ const refreshScript = () => { } else { scriptRefreshFailed = true; reportError(result.error); + markCodeStale(); } } catch (err) { reportException(err, "script"); @@ -363,23 +373,26 @@ const doLoad = async () => { loadWarnings = []; showEncoding(undefined); reportError(installed.error); + markCodeStale(); return; } excelReady = true; setStatus(""); } const loadJson = JSON.stringify({ version: getSettings().version, load: getSettings().load }); - const result = callPython("loadFile", file.name, bytes, loadJson); + const result = callPython("loadFile", file.name, bytes, loadJson, SOURCE_ID); if (seq !== loadSeq) return; if (!result.ok) { setDataState("error"); loadWarnings = []; showEncoding(undefined); reportError(result.error); + markCodeStale(); return; } loadWarnings = warningTexts(result.warnings); showEncoding(result.encoding === undefined ? null : result.encoding); + showSheets(result.sheets, result.sheet); setColumns(result.columns); showPreview(result.preview, getSettings().plot.skipRows); setDataState("ready"); @@ -415,7 +428,9 @@ export const selectFile = (file) => { if (!file) return; selectedFile = file; loadWarnings = []; + setPath("load.files", [{ id: SOURCE_ID, name: file.name, sheet: "" }], "reconcile"); // 新しいファイルは先頭のシート(再読込は下で予約する) showFileName(file.name); + showSheets([], null); scheduleLoad(LOAD_DELAY_MS.file); }; @@ -551,7 +566,7 @@ const routeChange = (_settings, change) => { const path = change.kind === "path" ? change.path : "series"; if (path === "plot.skipRows") applySkipRows(getSettings().plot.skipRows); // 表の灰色表示は Python を呼ばず即時更新 if (path.startsWith("load.")) { - scheduleLoad(path === "load.delimiter" ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header); + scheduleLoad(SLOW_LOAD_PATHS.has(path) ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header); } else if (path.startsWith("save.")) { if (!isEditMode()) scheduleScriptRefresh(); // 画像は変わらない。保存設定を反映したコードだけ作り直す } else { diff --git a/js/code-state.js b/js/code-state.js index f9d739e..8851f2e 100644 --- a/js/code-state.js +++ b/js/code-state.js @@ -4,7 +4,7 @@ // DOM には だけ反映する。 const root = typeof document !== "undefined" ? document.documentElement : null; const listeners = new Set(); -const state = { mode: "sync", generatedCode: "", generation: null }; +const state = { mode: "sync", generatedCode: "", generation: null, stale: false }; if (root) root.dataset.codeMode = state.mode; @@ -16,6 +16,7 @@ export const getCodeMode = () => state.mode; export const isEditMode = () => state.mode === "edit"; export const getGeneratedCode = () => state.generatedCode; export const getGeneratedGeneration = () => state.generation; +export const isCodeStale = () => state.stale; export const subscribeCode = (fn) => { listeners.add(fn); @@ -26,6 +27,7 @@ export const setCodeMode = (mode) => { if (mode !== "sync" && mode !== "edit") return; if (state.mode === mode) return; state.mode = mode; + state.stale = false; // 編集モードでは「最新でない」表示は使わない(同期に戻すと再描画される) if (root) root.dataset.codeMode = mode; notify({ kind: "mode" }); }; @@ -34,5 +36,13 @@ export const setCodeMode = (mode) => { export const setGeneratedCode = (code, generation) => { state.generatedCode = code; state.generation = generation; + state.stale = false; notify({ kind: "generated" }); }; + +// 同期モードで、描画またはコードの更新に失敗して、表示中のコードが最新の設定を反映していない +export const setCodeStale = (stale) => { + if (state.stale === stale) return; + state.stale = stale; + notify({ kind: "stale" }); +}; diff --git a/js/main.js b/js/main.js index 6927914..56cab53 100644 --- a/js/main.js +++ b/js/main.js @@ -6,6 +6,7 @@ import { bindColorPanelOutsideClick } from "./ui/colorPicker.js"; import { bindCodeTab } from "./ui/codeTab.js"; import { applyStateToForm, bindForms } from "./ui/forms.js"; import { bindSaveFormatEvents, syncSaveFormatOptions } from "./ui/saveFormat.js"; +import { bindLoadSection } from "./ui/loadSection.js"; import { bindSaveSection } from "./ui/saveSection.js"; import { bindClipboard } from "./ui/clipboard.js"; import { bindSeriesEvents, renderSeriesList, setColumns, syncMarkerSizeDisplay, syncVisibility } from "./ui/series.js"; @@ -29,6 +30,7 @@ bindTabs(); bindForms(); bindSeriesEvents(); bindSaveFormatEvents(); +bindLoadSection(); bindSaveSection(); bindClipboard(); bindCodeTab(); diff --git a/js/state.js b/js/state.js index 8bca88b..2f6272a 100644 --- a/js/state.js +++ b/js/state.js @@ -41,6 +41,14 @@ export const setPath = (path, value, origin = "user") => { notify({ kind: "path", path, origin }); }; +// 読み込むファイル(load.files)の sheet を替える。通知は load.files の変更(読み直しのきっかけ) +export const setFileSheet = (id, sheet, origin = "user") => { + const file = settings.load.files.find((f) => f.id === id); + if (!file || file.sheet === sheet) return; + file.sheet = sheet; + notify({ kind: "path", path: "load.files", origin }); +}; + const findSeries = (id) => settings.series.find((s) => s.id === id); export const updateSeries = (id, patch, origin = "user") => { diff --git a/js/ui/codeTab.js b/js/ui/codeTab.js index a724819..2e559a1 100644 --- a/js/ui/codeTab.js +++ b/js/ui/codeTab.js @@ -2,7 +2,7 @@ // 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 { getCodeMode, getGeneratedCode, getGeneratedGeneration, isCodeStale, isEditMode, subscribeCode } from "../code-state.js"; import { setStatus } from "./notify.js"; const $ = (id) => document.getElementById(id); @@ -31,6 +31,18 @@ const showGenerated = () => { updateGutter(); }; +// 「最新でない」表示: data-stale="true"(#customPyCode)と #codeStaleNote +const applyStale = () => { + const stale = isCodeStale() && !isEditMode(); + const editor = $("customPyCode"); + if (editor) { + if (stale) editor.dataset.stale = "true"; + else delete editor.dataset.stale; + } + const note = $("codeStaleNote"); + if (note) note.hidden = !stale; +}; + const applyMode = () => { const edit = isEditMode(); const editor = $("customPyCode"); @@ -122,6 +134,8 @@ export const bindCodeTab = () => { setEditCodeProvider(() => (editor ? editor.value : "")); subscribeCode((_state, change) => { + applyStale(); + if (change.kind === "stale") return; if (change.kind === "mode") { applyMode(); if (!isEditMode()) showGenerated(); // 「GUI から再生成」: 直近の生成コードをすぐ表示する @@ -152,5 +166,6 @@ export const bindCodeTab = () => { }); } applyMode(); + applyStale(); updateGutter(); }; diff --git a/js/ui/dataPreview.js b/js/ui/dataPreview.js index b401031..6cb62a8 100644 --- a/js/ui/dataPreview.js +++ b/js/ui/dataPreview.js @@ -15,6 +15,18 @@ export const applySkipRows = (skipRows) => { rows.forEach((tr, idx) => tr.classList.toggle("skipped-row", idx < n)); }; +// ヘッダより前に読み飛ばした行(preamble: {lines, total})。表の上に、枠で囲んで等幅で出す +const preambleBox = (preamble) => { + if (!preamble || !(preamble.total > 0)) return null; + const box = el("div"); + box.id = "dataPreamble"; + box.appendChild(el("div", `ヘッダより前に読み飛ばした行(${preamble.total}行)`)).className = "preamble-title"; + box.appendChild(el("pre", preamble.lines.join("\n"))).className = "preamble-lines"; + const more = preamble.total - preamble.lines.length; + if (more > 0) box.appendChild(el("div", `…ほか ${more} 行`)).className = "preamble-more"; + return box; +}; + export const showPreview = (preview, skipRows = 0) => { const dataArea = document.getElementById("dataArea"); if (!dataArea) return; @@ -45,6 +57,6 @@ export const showPreview = (preview, skipRows = 0) => { const wrap = el("div"); wrap.className = "table-wrap"; wrap.appendChild(table); - dataArea.replaceChildren(meta, wrap); + dataArea.replaceChildren(...[preambleBox(preview.preamble), meta, wrap].filter(Boolean)); applySkipRows(skipRows); }; diff --git a/js/ui/forms.js b/js/ui/forms.js index 3a574d1..90ee70a 100644 --- a/js/ui/forms.js +++ b/js/ui/forms.js @@ -5,6 +5,10 @@ import { getPath, setPath } from "../state.js"; export const FIELDS = [ ["delimiter", "load.delimiter", "text"], ["hasHeader", "load.hasHeader", "checkbox"], + ["skipLines", "load.skipLines", "number"], + ["thousands", "load.thousands", "select"], + ["decimal", "load.decimal", "select"], + ["commentChar", "load.comment", "text"], ["plotType", "plot.type", "select"], ["skipRows", "plot.skipRows", "number"], ["xColumn", "plot.xColumn", "select"], diff --git a/js/ui/loadSection.js b/js/ui/loadSection.js new file mode 100644 index 0000000..9d2ab61 --- /dev/null +++ b/js/ui/loadSection.js @@ -0,0 +1,28 @@ +// データ読み込み区分: シートの選択(#sheetGroup / #sheetSelect。.xlsx のときだけ表示)。 +// 選んだシートは設定の load.files に書く(読み直しは bridge.js が load.files の変更を購読して行う)。 +import { setFileSheet } from "../state.js"; + +const SOURCE_ID = "d1"; + +// sheets: シート名の配列(xlsx 以外は空)、sheet: 読んだシート名 +export const showSheets = (sheets, sheet) => { + const group = document.getElementById("sheetGroup"); + const select = document.getElementById("sheetSelect"); + if (!group || !select) return; + const names = Array.isArray(sheets) ? sheets : []; + group.hidden = names.length === 0; + select.replaceChildren( + ...names.map((name) => { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + return option; + }), + ); + if (names.length > 0) select.value = names.includes(sheet) ? sheet : names[0]; +}; + +export const bindLoadSection = () => { + const select = document.getElementById("sheetSelect"); + if (select) select.addEventListener("change", () => setFileSheet(SOURCE_ID, select.value)); +}; From 2e74046bcf47848f0c4194009995c8e8c1d1e3ee Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 18:49:56 +0900 Subject: [PATCH 05/18] Add E2E tests and consistency cases for load options and stale-code note Co-Authored-By: Claude Opus 5.5 --- tests/e2e/helpers.py | 35 +++++ tests/e2e/test_codegen_consistency.py | 42 +++++ tests/e2e/test_load_options.py | 216 ++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 tests/e2e/test_load_options.py diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 576b366..6ed88ea 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -304,3 +304,38 @@ def wait_code_contains(page, text, timeout=60_000): page.wait_for_function( "(t) => document.getElementById('customPyCode').value.includes(t)", arg=text, timeout=timeout ) + + +# --- データ読み込みの設定(シート・ヘッダより前の行・桁区切り・小数点・コメント記号) --- +def change_load_option(page, action): + """action() で読込設定を変え、読み直し(data-load-count が増える)と再描画が済むまで待つ。""" + before = load_count(page) + action() + wait_load_count(page, before + 1) + wait_settled(page) + + +def set_load_options(page, *, delimiter=None, skip_lines=None, thousands=None, decimal=None, comment=None): + """ファイルを選ぶ前に、読込設定を入れておく(ファイルが無いので読み直しは起こらない)。""" + if delimiter is not None: + page.fill("#delimiter", delimiter) + if skip_lines is not None: + page.fill("#skipLines", str(skip_lines)) + if thousands is not None: + page.select_option("#thousands", thousands) + if decimal is not None: + page.select_option("#decimal", decimal) + if comment is not None: + page.fill("#commentChar", comment) + + +def sheet_options(page) -> list[str]: + return page.evaluate("[...document.querySelectorAll('#sheetSelect option')].map(o => o.value)") + + +def code_is_stale(page) -> bool: + """コードタブの「最新でない」表示(#customPyCode の data-stale と #codeStaleNote)。""" + attr = page.evaluate("document.getElementById('customPyCode').dataset.stale || ''") == "true" + note = page.evaluate("!document.getElementById('codeStaleNote').hidden") + assert attr == note, "data-stale と #codeStaleNote の表示が食い違っている" + return attr diff --git a/tests/e2e/test_codegen_consistency.py b/tests/e2e/test_codegen_consistency.py index f93122f..253403e 100644 --- a/tests/e2e/test_codegen_consistency.py +++ b/tests/e2e/test_codegen_consistency.py @@ -74,10 +74,38 @@ def _configure_scatter_arimo_paper2_pdf(page): page.select_option("#saveFormat", "pdf") +def _pre_preamble(page): + page.fill("#skipLines", "3") # ファイルを選ぶ前に設定する(先頭の説明の行を読み飛ばす) + + +def _pre_european(page): + page.fill("#delimiter", ";") + page.select_option("#thousands", ".") + page.select_option("#decimal", ",") + + +def _configure_xy_1(page): + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__1") + + +def _configure_second_sheet(page): + page.select_option("#sheetSelect", "二枚目") + page.wait_for_function("document.documentElement.dataset.loadCount === '2'") + wait_settled(page) + _configure_xy_1(page) + + +# 読込の設定を変えた場合: 生成スクリプトの読込部が、GUI の読み方と同じ図を作る +PRE_STEPS = {"preamble_csv": _pre_preamble, "european_csv": _pre_european} + CASES = { "line": ("growth.csv", _configure_line), "scatter": ("utf8.csv", _configure_scatter), "bar": ("categories.csv", _configure_bar), + "xlsx_second_sheet": ("multi_sheet.xlsx", _configure_second_sheet), + "preamble_csv": ("preamble.csv", _configure_xy_1), + "european_csv": ("european.csv", _configure_xy_1), "scatter_paper1": ("utf8.csv", _configure_scatter_paper1), "scatter_arimo_paper2_pdf": ("utf8.csv", _configure_scatter_arimo_paper2_pdf), } @@ -102,6 +130,8 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ fixture, configure = CASES[case] page.goto(app_url) wait_app_ready(page) + if case in PRE_STEPS: + PRE_STEPS[case](page) load_fixture(page, fixtures_dir / fixture) wait_settled(page) configure(page) @@ -127,6 +157,18 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ 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 in ("xlsx_second_sheet", "preamble_csv", "european_csv"): + assert len(browser) == 1 and browser[0]["lines"] == 1 + text = script.read_text(encoding="utf-8") + expected = { + "xlsx_second_sheet": 'sheet_name="二枚目",', + "preamble_csv": "skiprows=3,", + "european_csv": 'thousands=".",', + }[case] + assert expected in text + if case == "european_csv": + assert 'decimal=",",' in text and 'sep=";",' in text + assert browser[0]["ylim"][1] > 1000 # 1.234,5 が数値として読めている if case == "bar": assert local[0]["xticklabels"] == browser[0]["xticklabels"] assert "品目A" in browser[0]["xticklabels"][0] diff --git a/tests/e2e/test_load_options.py b/tests/e2e/test_load_options.py new file mode 100644 index 0000000..315f14b --- /dev/null +++ b/tests/e2e/test_load_options.py @@ -0,0 +1,216 @@ +"""データ取り込みの設定(D2 シート / D3 ヘッダより前の行 / D4 桁区切り・小数点・コメント)と、コードの「最新でない」表示。""" +import pytest + +from helpers import ( + change_load_option, + code_generation, + code_is_stale, + code_text, + enter_edit_mode, + image_generation, + image_summary, + load_count, + load_fixture, + open_code_tab, + plot_src, + set_load_options, + set_save_dpi, + sheet_options, + status_kind, + status_text, + wait_app_ready, + wait_code_generated, + wait_code_contains, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def start(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + return page + + +def select_xy(page, x="__idx__0", y="__idx__1"): + page.select_option("#series-s1-x", x) + page.select_option("#series-s1-y", y) + wait_settled(page) + + +def test_second_sheet_changes_columns_plot_and_code(start, fixtures_dir): + page = start + assert not page.is_visible("#sheetGroup") + load_fixture(page, fixtures_dir / "multi_sheet.xlsx") + wait_settled(page) + assert page.is_visible("#sheetGroup") and sheet_options(page) == ["Sheet1", "二枚目"] + assert page.input_value("#sheetSelect") == "Sheet1" + assert "時間 [0]" in page.inner_text("#series-s1-y") + before = plot_src(page) + change_load_option(page, lambda: page.select_option("#sheetSelect", "二枚目")) + assert page.input_value("#sheetSelect") == "二枚目" + options = page.inner_text("#series-s1-y") + assert "z [2]" in options and "時間 [0]" not in options + select_xy(page) + wait_code_generated(page) + open_code_tab(page) + assert 'sheet_name="二枚目",' in code_text(page) + assert plot_src(page) != before + # 新しいファイルを選ぶと先頭のシートに戻る + load_fixture(page, fixtures_dir / "preamble.xlsx") + wait_settled(page) + assert page.input_value("#sheetSelect") == "データ" and sheet_options(page) == ["データ", "説明つき"] + # CSV ではシートの欄は出ない + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + assert not page.is_visible("#sheetGroup") + + +def test_preamble_needs_skip_lines_and_is_shown(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "preamble.csv")) + page.wait_for_function("document.documentElement.dataset.dataState === 'error'") + assert status_kind(page) == "error" and "ヘッダより前に読み飛ばす行数" in status_text(page) + change_load_option(page, lambda: page.fill("#skipLines", "3")) + assert status_kind(page) == "ok" + page.click("#tabDataBtn") + box = page.inner_text("#dataPreamble") + assert "ヘッダより前に読み飛ばした行(3行)" in box and "装置: テスト用ロガー" in box and "備考: この行はデータではありません" in box + assert "…ほか" not in box + assert "時間 [0]" in page.inner_text("#series-s1-y") + # 0 に戻すと説明の行は出ない(読み込みはまた失敗する) + page.fill("#skipLines", "0") + page.wait_for_function("document.documentElement.dataset.dataState === 'error'") + + +def test_preamble_box_for_xlsx_and_overflow(start, fixtures_dir): + page = start + set_load_options(page, skip_lines=2) + load_fixture(page, fixtures_dir / "multi_sheet.xlsx") # 先頭シートは見出しが1行目なので、2行読み飛ばすと見出しがデータ行になる + wait_settled(page) + page.click("#tabDataBtn") + assert "ヘッダより前に読み飛ばした行(2行)" in page.inner_text("#dataPreamble") + change_load_option(page, lambda: page.select_option("#sheetSelect", "二枚目")) + assert "ヘッダより前に読み飛ばした行(2行)" in page.inner_text("#dataPreamble") + + +def test_european_numbers_are_plotted(start, fixtures_dir): + page = start + set_load_options(page, delimiter=";", thousands=".", decimal=",") + load_fixture(page, fixtures_dir / "european.csv") + wait_settled(page) + select_xy(page) + summary = image_summary(page) + assert summary[0]["lines"] == 1 and summary[0]["ylim"][1] > 1000 + + +def test_european_numbers_without_settings_get_a_hint(start, fixtures_dir): + page = start + set_load_options(page, delimiter=";") + load_fixture(page, fixtures_dir / "european.csv") + wait_settled(page) + select_xy(page) + # 数値が1つも読めないので、案内つきのエラーになる + assert status_kind(page) == "error" and "「小数点」をカンマ" in status_text(page), status_text(page) + change_load_option(page, lambda: page.select_option("#decimal", ",")) + assert "小数点" not in status_text(page) + + +def test_comment_character(start, fixtures_dir): + page = start + set_load_options(page, comment="#") + load_fixture(page, fixtures_dir / "comments.csv") + wait_settled(page) + select_xy(page) + summary = image_summary(page) + assert summary[0]["lines"] == 1 + page.click("#tabDataBtn") + assert "行数: 10" in page.inner_text("#dataArea") + + +def test_thousands_warning_hint_and_fix(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "thousands.csv") + wait_settled(page) + select_xy(page, "__idx__0", "__idx__1") + assert status_kind(page) == "error" and "「桁区切り」をカンマ" in status_text(page), status_text(page) + change_load_option(page, lambda: page.select_option("#thousands", ",")) + assert "桁区切り" not in status_text(page) + assert image_summary(page)[0]["ylim"][1] > 10000 + + +def test_code_follows_load_settings_in_sync_mode(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + assert "skiprows=0," in code_text(page) and "thousands=None," in code_text(page) + for action, expected in ( + (lambda: page.fill("#skipLines", "1"), "skiprows=1,"), + (lambda: page.select_option("#decimal", ","), 'decimal=",",'), + (lambda: page.select_option("#thousands", "."), 'thousands=".",'), + (lambda: page.fill("#commentChar", "%"), 'comment="%",'), + ): + change_load_option(page, action) + wait_code_generated(page) + assert expected in code_text(page) + assert code_generation(page) == image_generation(page) + + +def test_edit_mode_keeps_code_when_load_settings_change(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + enter_edit_mode(page) + code = code_text(page) + count = load_count(page) + page.fill("#skipLines", "1") + page.wait_for_function(f"Number(document.documentElement.dataset.loadCount) > {count}") + assert code_text(page) == code + assert "編集中" in status_text(page) + + +def test_stale_note_for_render_error_and_recovery(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + assert not code_is_stale(page) + good = code_text(page) + page.fill("#subplotLeft", "0.9") + page.fill("#subplotRight", "0.1") + page.fill("#subplotBottom", "0.1") + page.fill("#subplotTop", "0.9") + wait_settled(page) + assert status_kind(page) == "error" + page.wait_for_function("document.getElementById('customPyCode').dataset.stale === 'true'") + assert code_is_stale(page) and "最新の設定を反映していません" in page.inner_text("#codeStaleNote") + assert code_text(page) == good # 表示中のコードは変わらない + page.fill("#subplotRight", "0.95") + wait_settled(page) + wait_code_generated(page) + assert not code_is_stale(page) + assert "subplots_adjust(left=0.9" in code_text(page) + + +def test_stale_note_for_script_refresh_error_and_edit_mode(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + set_save_dpi(page, 99999) # 保存設定の不正: コードの更新だけが失敗する + page.wait_for_function("document.getElementById('customPyCode').dataset.stale === 'true'") + assert code_is_stale(page) and status_kind(page) == "error" + set_save_dpi(page, 150) + wait_code_contains(page, "dpi=150") + assert not code_is_stale(page) + set_save_dpi(page, 99999) + page.wait_for_function("document.getElementById('customPyCode').dataset.stale === 'true'") + enter_edit_mode(page) + assert not code_is_stale(page) From c996b8ea142b4e35c6b018493b030b391afd1cad Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 18:52:23 +0900 Subject: [PATCH 06/18] Support .tsv and mark pasted sources in the loader and generated script Co-Authored-By: Claude Opus 5.5 --- py/mplgui/api.py | 7 ++- py/mplgui/codegen.py | 13 ++++- py/mplgui/loader.py | 10 ++-- tests/unit/test_paste_tsv.py | 108 +++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_paste_tsv.py diff --git a/py/mplgui/api.py b/py/mplgui/api.py index 786ab54..42705b1 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -6,6 +6,7 @@ from __future__ import annotations import base64 +import dataclasses import hashlib import json import struct @@ -152,9 +153,11 @@ def load_file_json(name: str, data: bytes, load_settings_json: str, source_id: s ) SESSION.df = loaded.df SESSION.filename = name - SESSION.source = loaded.source - SESSION.write_upload(name, raw) source = loaded.source + if pasted: + source = dataclasses.replace(source, pasted=True) + SESSION.source = source + SESSION.write_upload(name, raw) return { "ok": True, "encoding": loaded.encoding, diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py index 1c22fca..375574e 100644 --- a/py/mplgui/codegen.py +++ b/py/mplgui/codegen.py @@ -156,9 +156,13 @@ def _label_size_expr(font_size: float) -> str: def _emit_header(b: _Builder, source: SourceInfo, latin_font: str = "default") -> None: name = comment_text(source.filename) latin = LATIN_FONTS.get(latin_font) + if source.pasted: + usage = f"# 使い方: 貼り付けたデータを保存した「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。" + else: + usage = f"# 使い方: データファイル「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。" b.add( "# matplotlib GUI が生成したスクリプト", - f"# 使い方: データファイル「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。", + usage, "# 必要なライブラリ: pandas, matplotlib, numpy", "import matplotlib.pyplot as plt", ) @@ -209,12 +213,17 @@ def _font_list_literal() -> str: "thousands": "桁区切り(文字列のセルだけに効く。None: なし)", "decimal": "小数点(文字列のセルだけに効く)", } -_KIND_NOTES = {"csv": "CSV(.csv)", "txt": "テキスト(.txt)", "xlsx": "Excel(.xlsx)"} +_KIND_NOTES = {"csv": "CSV(.csv)", "txt": "テキスト(.txt)", "tsv": "TSV(.tsv)", "xlsx": "Excel(.xlsx)"} def _emit_load(b: _Builder, source: SourceInfo) -> None: start = b.section("1. データの読み込み") xlsx = source.kind == "xlsx" + if source.pasted: + b.add( + f"# 貼り付けたデータ: 「データ読み込み」の「貼り付けたデータを保存」で {comment_text(source.filename)} を保存し、" + "このスクリプトと同じフォルダに置いてください。" + ) b.add(f"# 形式: {_KIND_NOTES.get(source.kind, comment_text(source.kind))}") if xlsx: b.add("# 読み込みには openpyxl が必要です(pip install openpyxl)") diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py index 0161fc9..ad6c4bf 100644 --- a/py/mplgui/loader.py +++ b/py/mplgui/loader.py @@ -12,7 +12,7 @@ from .errors import UserError -SUPPORTED = {".xlsx", ".csv", ".txt"} +SUPPORTED = {".xlsx", ".csv", ".txt", ".tsv"} PREVIEW_ROWS = 100 PREAMBLE_LINES = 20 # 前置き行として画面に出す最大の行数 @@ -22,7 +22,7 @@ class SourceInfo: """読み込んだファイルの情報。codegen が生成スクリプトの「データの読み込み」部に使う。""" filename: str - kind: str # "csv" | "txt" | "xlsx" + kind: str # "csv" | "txt" | "tsv" | "xlsx" encoding: str | None = None # xlsx は None separator: str | None = None # デコード後の区切り文字(csv / txt)。xlsx は None has_header: bool = True @@ -33,7 +33,7 @@ class SourceInfo: comment: str | None = None # None = なし sheets: tuple[str, ...] = () # xlsx のシート一覧 datetime_columns: tuple = () # D5 用(未使用) - pasted: bool = False # D1 用(未使用) + pasted: bool = False # 貼り付けたデータ(pasted_data.tsv)。生成コードに保存の案内を書く def read_kwargs(self) -> dict: """pd.read_csv / pd.read_excel に渡す引数(文字コードとファイルを除く)。順序は生成スクリプトの並びと同じ。 @@ -91,6 +91,8 @@ def decode_delimiter(delimiter: str, suffix: str) -> str: ) from exc if suffix == ".csv": return "," + if suffix == ".tsv": + return "\t" return r"\s+" @@ -215,7 +217,7 @@ def load_file( ) -> LoadedData: suffix = Path(filename).suffix.lower() if suffix not in SUPPORTED: - raise UserError("対応していない拡張子です。xlsx/csv/txtを選択してください。", field="入力ファイル") + raise UserError("対応していない拡張子です。xlsx/csv/txt/tsvを選択してください。", field="入力ファイル") used_encoding: str | None = None sep: str | None = None diff --git a/tests/unit/test_paste_tsv.py b/tests/unit/test_paste_tsv.py new file mode 100644 index 0000000..f56ccc6 --- /dev/null +++ b/tests/unit/test_paste_tsv.py @@ -0,0 +1,108 @@ +"""D1: .tsv の読込と、貼り付けたデータ(pasted_data.tsv)の扱い。""" +import json +import os +import tempfile +from pathlib import Path + +import pandas as pd +import pytest + +from mplgui import api +from mplgui.codegen import generate_script +from mplgui.dataprep import plan_plot +from mplgui.errors import UserError +from mplgui.loader import SourceInfo, load_file +from mplgui.settings import default_settings, parse_settings + +TSV = "時間\t電圧\r\n0\t1.5\r\n1\t2.5\r\n2\t3.5\r\n".replace("\r\n", "\n").encode("utf-8") + + +def test_tsv_default_delimiter_is_tab(): + r = load_file(TSV, "a.tsv", "", True) + assert r.df.shape == (3, 2) and r.source.kind == "tsv" and r.source.separator == "\t" + assert list(r.df.columns) == ["時間", "電圧"] + + +def test_tsv_values_with_spaces_stay_in_one_cell(): + r = load_file("名前\t値\nA B\t1\nC D\t2\n".encode(), "a.tsv", "", True) + assert list(r.df.iloc[:, 0]) == ["A B", "C D"] + + +def test_tsv_explicit_delimiter_overrides(): + r = load_file(b"a;b\n1;2\n", "a.tsv", ";", True) + assert r.df.shape == (1, 2) and r.source.separator == ";" + + +def test_unsupported_extension_message_mentions_tsv(): + with pytest.raises(UserError) as e: + load_file(b"x", "a.json", "", True) + assert "tsv" in e.value.message + + +def _script(source, df): + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}) + return generate_script(settings, source, plan_plot(df, settings)) + + +def test_pasted_source_adds_comment_and_usage(): + r = load_file(TSV, "pasted_data.tsv", "", True) + pasted = SourceInfo(**{**r.source.__dict__, "pasted": True}) + text = _script(pasted, r.df).text + assert "# 貼り付けたデータ: 「データ読み込み」の「貼り付けたデータを保存」で pasted_data.tsv を保存し、このスクリプトと同じフォルダに置いてください。" in text + assert "貼り付けたデータを保存した「pasted_data.tsv」" in text + assert "# 形式: TSV(.tsv)" in text and r'sep="\t"' in text + plain = _script(r.source, r.df).text + assert "貼り付けたデータ" not in plain + + +def test_pasted_comment_is_safe_with_hostile_filename(): + r = load_file(TSV, "x.tsv", "", True) + src = SourceInfo(**{**r.source.__dict__, "filename": "a\nimport os\nos.system('x').tsv", "pasted": True}) + text = _script(src, r.df).text + compile(text, "s.py", "exec") + assert "\nimport os\nos" not in text + + +def test_pasted_read_section_reproduces_loader(tmp_path): + r = load_file(TSV, "pasted_data.tsv", "", True) + src = SourceInfo(**{**r.source.__dict__, "pasted": True}) + script = _script(src, r.df) + (tmp_path / "pasted_data.tsv").write_bytes(TSV) + start, end = script.load_lines + code = "import pandas as pd\n" + "\n".join(script.text.split("\n")[start - 1:end]) + ns, cwd = {}, Path.cwd() + os.chdir(tmp_path) + try: + exec(compile(code, "load.py", "exec"), ns) + finally: + os.chdir(cwd) + pd.testing.assert_frame_equal(ns["df"], r.df) + + +@pytest.fixture +def session(): + api.SESSION.forget() + with tempfile.TemporaryDirectory() as d: + api.SESSION.workdir = Path(d) + yield api.SESSION + api.SESSION.forget() + api.SESSION.workdir = None + + +def _load(pasted): + s = {"version": 3, "load": default_settings()["load"]} + return json.loads(api.load_file_json("pasted_data.tsv", TSV, json.dumps(s), "d1", pasted)) + + +def test_api_pasted_load_marks_source_and_writes_file(session): + r = _load(True) + assert r["ok"] and r["encoding"] == "utf-8" and len(r["columns"]) == 2 + assert session.source.pasted is True and session.source.kind == "tsv" + assert (session.workdir / "pasted_data.tsv").read_bytes() == TSV + code = json.loads(api.script_json(json.dumps(default_settings())))["code"] + assert "貼り付けたデータ" in code + + +def test_api_not_pasted_by_default(session): + r = _load(False) + assert r["ok"] and session.source.pasted is False From 2da7056f6733ed9c53c9a70b9d5e3a0b459b80a4 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 19:04:54 +0900 Subject: [PATCH 07/18] Add page-wide file drop, table paste and pasted-data save, with E2E and consistency tests Co-Authored-By: Claude Opus 5.5 --- css/style.css | 52 +++++ index.html | 15 +- js/bridge.js | 25 ++- js/main.js | 2 + js/ui/dropPaste.js | 154 +++++++++++++++ js/ui/fileInfo.js | 6 + tests/e2e/helpers.py | 48 +++++ tests/e2e/test_codegen_consistency.py | 28 ++- tests/e2e/test_drop_paste.py | 274 ++++++++++++++++++++++++++ 9 files changed, 594 insertions(+), 10 deletions(-) create mode 100644 js/ui/dropPaste.js create mode 100644 tests/e2e/test_drop_paste.py diff --git a/css/style.css b/css/style.css index ad11296..735ad9e 100644 --- a/css/style.css +++ b/css/style.css @@ -910,3 +910,55 @@ button.series-color-chip.active { font-size: 13px; white-space: nowrap; } + +/* 表の貼り付け欄とファイルのドロップ */ +.paste-area { + width: 100%; + box-sizing: border-box; + resize: vertical; + padding: 8px; + border: 2px dashed var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-size: 13px; +} +.paste-area:focus { + border-color: var(--accent); + outline: none; +} +#savePastedBtn { + margin-top: 8px; +} +.drop-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: stretch; + justify-content: center; + padding: 24px; + box-sizing: border-box; + background: var(--busy-bg); +} +.drop-overlay[hidden] { + display: none; +} +.drop-zones { + display: flex; + flex: 1; + gap: 24px; +} +.drop-zone { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border: 3px dashed var(--accent); + border-radius: 16px; + background: var(--card); + color: var(--accent); + font-size: 22px; + font-weight: 700; + text-align: center; +} diff --git a/index.html b/index.html index dd08f47..699e284 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,11 @@ +
@@ -451,6 +458,10 @@

Matplotlib GUI

+
+ + +
@@ -104,8 +116,11 @@ const buildCard = (series, plotType) => { item.className = "series-item"; item.dataset.seriesId = series.id; item.innerHTML = cardHtml(series, plotType); - fillSelect(item.querySelector(".series-x"), xOptions(), series.x); - fillSelect(item.querySelector(".series-y"), yOptions(), series.y); + const columns = columnsOf(series); + fillSelect(item.querySelector(".series-x"), xOptions(columns), series.x); + fillSelect(item.querySelector(".series-y"), yOptions(columns), series.y); + fillSelect(item.querySelector(".series-source"), sourceOptions(), sourceIdOf(series)); + item.querySelector(".series-source-wrap").hidden = !hasManySources(); item.querySelector(".series-line-style").value = series.lineStyle; bindSeriesColorControls(item, (color) => updateSeries(series.id, { color })); item.querySelector(".remove-series").addEventListener("click", () => removeSeries(series.id)); @@ -120,6 +135,7 @@ export const syncVisibility = () => { for (const group of document.querySelectorAll("#seriesList .series-x-wrap")) { group.style.display = usePerSeriesX ? "block" : "none"; } + for (const wrap of document.querySelectorAll("#seriesList .series-source-wrap")) wrap.hidden = !hasManySources(); const dateGroup = document.getElementById("xDateFormatGroup"); if (dateGroup) dateGroup.hidden = !usesDatetimeX(); const hasY2 = hasSecondaryAxis(); @@ -161,23 +177,44 @@ export const renderSeriesList = () => { const refreshXColumnSelect = () => { const select = document.getElementById("xColumn"); - if (select) fillSelect(select, xOptions(), getSettings().plot.xColumn); + const { series, plot } = getSettings(); + if (select) fillSelect(select, xOptions(series[0] ? columnsOf(series[0]) : []), plot.xColumn); // 棒グラフの X は系列1のデータ元の列 +}; + +// ファイルの追加・削除などで、データ元の選択肢と各系列の列の選択肢を描き直す +export const refreshSources = () => { + refreshXColumnSelect(); + renderSeriesList(); +}; + +// 1つのデータ元の列の選択肢を更新する(読込のたびに呼ぶ)。 +export const setSourceColumns = (id, newColumns) => { + columnsById.set(id, newColumns); }; -// 読込後の列の選択肢を更新する。もう存在しない列の選択は解除する(再描画は呼び出し側が行う)。 -export const setColumns = (newColumns) => { - columns = newColumns; - const valid = new Set(columns.map((c) => c.value)); +export const dropSourceColumns = (keepIds) => { + const keep = new Set(keepIds); + for (const id of Array.from(columnsById.keys())) if (!keep.has(id)) columnsById.delete(id); +}; + +// 読込後の整合: もう存在しない列の選択は解除する(再描画は呼び出し側が行う)。列の選択肢が分かっているデータ元の系列だけを見る。 +export const reconcileColumns = () => { const settings = getSettings(); - if (settings.plot.xColumn && !valid.has(settings.plot.xColumn)) setPath("plot.xColumn", "", "reconcile"); + const first = settings.series[0]; + const firstColumns = first ? columnsById.get(sourceIdOf(first)) : null; + if (settings.plot.xColumn && firstColumns && !firstColumns.some((c) => c.value === settings.plot.xColumn)) { + setPath("plot.xColumn", "", "reconcile"); + } for (const s of settings.series) { + const known = columnsById.get(sourceIdOf(s)); + if (!known) continue; + const valid = new Set(known.map((c) => c.value)); const patch = {}; if (s.x && !valid.has(s.x)) patch.x = ""; if (s.y && !valid.has(s.y)) patch.y = ""; if (Object.keys(patch).length) updateSeries(s.id, patch, "reconcile"); } - refreshXColumnSelect(); - renderSeriesList(); + refreshSources(); }; const FIELD_BY_CLASS = [ @@ -190,11 +227,25 @@ const FIELD_BY_CLASS = [ ["series-use-y2", "secondaryAxis", "checkbox"], ]; +// データ元を替えたら、X / Y は自動に戻す(別のファイルの列番号は意味が変わる)。系列1なら棒グラフの X列も戻す +const onSourceChange = (seriesId, source) => { + const { series, plot } = getSettings(); + const current = series.find((s) => s.id === seriesId); + if (!current || sourceIdOf(current) === source) return; + if (series[0] && series[0].id === seriesId && plot.xColumn) setPath("plot.xColumn", ""); + updateSeries(seriesId, { source, x: "", y: "" }); + refreshSources(); +}; + const onSeriesInput = (event) => { const target = event.target; if (!target || !target.matches || !target.matches("input, select")) return; const item = target.closest(".series-item"); if (!item) return; + if (target.classList.contains("series-source")) { + onSourceChange(item.dataset.seriesId, target.value); + return; + } const entry = FIELD_BY_CLASS.find(([cls]) => target.classList.contains(cls)); if (!entry) return; const [, field, kind] = entry; diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 3e84cc9..a7f0a5a 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -387,3 +387,34 @@ def paste_text(page, text, selector="#pasteArea") -> bool: EXCEL_TABLE = "時間\t電圧\t電流\r\n0\t0\t1.5\r\n1\t0.5\t1.4\r\n2\t0.9\t1.1\r\n3\t1.0\t0.8\r\n" + + +# --- 複数ファイル(D6) --- +def add_files(page, paths, selector="#addFileInput", timeout=120_000): + """ファイルを追加(または selector で置き換え)し、読み込みと描画が済むまで待つ。""" + before = load_count(page) + page.set_input_files(selector, [str(p) for p in paths]) + wait_load_count(page, before + 1, timeout=timeout) + wait_settled(page, timeout=timeout) + + +def file_row_ids(page) -> list[str]: + """一覧(#fileList)の行の id(file-d1 など)。""" + return page.evaluate("[...document.querySelectorAll('#fileList .file-row')].map(e => e.id)") + + +def file_row_states(page) -> dict[str, str]: + return page.evaluate("Object.fromEntries([...document.querySelectorAll('#fileList .file-row')].map(e => [e.id, e.dataset.state]))") + + +def settings_files(page) -> list[dict]: + """画面のファイル一覧に対応する、データ元セレクト(#previewSource)の選択肢の値。""" + return page.evaluate("[...document.querySelectorAll('#previewSource option')].map(o => o.value)") + + +def add_series_with_source(page, source, y="__idx__1", series_id="s2"): + """系列を追加し、データ元と Y 列を選ぶ。""" + page.click("#addSeriesBtn") + page.select_option(f"#series-{series_id}-source", source) + page.select_option(f"#series-{series_id}-y", y) + wait_settled(page) diff --git a/tests/e2e/test_codegen_consistency.py b/tests/e2e/test_codegen_consistency.py index 25f9607..90047b6 100644 --- a/tests/e2e/test_codegen_consistency.py +++ b/tests/e2e/test_codegen_consistency.py @@ -12,6 +12,8 @@ import pytest from helpers import ( + add_files, + add_series_with_source, image_summary, load_fixture, open_code_tab, @@ -108,6 +110,19 @@ def _configure_second_sheet(page): _configure_xy_1(page) +def _configure_two_csv(page): + page.select_option("#series-s1-y", "__idx__1") + add_series_with_source(page, "d2", "__idx__2") + + +def _configure_csv_and_second_sheet(page): + page.select_option("#file-d2-sheet", "二枚目") + page.wait_for_function("document.documentElement.dataset.loadCount === '3'") + wait_settled(page) + page.select_option("#series-s1-y", "__idx__1") + add_series_with_source(page, "d2", "__idx__2") + + # 読込の設定を変えた場合: 生成スクリプトの読込部が、GUI の読み方と同じ図を作る PRE_STEPS = {"preamble_csv": _pre_preamble, "european_csv": _pre_european} @@ -128,6 +143,8 @@ def _paste_fixture(page, fixtures_dir): "xlsx_second_sheet": ("multi_sheet.xlsx", _configure_second_sheet), "datetime_axis": ("datetime.csv", _configure_datetime_line), "xlsx_datetime": ("datetime.xlsx", _configure_xlsx_datetime), + "two_csv_files": (("utf8.csv", "growth.csv"), _configure_two_csv), + "csv_and_xlsx_second_sheet": (("utf8.csv", "multi_sheet.xlsx"), _configure_csv_and_second_sheet), "preamble_csv": ("preamble.csv", _configure_xy_1), "european_csv": ("european.csv", _configure_xy_1), "scatter_paper1": ("utf8.csv", _configure_scatter_paper1), @@ -159,7 +176,11 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ if fixture is None: _paste_fixture(page, fixtures_dir) else: - load_fixture(page, fixtures_dir / fixture) + names = fixture if isinstance(fixture, tuple) else (fixture,) + load_fixture(page, fixtures_dir / names[0]) + wait_settled(page) + for extra in names[1:]: + add_files(page, [fixtures_dir / extra]) wait_settled(page) configure(page) wait_settled(page) @@ -180,7 +201,8 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ pasted_dl.value.save_as(tmp_path / pasted_dl.value.suggested_filename) assert pasted_dl.value.suggested_filename == "pasted_data.tsv" else: - shutil.copy(fixtures_dir / fixture, tmp_path / fixture) # データは元のファイル名でスクリプトの隣に置く + for name in fixture if isinstance(fixture, tuple) else (fixture,): + shutil.copy(fixtures_dir / name, tmp_path / name) # データは元のファイル名でスクリプトの隣に置く local, stderr = _run_in_cpython(script, tmp_path) assert len(local) == len(browser) @@ -190,6 +212,10 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ 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 in ("two_csv_files", "csv_and_xlsx_second_sheet"): + text = script.read_text(encoding="utf-8") + assert browser[0]["lines"] == 2 and "DATA_FILE_1" in text and "DATA_FILE_2" in text and "df2.iloc[:, 2]" in text + assert ('sheet_name="二枚目",' in text) == (case == "csv_and_xlsx_second_sheet") if case in ("xlsx_second_sheet", "preamble_csv", "european_csv"): assert len(browser) == 1 and browser[0]["lines"] == 1 text = script.read_text(encoding="utf-8") diff --git a/tests/e2e/test_drop_paste.py b/tests/e2e/test_drop_paste.py index 8215231..6f426b0 100644 --- a/tests/e2e/test_drop_paste.py +++ b/tests/e2e/test_drop_paste.py @@ -99,12 +99,12 @@ def test_text_drag_does_not_show_overlay(start): assert page.get_attribute("html", "data-data-state") == "none" -def test_drop_two_files_loads_first_with_warning(start, fixtures_dir): +def test_drop_two_files_loads_both(start, fixtures_dir): page = start drop_and_wait(page, [fixture_file(fixtures_dir, "utf8.csv"), fixture_file(fixtures_dir, "growth.csv")]) - assert "utf8.csv" in page.inner_text("#fileNameLabel") - assert "複数のファイルがドロップされました。先頭の「utf8.csv」だけを読み込みました。" in status_warnings(page) - assert status_kind(page) == "warning" + assert "utf8.csv ほか 1 件" in page.inner_text("#fileNameLabel") + assert page.locator("#fileList .file-row").count() == 2 + assert status_warnings(page) == [] def test_drop_on_code_textarea_loads_instead_of_navigating(start, fixtures_dir): diff --git a/tests/e2e/test_multi_files.py b/tests/e2e/test_multi_files.py new file mode 100644 index 0000000..7780413 --- /dev/null +++ b/tests/e2e/test_multi_files.py @@ -0,0 +1,288 @@ +"""D6: 複数ファイル(追加・置き換え・削除、データ元、データ確認の切替、一部の失敗、編集モード、ドロップ)。""" +import pytest + +from helpers import ( + add_files, + add_series_with_source, + code_text, + drop_files, + enter_edit_mode, + file_row_ids, + file_row_states, + image_summary, + load_count, + load_fixture, + open_code_tab, + plot_src, + run_edited_code, + status_kind, + status_text, + status_warnings, + wait_app_ready, + wait_code_generated, + wait_data_ready, + wait_load_count, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def start(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + return page + + +def two_files(page, fixtures_dir, first="utf8.csv", second="growth.csv"): + load_fixture(page, fixtures_dir / first) + wait_settled(page) + add_files(page, [fixtures_dir / second]) + + +def test_single_file_display_has_no_list_or_source_select(start, fixtures_dir): + page = start + assert not page.is_visible("#fileList") and not page.is_visible("#previewSource") + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + assert not page.is_visible("#fileList") and not page.is_visible("#series-s1-source") + assert "UTF-8" in page.inner_text("#encodingLabel") and page.inner_text("#fileNameLabel") == "utf8.csv" + assert page.get_attribute("#fileInput", "multiple") is not None and page.get_attribute("#addFileInput", "multiple") is not None + + +def test_add_second_file_two_series_from_different_files(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + assert file_row_ids(page) == ["file-d1", "file-d2"] + assert file_row_states(page) == {"file-d1": "ready", "file-d2": "ready"} + assert page.inner_text("#fileNameLabel") == "utf8.csv ほか 1 件" + assert page.inner_text("#encodingLabel") == "" and not page.is_visible("#sheetGroup") + assert "データ1" in page.inner_text("#file-d1") and "growth.csv" in page.inner_text("#file-d2") + assert page.is_visible("#series-s1-source") + assert page.inner_text("#series-s1-source") == "データ1: utf8.csv\nデータ2: growth.csv" + assert page.get_attribute("html", "data-data-state") == "ready" + add_series_with_source(page, "d2", "__idx__2") + wait_code_generated(page) + summary = image_summary(page) + assert summary[0]["lines"] == 2 + # 系列2のY列の選択肢は growth.csv の列 + assert "指数 [1]" in page.inner_text("#series-s2-y") and "電圧" not in page.inner_text("#series-s2-y") + assert "電圧 [1]" in page.inner_text("#series-s1-y") + open_code_tab(page) + text = code_text(page) + for needle in ("DATA_FILE_1", "DATA_FILE_2", "df2.iloc[:, 2]", "df1.iloc[:, 0]"): + assert needle in text + assert "「utf8.csv」「growth.csv」" in text + + +def test_changing_series_source_resets_columns(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__2") + page.select_option("#series-s1-source", "d2") + assert page.input_value("#series-s1-x") == "" and page.input_value("#series-s1-y") == "" + wait_settled(page) + assert "指数 [1]" in page.inner_text("#series-s1-y") + + +def test_preview_source_switches_the_table(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + assert page.is_visible("#previewSource") is False # データ確認タブを開くまでは見えない + page.click("#tabDataBtn") + assert page.is_visible("#previewSource") + assert "電圧" in page.inner_text("#dataArea") + page.select_option("#previewSource", "d2") + assert "指数" in page.inner_text("#dataArea") and "電圧" not in page.inner_text("#dataArea") + page.fill("#skipRows", "2") + assert page.locator("#dataArea tbody tr.skipped-row").count() == 2 + + +def test_remove_file_resets_series_and_returns_to_single_display(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + add_series_with_source(page, "d2", "__idx__2") + page.click("#file-d2-remove") + wait_settled(page) + assert not page.is_visible("#fileList") and not page.is_visible("#series-s1-source") + assert page.inner_text("#fileNameLabel") == "utf8.csv" and "UTF-8" in page.inner_text("#encodingLabel") + assert page.input_value("#series-s2-y") == "" + assert "データ2(growth.csv)を削除したため、系列2の列を自動に戻しました。" in status_warnings(page) + assert image_summary(page)[0]["lines"] == 2 # 系列2は先頭ファイルの自動列で描かれる + page.click("#tabCodeBtn") + wait_code_generated(page) + assert "DATA_FILE =" in code_text(page) and "DATA_FILE_2" not in code_text(page) + + +def test_remove_first_file_resets_series_using_it(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + page.select_option("#series-s1-y", "__idx__2") + wait_settled(page) + page.click("#file-d1-remove") + wait_settled(page) + assert page.inner_text("#fileNameLabel") == "growth.csv" + assert page.input_value("#series-s1-y") == "" + assert "データ1(utf8.csv)を削除したため、系列1の列を自動に戻しました。" in status_warnings(page) + + +def test_replace_with_two_files_via_file_input(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + add_series_with_source(page, "d2", "__idx__2") + before = load_count(page) + page.set_input_files("#fileInput", [str(fixtures_dir / "categories.csv"), str(fixtures_dir / "tab.txt")]) + wait_load_count(page, before + 1) + wait_settled(page) + assert file_row_ids(page) == ["file-d1", "file-d2"] + assert "categories.csv" in page.inner_text("#file-d1") and "tab.txt" in page.inner_text("#file-d2") + assert page.input_value("#series-s2-source") == "d1" # データ元は先頭に戻る + + +def test_same_name_file_replaces_the_entry(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + add_files(page, [fixtures_dir / "growth.csv"]) + assert file_row_ids(page) == ["file-d1", "file-d2"] + add_files(page, [fixtures_dir / "tab.txt"]) + assert file_row_ids(page) == ["file-d1", "file-d2", "file-d3"] + + +def test_more_than_ten_files_warns(start, fixtures_dir): + page = start + files = [(f"f{i}.csv", b"a,b\n1,2\n3,4\n5,7\n") for i in range(12)] + before = load_count(page) + drop_files(page, files) + wait_load_count(page, before + 1) + wait_settled(page) + assert len(file_row_ids(page)) == 10 + assert "読み込めるファイルは10個までです。先頭から10個を読み込みました。" in status_warnings(page) + + +def test_drop_add_and_replace_zones(start, fixtures_dir): + page = start + csv = lambda name: (name, (fixtures_dir / name).read_bytes()) # noqa: E731 + before = load_count(page) + drop_files(page, [csv("utf8.csv")]) + wait_load_count(page, before + 1) + wait_settled(page) + from helpers import drag_event, drop_overlay_visible + + drag_event(page, "dragenter", "body", [csv("growth.csv")]) + assert drop_overlay_visible(page) + assert page.is_visible("#dropReplace") and page.is_visible("#dropAdd") and not page.is_visible("#dropLoad") + drag_event(page, "dragover", "#dropAdd", [csv("growth.csv")]) + assert "drop-zone-active" in page.get_attribute("#dropAdd", "class") + drag_event(page, "drop", "#dropAdd", [csv("growth.csv")]) + wait_load_count(page, before + 2) + wait_settled(page) + assert file_row_ids(page) == ["file-d1", "file-d2"] + drag_event(page, "dragenter", "body", [csv("tab.txt")]) + drag_event(page, "drop", "#dropReplace", [csv("tab.txt")]) + wait_load_count(page, before + 3) + wait_settled(page) + assert file_row_ids(page) == [] and page.inner_text("#fileNameLabel") == "tab.txt" + + +def test_one_file_failing_keeps_the_other_and_recovers(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + before = load_count(page) + page.set_input_files("#addFileInput", [str(fixtures_dir / "growth.csv")]) + wait_load_count(page, before + 1) + wait_settled(page) + # 壊れた xlsx を足す(読み込めない) + page.set_input_files("#addFileInput", [{"name": "broken.xlsx", "mimeType": "application/octet-stream", "buffer": b"not an excel file"}]) + page.wait_for_function("document.querySelector('#file-d3') && document.querySelector('#file-d3').dataset.state === 'error'") + wait_settled(page) + assert file_row_states(page) == {"file-d1": "ready", "file-d2": "ready", "file-d3": "error"} + assert page.get_attribute("html", "data-data-state") == "error" + assert page.inner_text("#file-d3 .file-row-error") != "" + assert plot_src(page).startswith("data:image/png") # ほかのファイルで描ける + assert any("データ3(broken.xlsx)を読み込めませんでした" in w for w in status_warnings(page)) + assert status_kind(page) == "warning" + # 失敗したファイルを使う系列はエラー + page.click("#addSeriesBtn") + page.select_option("#series-s2-source", "d3") + page.wait_for_function("document.getElementById('status').dataset.kind === 'error'") + assert "データ3(broken.xlsx)を読み込めていません" in status_text(page) + # 削除すると復帰する + page.click("#file-d3-remove") + page.wait_for_function("document.documentElement.dataset.dataState === 'ready'") + + +def test_failed_file_is_retried_when_load_settings_change(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + semicolon = {"name": "semi.csv", "mimeType": "text/csv", "buffer": b"a;b\n1;2\n3;4\n5;7\n"} + before = load_count(page) + page.set_input_files("#addFileInput", [semicolon]) + wait_load_count(page, before + 1) + wait_settled(page) + assert file_row_states(page)["file-d2"] == "ready" + page.fill("#delimiter", "x") # どちらも区切れず、1列のまま + page.wait_for_function("document.documentElement.dataset.dataState !== 'loading'") + wait_settled(page) + page.fill("#delimiter", "") + page.wait_for_function("document.documentElement.dataset.dataState === 'ready'") + wait_settled(page) + assert file_row_states(page) == {"file-d1": "ready", "file-d2": "ready"} + + +def test_sheet_select_reloads_only_that_file(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + add_files(page, [fixtures_dir / "multi_sheet.xlsx"]) + assert page.is_visible("#file-d2-sheet") and not page.is_visible("#file-d1-sheet") + assert page.inner_text("#file-d2") != "" and "Excel" in page.inner_text("#file-d2") + page.evaluate( + "() => { const orig = window.mplgui.loadFile; window.__ids = [];" + "window.mplgui.loadFile = function () { window.__ids.push(arguments[3]); return orig.apply(this, arguments); }; }" + ) + before = load_count(page) + page.select_option("#file-d2-sheet", "二枚目") + wait_load_count(page, before + 1) + wait_settled(page) + assert page.evaluate("window.__ids") == ["d2"] + assert page.input_value("#file-d2-sheet") == "二枚目" + page.select_option("#series-s1-source", "d2") + wait_settled(page) + assert "z [2]" in page.inner_text("#series-s1-y") + wait_code_generated(page) + open_code_tab(page) + assert 'sheet_name="二枚目",' in code_text(page) + + +def test_edit_mode_reads_both_files_from_the_work_folder(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + enter_edit_mode(page) + page.fill( + "#customPyCode", + "import pandas as pd\nimport matplotlib.pyplot as plt\n" + "a = pd.read_csv('utf8.csv')\nb = pd.read_csv('growth.csv')\n" + "fig, ax = plt.subplots()\nax.plot(a.iloc[:, 0], a.iloc[:, 1])\nax.plot(b.iloc[:, 0], b.iloc[:, 1])\n", + ) + run_edited_code(page) + assert image_summary(page)[0]["lines"] == 2 + assert status_kind(page) == "ok" + + +def test_paste_replaces_all_files_and_keeps_save_button(start, fixtures_dir): + from helpers import EXCEL_TABLE, paste_text + + page = start + two_files(page, fixtures_dir) + before = load_count(page) + paste_text(page, EXCEL_TABLE) + wait_load_count(page, before + 1) + wait_settled(page) + assert file_row_ids(page) == [] and page.is_visible("#savePastedBtn") + add_files(page, [fixtures_dir / "growth.csv"]) + assert page.is_visible("#savePastedBtn") # 貼り付けたデータが残っている間は出す + assert file_row_ids(page) == ["file-d1", "file-d2"] From e513f9f743872e43373efcce7ad99c4c7a422d48 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 20:09:09 +0900 Subject: [PATCH 13/18] Record Phase 4 decisions in the requirements Co-Authored-By: Claude Opus 5.5 --- docs/REQUIREMENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 2ae10ae..e1b94e3 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -48,6 +48,19 @@ - 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁(フォント・サイズ・プリセット)」「保存」の区分に整理し、各区分を折りたためるようにする。「注釈・解析」の区分は、中身が入る Phase 5 まで作らない。 - 日本語フォントを、Noto Sans CJK JP(OTF、CFF 形式、約16 MB)から、同じデザインの TrueType 版 Noto Sans JP(約5.7 MB)に替える。matplotlib は CFF 形式のフォントを `pdf.fonttype=42` で正しく埋め込めない(TrueType 用の FontFile2 に CFF を入れ、PDF が壊れる)ため。文字幅は同じで、見た目は変わらない。ハングルと中国語専用の漢字は含まれない(JIS 第1〜第4水準は含む)。 +### 2.4 Phase 4 の決定事項(2026-10-08 にユーザーが決定) + +- D6(複数ファイル)も Phase 4 に含める。読込設定(区切り文字、ヘッダ、読み飛ばし行数、桁区切り、小数点、コメント記号、日時の認識)は全ファイル共通で、シートだけファイルごとに選ぶ。棒グラフは、すべての系列が同じデータ元のときだけ描ける。同じ名前のファイルは置き換えとして扱う(作業フォルダと生成コードでファイルを名前で区別するため)。読み込めるのは 10 個まで。 +- 設定スキーマを version 3 にする。v2 からの移行では、日時の列の自動認識(`load.parseDates`)を false にする(v2 までの出力を保つため。新しく作る設定の既定値は true)。 +- ドラッグ&ドロップはページ全体で受ける。データを読み込み済みのときは、「置き換えて読み込む」「追加して読み込む」のどちらにドロップするかで選ぶ。 +- 貼り付けたデータは `pasted_data.tsv`(UTF-8、タブ区切り)というファイルとして扱う。生成コードはこのファイルを読み、データはスクリプトに埋め込まない。「貼り付けたデータを保存」で同じ内容を保存し、スクリプトと同じフォルダに置いてもらう。あわせて `.tsv` を対応する拡張子に加える(既定の区切り文字はタブ)。 +- 「ヘッダより前に読み飛ばす行数」は「データ読み込み」の区分に、「描画から除外する先頭行数」は「系列」の区分に置き、それぞれ何を数えるかを補足で示す。読み飛ばした行は、データ確認タブの表の上に枠で囲んで表示する(描画から除外する行のグレー表示とは見た目を分ける)。 +- 桁区切り・小数点・コメント記号は pandas の `thousands` / `decimal` / `comment` で扱い、生成コードの読込部には、すべての読込引数を既定値も含めて明記する。区切り文字の自動判定は加えない。 +- 日時の列の認識: 数値でない列のうち、年が先頭の書式(`2026-01-31`、`2026/01/31`、`2026年1月31日` と、その時刻付き・ISO 8601 のタイムゾーン付き)で、先頭100個の90%以上かつ列全体の90%以上が読める列を日時とみなす。月日の順が曖昧な書式、時刻だけ、年月だけは認識しない。読めない値は欠損にして警告する。タイムゾーン付きは、同じオフセットなら書かれた時刻のまま、オフセットが混在していたら UTC にそろえる(警告する)。Excel の日付セルは常に日時として扱う。 +- 日時の X 軸の目盛は、自動(`AutoDateLocator` と `ConciseDateFormatter`)か、指定した書式(`DateFormatter`)にする。X が日時のときは、X 軸の対数と範囲の指定はできない(日本語のエラーで案内する)。棒グラフで X が日時のときは、表示書式で文字列にしてカテゴリとして並べる。 +- Phase 2 で line / scatter の X が数値でない列のときに入れていた `ax.set_xscale("linear")`(目盛が位置の番号になる)はやめる。日時の列は日時軸にし、それ以外の文字列の列は目盛に値を出す(25個を超えるときは間引く)。 +- Pythonコードタブが GUI 同期のとき、エラーで最新の設定のコードを作れなかったら、表示中のコードが最新でないことをタブ内に表示する。 + --- ## 3. 現状の構成(2026-10-07 時点) From 03890336a9a2bf30f024c94028126bf9a307e474 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 21:20:37 +0900 Subject: [PATCH 14/18] Measure Phase 4 performance: paste, sheet switch, datetime and two-file loads Co-Authored-By: Claude Opus 5.5 --- docs/PERFORMANCE.md | 69 ++++++++++++++ tests/perf/bench.py | 228 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 272 insertions(+), 25 deletions(-) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 482cddc..95881cc 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -232,3 +232,72 @@ Phase 1 後より悪化した項目はなかった。このため、悪化の内 - どれも、メモリ不足やクラッシュは起きなかった。コンソールエラーもない。画像の大きさは、ファイルを開いて指定どおりであることを確かめた。 - 上限付近では、RGBA の画素バッファ(1画素 4 バイト、100 MP で約 400 MB)が wasm のメモリに載るため、wasm メモリは約 600 MB まで広がった。wasm のメモリは縮まないので、保存後もこの大きさのまま残る。JS ヒープ(`performance.memory`)は約 93〜98 MB で変わらない。 - この Mac(24 GB)の結果。メモリの少ない端末(スマートフォンなど)では、600 MB 級の確保が失敗する可能性があるが、今回は確かめていない。上限(1億画素)の変更は不要と判断し、触っていない。 + +## Phase 4 後 + +### 計測条件 + +| 項目 | 内容 | +|---|---| +| 日付 | 2026-10-08 | +| 対象 | `c668457`(Phase 4: 読込設定(シート、ヘッダより前の読み飛ばし、桁区切り・小数点・コメント)、ドラッグ&ドロップと貼り付け、日時の列と日時軸、複数ファイル)に、Phase 4 の計測行を追加した `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、ヘッドレス) | +| ネットワーク | 実ネットワーク(Phase 3 後と同じ) | +| 配信 | ローカルの静的 HTTP サーバー(127.0.0.1、サブパスなし) | +| 反復 | 各項目 N=5。既存項目の計測方法は Phase 3 後と同じ | + +追加した項目(既存項目の方法は変えていない。データはすべて実行時に生成し、コミットしない): + +- 「貼り付け → 最初のプロット」: `#pasteArea` に `ClipboardEvent('paste')`(`DataTransfer` に text/plain)を送り、最初のプロットが出るまで(終了条件は「初回ファイル読込 → 最初のプロット」と同じ)。(a) 10,000 行 × 6 列(X + Y 5列)のタブ区切り、改行は CRLF(Excel からのコピーと同じ形)、(b) 30 行 × 3 列。 +- 「xlsx(2 シート × 10,000 行)」: openpyxl で作った 2 シート(各 10,000 行 × 6 列、約 0.8 MB)の xlsx。(a) 初回読込 → 最初のプロット(Excel 用ライブラリの wheel は HTTP キャッシュを温めた状態)、(b) 系列5本を設定してから `#sheetSelect` で 2 枚目に切り替え → 新しい画像(`change` イベントから。そのファイルの読み直しと描画を含む)。 +- 「日時の列を含む 10,000 行」: `%Y/%m/%d %H:%M:%S` の日時の列 + 数値 5 列の CSV。(a) 読込 → 最初のプロット(既定の系列で描く。日時の認識と変換を含む)、(b) X = 日時の列で系列5本を設定してから、既存の「設定変更」と同じ方法(タイトルの `input` → 新しい画像。デバウンス 250 ms 込み)。 +- 「2ファイル」: 10,000 行の CSV 2つを `#fileInput`(multiple)で同時に選ぶ → 最初のプロット。設定変更は、系列1 をファイル1、系列2 をファイル2(2 系列)にしてから、既存の「設定変更」と同じ方法で測る(5 系列の既存項目とは系列数が違うので、直接は比べられない)。 + +### 結果(ミリ秒、中央値 / 最小 / 最大) + +| 項目 | Phase 3 後 | Phase 4 後 | Phase 3 後比 | +|---|---:|---:|---| +| コールド起動 | 3,564 / 3,526 / 7,098 | 3,605 / 3,544 / 6,444 | 同等(+1%) | +| ウォーム起動 | 3,441 / 3,424 / 3,484 | 3,429 / 3,418 / 3,437 | 同等(-0%) | +| 初回ファイル読込 → 最初のプロット | 177 / 175 / 179 | 178 / 176 / 179 | 同等(+1%) | +| 設定変更 → プロット更新(デバウンス込み) | 471 / 468 / 473 | 465 / 457 / 470 | 同等(-1%) | +| 同上(デバウンス 250ms 除く) | 221 / 218 / 223 | 215 / 207 / 220 | 同等(-3%) | +| 保存(PNG、クリック → ダウンロード開始。既定の 300 dpi) | 405 / 364 / 408 | 384 / 377 / 387 | 同等(-5%) | +| 初回 .xlsx 読込 → 最初のプロット | 329 / 325 / 330 | 325 / 323 / 325 | 同等(-1%) | +| 編集モードでの実行(Ctrl/Cmd+Enter → プロット更新) | 579 / 575 / 596 | 571 / 561 / 582 | 同等(-1%) | +| 保存(PNG、120 dpi) | 246 / 242 / 257 | 252 / 215 / 258 | 同等(+2%) | +| 保存(PNG、300 dpi。別測定) | 403 / 369 / 412 | 403 / 393 / 409 | 同等(-0%) | +| 欧文フォント選択 → プロット更新、初回(Arimo を jsDelivr から取得) | 468 / 456 / 472 | 459 / 452 / 471 | 同等(-2%) | +| 同上、Cache Storage にあるとき | 467 / 462 / 469 | 466 / 453 / 471 | 同等(-0%) | +| 参考: 欧文フォント初回の、選択 → `data-latin-font-state=ready` | 23 / 21 / 25 | 34 / 24 / 38 | 参考値(+48%。下記) | +| クリップボードへのコピー(クリック → done、300 dpi) | 422 / 406 / 424 | 406 / 390 / 407 | 同等(-4%) | +| 日本語フォント取得、コールド(ナビゲーション開始 → `font-state=ready`) | 3,622 / 3,606 / 3,623 | 3,692 / 3,687 / 3,721 | 同等(+2%) | +| 同上、Python 準備完了からの追加待ち | 67 / 56 / 77 | 68 / 61 / 83 | 同等(+1%) | +| 貼り付け(10,000 行 × 6 列)→ 最初のプロット【新】 | - | 180 / 180 / 182 | - | +| 貼り付け(30 行 × 3 列)→ 最初のプロット【新】 | - | 132 / 131 / 135 | - | +| xlsx(2 シート × 10,000 行)初回読込 → 最初のプロット【新】 | - | 843 / 840 / 843 | - | +| xlsx のシート切り替え → プロット更新(系列5本)【新】 | - | 465 / 461 / 466 | - | +| 日時の列を含む 10,000 行の読込 → 最初のプロット【新】 | - | 238 / 234 / 240 | - | +| 同、X = 日時で設定変更 → プロット更新(デバウンス込み、系列5本)【新】 | - | 478 / 468 / 481 | - | +| 同上(デバウンス 250ms 除く)【新】 | - | 228 / 218 / 231 | - | +| 2ファイル(各 10,000 行)の読込 → 最初のプロット【新】 | - | 208 / 206 / 209 | - | +| 2ファイルで設定変更 → プロット更新(デバウンス込み、系列2本)【新】 | - | 342 / 337 / 345 | - | +| 同上(デバウンス 250ms 除く)【新】 | - | 92 / 87 / 95 | - | + +コールド起動の最大値は1回目の反復のみ(キャッシュが冷えていたため)。2回目以降は約3.6秒。 + +### 要件の確認 + +要件「設定変更後 1 秒以内の再描画(10,000 行 × 5 系列)」: 設定変更 → プロット更新は、デバウンス 250 ms を含めて中央値 465 ms(最大 470 ms)で、**満たしている**。X が日時の列のときも 478 ms(最大 481 ms)、xlsx のシート切り替え(読み直しを含む)も 465 ms で、どちらも 1 秒以内。 + +### 分析 + +Phase 3 後より 5% を超えて悪くなった既存項目は無い(日本語フォントのコールド取得の +2% は、jsDelivr からの実ダウンロードを測っているためのぶれの範囲)。Phase 4 の読込設定(読み飛ばし・桁区切り・小数点・コメント)は `read_csv` の引数が増えるだけで、日時の認識は文字列の列だけを調べるため、数値だけの CSV の読込(「初回ファイル読込」177 → 178 ms)には影響していない。 + +- **参考値「欧文フォント初回の選択 → ready」+48%(23 → 34 ms)**: jsDelivr から約 0.3 MB を取得する時間で、ネットワークのぶれ。デバウンス 250 ms の間に終わるため、プロット更新(459 ms)には影響していない(Phase 4 で欧文フォントの処理は変えていない)。 +- **日時の列を含む読込(238 ms)**: 数値だけの同じ大きさの CSV(178 ms)より約 60 ms 長い。日時の認識(文字列の列ごとに、先頭 100 個の値で書式の候補を試す)と、10,000 個の文字列の日時への変換の分。設定変更からの更新(228 ms、デバウンス除く)は数値の X(215 ms)より約 13 ms 長い程度で、日時軸の目盛の計算の分とみられる。 +- **貼り付け(10,000 行)180 ms**: 同じ大きさのファイルの読込(178 ms)とほぼ同じ。貼り付けたテキストは `pasted_data.tsv` というファイルとして、ファイルと同じ経路で読むため。 +- **xlsx(2 シート × 10,000 行)の初回読込 843 ms**: ワークブックの解析(openpyxl)が大半。シートの切り替えでは、開いた `ExcelFile` を同じファイルの間だけ使い回すので、ワークブックを開き直さず、そのシートの読込と系列5本の描画だけで 465 ms になる。 +- **2ファイル**: 読込はファイルごとに順に行う(208 ms。1ファイルの 178 ms に、2つ目の読込が約 30 ms 加わる)。設定変更からの更新は系列が2本なので、5本の既存項目より短い。 diff --git a/tests/perf/bench.py b/tests/perf/bench.py index 8f6fdd3..6af156d 100644 --- a/tests/perf/bench.py +++ b/tests/perf/bench.py @@ -18,7 +18,7 @@ REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO / "tests" / "e2e")) from helpers import ( # noqa: E402 - enter_edit_mode, image_generation, load_fixture, plot_src, + add_series_with_source, enter_edit_mode, image_generation, load_fixture, paste_text, plot_src, set_save_dpi, wait_app_ready, wait_copy_state, wait_data_ready, wait_latin_state, wait_plot_changed, ) @@ -294,6 +294,156 @@ def bench_japanese_font(browser, url, reps): return total, after_ready +# --- Phase 4: データ読込の追加機能(貼り付け・シート切替・日時・複数ファイル) --- +# シートの切り替えは change イベント(select)。測り方は CHANGE_JS と同じ。 +SHEET_JS = CHANGE_JS.replace("'input'", "'change'") + + +def make_tsv(rows=10_000, ys=5): + import math + lines = ["\t".join(["x"] + [f"y{k + 1}" for k in range(ys)])] + for i in range(rows): + x = i * 0.01 + lines.append("\t".join([str(round(x, 4))] + [str(round(math.sin(x * (k + 1)) + 0.1 * k, 5)) for k in range(ys)])) + return "\r\n".join(lines) + "\r\n" + + +def make_xlsx(path, rows=10_000, sheets=2): + import math + from openpyxl import Workbook + wb = Workbook(write_only=True) + for s in range(sheets): + ws = wb.create_sheet(f"シート{s + 1}") + ws.append(["x", "y1", "y2", "y3", "y4", "y5"]) + for i in range(rows): + x = i * 0.01 + ws.append([round(x, 4)] + [round(math.sin(x * (k + 1) + s) + 0.1 * k, 5) for k in range(5)]) + wb.save(path) + + +def make_datetime_csv(path, rows=10_000): + import datetime + import math + t0 = datetime.datetime(2026, 1, 1) + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["time", "y1", "y2", "y3", "y4", "y5"]) + for i in range(rows): + x = i * 0.01 + t = t0 + datetime.timedelta(seconds=30 * i) + w.writerow([t.strftime("%Y/%m/%d %H:%M:%S")] + [round(math.sin(x * (k + 1)) + 0.1 * k, 5) for k in range(5)]) + + +def bench_paste(browser, url, reps, big_text, small_text): + """貼り付け(ClipboardEvent を #pasteArea へ)→ 最初のプロット。終了条件は「初回ファイル読込」と同じ。""" + out = {} + ctx = browser.new_context() + p0 = ctx.new_page() + p0.goto(url, timeout=TIMEOUT) + wait_app_ready(p0, TIMEOUT) + paste_text(p0, small_text) + wait_data_ready(p0, TIMEOUT) + wait_plot_changed(p0, "", TIMEOUT) + p0.close() + for name, text in (("paste_10000x6_first_plot_ms", big_text), ("paste_small_30rows_first_plot_ms", small_text)): + out[name] = [] + for _ in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + paste_text(page, text) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + out[name].append((time.perf_counter() - t) * 1000) + page.close() + ctx.close() + return out + + +def bench_sheet_switch(browser, url, reps, xlsx_path): + """2 シート × 10,000 行の xlsx。(a) 初回読込 → 最初のプロット(Excel 用 wheel 取得込み、HTTP キャッシュは温めた状態)、 + (b) 系列5本を設定した後、#sheetSelect で 2 枚目へ → 新しい画像(change イベントから。読み直し + 描画)。""" + first, switch = [], [] + ctx = browser.new_context() + p0 = ctx.new_page() + p0.goto(url, timeout=TIMEOUT) + wait_app_ready(p0, TIMEOUT) + load_fixture(p0, xlsx_path) + wait_plot_changed(p0, "", TIMEOUT) + p0.close() + for _ in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + page.set_input_files("#fileInput", str(xlsx_path)) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + first.append((time.perf_counter() - t) * 1000) + setup_series(page) # 既定の系列は X 列そのものを描くので、シートで絵が変わるよう 5 系列にしておく + page.wait_for_timeout(3000) + switch.append(page.evaluate(SHEET_JS, ["#sheetSelect", "シート2"])) + page.close() + ctx.close() + return first, switch + + +def bench_datetime(browser, url, reps, dt_path): + """日時の列(%Y/%m/%d %H:%M:%S)+ 数値5列、10,000 行。X = 日時の列、系列5本。""" + first, change = [], [] + ctx = _warm_context(browser, url, dt_path) + for i in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + page.set_input_files("#fileInput", str(dt_path)) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + first.append((time.perf_counter() - t) * 1000) + setup_series(page) + page.wait_for_timeout(3000) + change.append(page.evaluate(CHANGE_JS, ["#title", f"bench title {i}"])) + page.close() + ctx.close() + return first, change + + +def bench_two_files(browser, url, reps, path1, path2): + """2つの 10,000 行 CSV を #fileInput(multiple)で同時に選ぶ → 最初のプロット。 + 設定変更: 系列1 はファイル1、系列2 はファイル2 の 2 系列でタイトルを変える。""" + first, change = [], [] + ctx = browser.new_context() + p0 = ctx.new_page() + p0.goto(url, timeout=TIMEOUT) + wait_app_ready(p0, TIMEOUT) + p0.set_input_files("#fileInput", [str(path1), str(path2)]) + wait_data_ready(p0, TIMEOUT) + wait_plot_changed(p0, "", TIMEOUT) + p0.close() + for i in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + page.set_input_files("#fileInput", [str(path1), str(path2)]) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + first.append((time.perf_counter() - t) * 1000) + page.wait_for_timeout(2000) + add_series_with_source(page, "d2", y="__idx__1", series_id="s2") + page.wait_for_timeout(3000) + change.append(page.evaluate(CHANGE_JS, ["#title", f"bench title {i}"])) + page.close() + ctx.close() + return first, change + + def fmt(name, xs): s = stats(xs) return f"{name:<44} median {s['median']:9.0f} min {s['min']:9.0f} max {s['max']:9.0f} (ms, n={s['n']})" @@ -304,40 +454,68 @@ def main(): ap.add_argument("--reps", type=int, default=5) ap.add_argument("--headed", action="store_true") ap.add_argument("--json") + ap.add_argument("--only", choices=["old", "new"], help="old: Phase 3 までの項目だけ / new: Phase 4 の項目だけ") a = ap.parse_args() serve = Path(os.environ.get("E2E_SERVE_DIR") or REPO) srv, url = start_server(serve) tmp = Path(tempfile.mkdtemp(prefix="bench_")) csv_path = tmp / "bench_10000x6.csv" make_csv(csv_path) + csv2_path = tmp / "bench_10000x6_b.csv" + make_csv(csv2_path) + xlsx_path = tmp / "bench_2sheets_10000x6.xlsx" + make_xlsx(xlsx_path) + dt_path = tmp / "bench_datetime_10000x6.csv" + make_datetime_csv(dt_path) + big_tsv = make_tsv() + small_tsv = make_tsv(rows=30, ys=2) with sync_playwright() as p: 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, edit_run = bench_render(browser, url, a.reps, csv_path) - xlsx = bench_xlsx(browser, url, a.reps, REPO / "tests" / "fixtures" / "multi_sheet.xlsx") - save120, save300 = bench_save_dpi(browser, url, a.reps, csv_path) - latin_first, latin_cached, latin_fetch = bench_latin_font(browser, url, a.reps, csv_path) - copy = bench_copy(browser, url, a.reps, csv_path) - jp_total, jp_after_ready = bench_japanese_font(browser, url, a.reps) + res = {} + if a.only != "new": + cold, warm = bench_startup(browser, url, a.reps) + 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") + save120, save300 = bench_save_dpi(browser, url, a.reps, csv_path) + latin_first, latin_cached, latin_fetch = bench_latin_font(browser, url, a.reps, csv_path) + copy = bench_copy(browser, url, a.reps, csv_path) + jp_total, jp_after_ready = bench_japanese_font(browser, url, a.reps) + res.update({ + "cold_startup_ms": cold, "warm_startup_ms": warm, + "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, + # Phase 3 で追加。save_png_ms は既定 300 dpi(Phase 2 までは 120 dpi 固定) + "save_png_120dpi_ms": save120, + "save_png_300dpi_ms": save300, + "latin_font_first_select_to_plot_ms": latin_first, + "latin_font_cached_select_to_plot_ms": latin_cached, + "latin_font_first_select_to_font_ready_ms": latin_fetch, # 参考値 + "copy_to_clipboard_300dpi_ms": copy, + "japanese_font_cold_navigate_to_font_ready_ms": jp_total, + "japanese_font_cold_after_app_ready_ms": jp_after_ready, + }) + if a.only != "old": + paste = bench_paste(browser, url, a.reps, big_tsv, small_tsv) + xlsx_first, sheet_switch = bench_sheet_switch(browser, url, a.reps, xlsx_path) + dt_first, dt_change = bench_datetime(browser, url, a.reps, dt_path) + two_first, two_change = bench_two_files(browser, url, a.reps, csv_path, csv2_path) + res.update({ + # Phase 4 で追加 + **paste, + "xlsx_2sheets_10000_first_plot_ms": xlsx_first, + "xlsx_sheet_switch_to_plot_ms": sheet_switch, + "datetime_10000_first_plot_ms": dt_first, + "datetime_change_to_plot_ms": dt_change, + "datetime_change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in dt_change], + "two_files_first_plot_ms": two_first, + "two_files_change_to_plot_ms": two_change, + "two_files_change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in two_change], + }) browser.close() - res = { - "cold_startup_ms": cold, "warm_startup_ms": warm, - "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, - # Phase 3 で追加。save_png_ms は既定 300 dpi(Phase 2 までは 120 dpi 固定) - "save_png_120dpi_ms": save120, - "save_png_300dpi_ms": save300, - "latin_font_first_select_to_plot_ms": latin_first, - "latin_font_cached_select_to_plot_ms": latin_cached, - "latin_font_first_select_to_font_ready_ms": latin_fetch, # 参考値 - "copy_to_clipboard_300dpi_ms": copy, - "japanese_font_cold_navigate_to_font_ready_ms": jp_total, - "japanese_font_cold_after_app_ready_ms": jp_after_ready, - } for k, v in res.items(): print(fmt(k, v)) if a.json: From 2ff893e6ce1f237469b7a777fb5d4d7e84e6c2ad Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 21:21:56 +0900 Subject: [PATCH 15/18] Warn on decimal==separator, split lines like pandas, name data source in skip-rows error Co-Authored-By: Claude Opus 5.5 --- py/mplgui/dataprep.py | 13 ++++++++++--- py/mplgui/loader.py | 22 ++++++++++++++++++++-- tests/unit/test_load_options.py | 23 +++++++++++++++++++++++ tests/unit/test_multi_source.py | 15 +++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/py/mplgui/dataprep.py b/py/mplgui/dataprep.py index c65a823..2d99503 100644 --- a/py/mplgui/dataprep.py +++ b/py/mplgui/dataprep.py @@ -147,9 +147,13 @@ def resolve_y_indices(df: pd.DataFrame, requests: list[str]) -> list[str]: return resolved -def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int) -> pd.DataFrame: +def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int, label: str = "") -> pd.DataFrame: + """label は複数ファイルのときのデータ元の名前(「データ2(b.csv)」)。1つのときは空。""" if skip_rows >= len(df_full): - raise UserError("スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数") + prefix = f"{label}: " if label else "" + raise UserError( + f"{prefix}スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数" + ) return df_full.iloc[skip_rows:].reset_index(drop=True) @@ -262,7 +266,10 @@ def plan_plot(data, settings: Settings) -> PlotPlan: raise UserError(BAR_SOURCE_MESSAGE, field="データ元") order = [f.id for f in files if f.id in set(ids)] if files else list(dict.fromkeys(ids)) - frames = {sid: slice_skip_rows(data[sid], plot.skip_rows) for sid in order} + frames = { + sid: slice_skip_rows(data[sid], plot.skip_rows, f"データ{numbers.get(sid, 1)}({names.get(sid, '')})" if multi else "") + for sid in order + } sources = tuple( PlanSource(id=sid, number=numbers.get(sid, 1), var=f"df{numbers[sid]}" if multi else "df", name=names.get(sid, "")) for sid in order diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py index c0bbae5..2e8d056 100644 --- a/py/mplgui/loader.py +++ b/py/mplgui/loader.py @@ -190,8 +190,23 @@ def _check_conflicts(sep: str, thousands: str, comment: str) -> None: raise UserError("「コメント記号」に、区切り文字と同じ記号は使えません。", field="コメント記号") +def split_lines(text: str) -> list[str]: + """pandas と同じく \\r・\\n・\\r\\n だけを行の区切りにする(str.splitlines は U+2028 や改ページでも切る)。""" + return re.split(r"\r\n|\r|\n", text) + + +def _decimal_separator_warning(sep: str, decimal: str) -> str | None: + if len(sep) == 1 and decimal == sep: + shown = "タブ" if sep == "\t" else sep + return ( + f"小数点と区切り文字が同じ記号({shown})です。値が引用符(\"1,5\" のように)で囲まれていないと、" + "正しく読めません。区切り文字が ; やタブのファイルなら、「区切り文字」に指定してください。" + ) + return None + + def _first_data_line(text: str, skip_lines: int, comment: str) -> str: - for line in text.splitlines()[skip_lines:]: + for line in split_lines(text)[skip_lines:]: if line.strip() and not (comment and line.lstrip().startswith(comment)): return line return "" @@ -407,11 +422,14 @@ def make_source() -> SourceInfo: else: sep = decode_delimiter(delimiter, suffix) _check_conflicts(sep, thousands, comment) + decimal_warning = _decimal_separator_warning(sep, decimal) + if decimal_warning: + warnings_out.append(decimal_warning) used_encoding, text = detect_encoding(file_bytes) kwargs = make_source().read_kwargs() df = pd.read_csv(io.StringIO(text), **kwargs) if skip_lines > 0: - preamble = tuple(text.splitlines()[:skip_lines][:PREAMBLE_LINES]) + preamble = tuple(split_lines(text)[:skip_lines][:PREAMBLE_LINES]) except UserError: raise except pd.errors.EmptyDataError as exc: diff --git a/tests/unit/test_load_options.py b/tests/unit/test_load_options.py index d8444e6..0e5c649 100644 --- a/tests/unit/test_load_options.py +++ b/tests/unit/test_load_options.py @@ -426,3 +426,26 @@ def test_full_script_and_auto_render_agree(tmp_path, name, delimiter, opts): with run_script(script.auto_render_text(), injected={"df": loaded.df}) as run: auto = figure_summary(run.fig) assert full == auto + + +def test_decimal_equal_to_separator_warns_and_reads_quoted_values(): + r = load_file(csv_bytes('a,b\n"1,5",2\n"2,5",3\n'), "d.csv", "", True, decimal=",") + assert list(r.df["a"]) == [1.5, 2.5] + assert any("小数点と区切り文字が同じ記号(,)です" in w for w in r.warnings) + # 区切り文字が違えば警告しない + ok = load_file(csv_bytes("a;b\n1,5;2\n"), "d.csv", ";", True, decimal=",") + assert not any("小数点と区切り文字" in w for w in ok.warnings) + + +def test_thousands_equal_to_separator_is_allowed_silently(): + r = load_file(csv_bytes('a,b\n"1,234",2\n'), "t.csv", "", True, thousands=",") + assert list(r.df["a"]) == [1234] + assert not any("区切り文字が同じ" in w for w in r.warnings) + + +def test_preamble_and_first_data_line_split_only_on_cr_lf(): + text = "memo
line\x0cform\nx;y\n1;2\n" + r = load_file(csv_bytes(text), "p.csv", ";", True, skip_lines=1) + assert r.preamble_lines == ("memo
line\x0cform",) + assert list(r.df.columns) == ["x", "y"] + assert loader._first_data_line("a
b;c\nx\n", 0, "") == "a
b;c" diff --git a/tests/unit/test_multi_source.py b/tests/unit/test_multi_source.py index 463472a..e8762a5 100644 --- a/tests/unit/test_multi_source.py +++ b/tests/unit/test_multi_source.py @@ -358,3 +358,18 @@ def test_edit_mode_reads_all_files_from_the_work_folder(): ) r = json.loads(api.render_json(json.dumps(s), code)) assert r["ok"], r + + +def test_skip_rows_error_names_the_data_source_only_with_several_files(): + s = files_settings(*TWO, series=[{"x": "", "y": "__idx__1", "source": "d1"}, {"x": "", "y": "__idx__1", "source": "d2"}]) + load_two(s) + s["plot"]["skipRows"] = 10_000 + multi = render(s) + assert not multi["ok"] + assert multi["error"]["message"].startswith("データ1(utf8.csv): スキップ行数がデータ行数以上です。") + single = files_settings(("d1", "utf8.csv", "")) + api.SESSION.forget() + load("utf8.csv", "d1", single) + single["plot"]["skipRows"] = 10_000 + one = render(single) + assert one["error"]["message"].startswith("スキップ行数がデータ行数以上です。") From 3cac0f1be0a6ecf89b2384b737c71b48aae96251 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 21:27:26 +0900 Subject: [PATCH 16/18] Reset to the initial state when the last file is removed, count only real loads, show batch notes once, keep series cards when columns are unchanged Co-Authored-By: Claude Opus 5.5 --- js/bridge.js | 40 +++++++++++++++++++++++------- js/ui/dataPreview.js | 6 +++++ js/ui/plotView.js | 6 +++++ js/ui/series.js | 33 +++++++++++++++++++++++++ tests/e2e/helpers.py | 8 ++++++ tests/e2e/test_multi_files.py | 46 +++++++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 9 deletions(-) diff --git a/js/bridge.js b/js/bridge.js index d38c812..a3959fd 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -7,10 +7,10 @@ // 「実行」したときだけ描き、GUI の変更では再描画しない(読込は行うが自動実行しない)。 import { subscribe, toJson, getSettings, setFileSheet, setPath, updateSeries } from "./state.js"; import { addStickyWarning, removeStickyWarning, setStatus, warningTexts } from "./ui/notify.js"; -import { showPlot } from "./ui/plotView.js"; +import { clearPlot, showPlot } from "./ui/plotView.js"; import { showCodeOutput } from "./ui/codeOutput.js"; import { getGeneratedCode, getGeneratedGeneration, isEditMode, setCodeMode, setCodeStale, setGeneratedCode } from "./code-state.js"; -import { applySkipRows, showPreview } from "./ui/dataPreview.js"; +import { applySkipRows, clearPreview, showPreview } from "./ui/dataPreview.js"; import { dropSourceColumns, reconcileColumns, refreshSources, setSourceColumns } from "./ui/series.js"; import { encodingDisplayName, showEncoding, showFileName, showPastedSave } from "./ui/fileInfo.js"; import { showSheets } from "./ui/loadSection.js"; @@ -46,6 +46,7 @@ let latestGeneration = 0; // 最後に要求された描画の世代番号 let renderRunning = false; let renderDirty = false; let loadCount = 0; +let batchLoaded = false; // この読込のまとまりで、実際に読み込めたファイルがある(data-load-count の数え方) let editCodeProvider = () => ""; // 編集モードのコード(Pythonコードタブの textarea の内容) let editHasRun = false; // 編集モードに入ってから「実行」したことがある(フォント取得後の描き直しの判断に使う) let readyCallbacks = []; @@ -252,10 +253,10 @@ const executeRender = async (generation) => { 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; - const warnings = [...currentLoadWarnings(), ...warningTexts(result.warnings)]; + const warnings = [...currentLoadWarnings({ consumeNotes: true }), ...warningTexts(result.warnings)]; lastRenderStatus = { message: `描画に成功しました(${result.seriesCount}系列、スキップ${skipped}行)。`, detail: "", warnings }; } else { - lastRenderStatus = { message: "コードの実行に成功しました。", detail: result.output || "", warnings: currentLoadWarnings() }; + lastRenderStatus = { message: "コードの実行に成功しました。", detail: result.output || "", warnings: currentLoadWarnings({ consumeNotes: true }) }; } scriptRefreshFailed = false; setStatus(lastRenderStatus.message, "ok", lastRenderStatus.detail, lastRenderStatus.warnings); @@ -366,8 +367,10 @@ const fileLabel = (id) => { }; // 読込・描画の結果に添える警告: 直近のファイル操作の注意、各ファイルの読込警告(複数ファイルのときはデータ名つき)、読み込めなかったファイル -const currentLoadWarnings = () => { +// consumeNotes: ファイル操作の注意(notes)は、その操作の結果を表示するときに1度だけ添える(表示したら消す) +const currentLoadWarnings = ({ consumeNotes = false } = {}) => { const out = [...notes]; + if (consumeNotes) notes = []; const list = files(); const multi = list.length >= 2; list.forEach((f, i) => { @@ -447,6 +450,21 @@ const reportLoadError = (id) => { else reportError(error); }; +// 最後のファイルを取り除いたとき: 最初に開いたときの状態に戻す(編集モードのコードはそのまま) +const resetToInitialState = () => { + invalidatePendingRender(); + if (scriptTimer) window.clearTimeout(scriptTimer); + scriptTimer = null; + lastRenderStatus = null; + scriptRefreshFailed = false; + notes = []; + clearPlot(); + clearPreview(); + showCodeOutput({ output: "", kind: "ok" }); + if (!isEditMode()) setGeneratedCode("", null); + setStatus(""); +}; + // 読込が一通り終わったとき(待っている読込が無いとき)の後処理: 列の選択肢・表示の更新、描画または失敗の報告 const finalizeLoad = ({ counted = true } = {}) => { const list = files(); @@ -458,23 +476,26 @@ const finalizeLoad = ({ counted = true } = {}) => { reconcileColumns(); updateFilesView(); refreshDataState(); + const loaded = batchLoaded; + batchLoaded = false; if (list.length === 0) { - setStatus(""); + resetToInitialState(); return; } if (dataReady) { - if (counted) { + if (counted && loaded) { loadCount += 1; root.dataset.loadCount = String(loadCount); } if (isEditMode()) { - setStatus(EDIT_MODE_MESSAGE, "warning"); // 編集中は自動で実行しない(ファイルは作業フォルダに置かれている) + setStatus(EDIT_MODE_MESSAGE, "warning", "", currentLoadWarnings({ consumeNotes: true })); // 編集中は自動で実行しない(ファイルは作業フォルダに置かれている) } else { setStatus(""); renderNow(); } return; } + notes = []; const failed = list.find((f) => sources.get(f.id) && sources.get(f.id).state === "error"); if (failed) reportLoadError(failed.id); markCodeStale(); @@ -519,6 +540,7 @@ const loadOne = async (src) => { return; } src.state = "ready"; + batchLoaded = true; src.result = result; src.error = ""; src.errorInfo = null; @@ -633,7 +655,7 @@ export const selectFile = (file, options = {}) => { }; // ファイルを取り除く。それを使っていた系列は、データ元を先頭に戻し X / Y を自動に戻す(警告を添える) -const removeFile = (id) => { +export const removeFile = (id) => { const list = files(); const index = list.findIndex((f) => f.id === id); if (index < 0) return; diff --git a/js/ui/dataPreview.js b/js/ui/dataPreview.js index 3292e18..0461771 100644 --- a/js/ui/dataPreview.js +++ b/js/ui/dataPreview.js @@ -68,3 +68,9 @@ export const showPreview = (preview, skipRows = 0) => { dataArea.replaceChildren(...[preambleBox(preview.preamble), meta, wrap].filter(Boolean)); applySkipRows(skipRows); }; + +// 初期状態(表なし)に戻す +export const clearPreview = () => { + const dataArea = document.getElementById("dataArea"); + if (dataArea) dataArea.replaceChildren(); +}; diff --git a/js/ui/plotView.js b/js/ui/plotView.js index 6fbb4d6..d7e86bb 100644 --- a/js/ui/plotView.js +++ b/js/ui/plotView.js @@ -9,3 +9,9 @@ export const showPlot = (dataUri, generation, summary) => { if (summary !== undefined) img.dataset.summary = JSON.stringify(summary); plotArea.replaceChildren(img); }; + +// 初期状態(画像なし)に戻す +export const clearPlot = () => { + const plotArea = document.getElementById("plotArea"); + if (plotArea) plotArea.replaceChildren(); +}; diff --git a/js/ui/series.js b/js/ui/series.js index dc86797..28de326 100644 --- a/js/ui/series.js +++ b/js/ui/series.js @@ -157,9 +157,40 @@ export const syncMarkerSizeDisplay = () => { } }; +// カードを描いたときの、データ元と列の選択肢の状態(読込が終わったとき、変わっていなければ描き直さない) +let renderedStructure = ""; +const structureOf = () => { + const { series, plot } = getSettings(); + return JSON.stringify([ + loadedFiles().map((f) => [f.id, f.name]), + series.map((s) => [s.id, sourceIdOf(s), columnsOf(s)]), + plot.type, + plot.xColumn, + ]); +}; + +// 表示中のカードの選択が、設定と同じか(違えば描き直す) +const cardsMatchSettings = () => { + const list = document.getElementById("seriesList"); + if (!list) return false; + const { series } = getSettings(); + const items = list.querySelectorAll(".series-item"); + if (items.length !== series.length) return false; + return series.every((s, i) => { + const item = items[i]; + return ( + item.dataset.seriesId === s.id && + item.querySelector(".series-x")?.value === s.x && + item.querySelector(".series-y")?.value === s.y && + item.querySelector(".series-source")?.value === sourceIdOf(s) + ); + }); +}; + export const renderSeriesList = () => { const list = document.getElementById("seriesList"); if (!list) return; + renderedStructure = structureOf(); const { series, plot } = getSettings(); list.replaceChildren(...series.map((s) => buildCard(s, plot.type))); const items = list.querySelectorAll(".series-item"); @@ -184,6 +215,8 @@ const refreshXColumnSelect = () => { // ファイルの追加・削除などで、データ元の選択肢と各系列の列の選択肢を描き直す export const refreshSources = () => { refreshXColumnSelect(); + // データ元と列の選択肢が変わっていなければ、カードは描き直さない(入力中の欄や開いている選択肢を保つ) + if (renderedStructure === structureOf() && cardsMatchSettings()) return; renderSeriesList(); }; diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index a7f0a5a..21dbc8a 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -418,3 +418,11 @@ def add_series_with_source(page, source, y="__idx__1", series_id="s2"): page.select_option(f"#series-{series_id}-source", source) page.select_option(f"#series-{series_id}-y", y) wait_settled(page) + + +def remove_file(page, file_id): + """ファイルを取り除く。一覧の削除ボタンは2ファイル以上のときだけ出るので、最後の1つは bridge.js の removeFile で取り除く。""" + if page.locator(f"#file-{file_id}-remove").count() > 0: + page.click(f"#file-{file_id}-remove") + else: + page.evaluate("(id) => import(new URL('js/bridge.js', document.baseURI)).then((m) => m.removeFile(id))", file_id) diff --git a/tests/e2e/test_multi_files.py b/tests/e2e/test_multi_files.py index 7780413..591dbb6 100644 --- a/tests/e2e/test_multi_files.py +++ b/tests/e2e/test_multi_files.py @@ -14,6 +14,8 @@ load_fixture, open_code_tab, plot_src, + remove_file, + set_title, run_edited_code, status_kind, status_text, @@ -159,6 +161,10 @@ def test_more_than_ten_files_warns(start, fixtures_dir): wait_settled(page) assert len(file_row_ids(page)) == 10 assert "読み込めるファイルは10個までです。先頭から10個を読み込みました。" in status_warnings(page) + # 注意は、その読込の結果にだけ添える(あとの描画では繰り返さない) + set_title(page, "after") + wait_settled(page) + assert "読み込めるファイルは10個までです。先頭から10個を読み込みました。" not in status_warnings(page) def test_drop_add_and_replace_zones(start, fixtures_dir): @@ -286,3 +292,43 @@ def test_paste_replaces_all_files_and_keeps_save_button(start, fixtures_dir): add_files(page, [fixtures_dir / "growth.csv"]) assert page.is_visible("#savePastedBtn") # 貼り付けたデータが残っている間は出す assert file_row_ids(page) == ["file-d1", "file-d2"] + + +def test_removing_every_file_returns_to_the_initial_state(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + open_code_tab(page) + assert code_text(page) != "" + before = load_count(page) + remove_file(page, "d2") + wait_settled(page) + remove_file(page, "d1") + page.wait_for_function("document.documentElement.dataset.dataState === 'none'") + assert page.locator("#plotArea img").count() == 0 + assert page.inner_text("#dataArea").strip() == "" + assert page.inner_text("#fileNameLabel") == "選択されていません" + assert page.inner_text("#encodingLabel") == "" and not page.is_visible("#sheetGroup") + assert code_text(page) == "" and page.get_attribute("#customPyCode", "data-generation") is None + assert status_text(page) == "" + assert "(自動)" in page.inner_text("#series-s1-y") and "電圧" not in page.inner_text("#series-s1-y") + assert load_count(page) == before # 取り除くだけでは読込として数えない + load_fixture(page, fixtures_dir / "utf8.csv") # また読み込める + wait_settled(page) + assert plot_src(page).startswith("data:image/png") + + +def test_series_cards_are_kept_when_a_load_leaves_columns_unchanged(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + page.evaluate("document.getElementById('series-s1-legend-name').dataset.mark = 'kept'") + before = load_count(page) + page.fill("#delimiter", ",") # 同じ区切り文字を明示するだけ。読み直しても列は同じ + wait_load_count(page, before + 1) + wait_settled(page) + assert page.evaluate("document.getElementById('series-s1-legend-name').dataset.mark") == "kept" + # 列が変わる読込では描き直す + page.fill("#delimiter", "x") + wait_load_count(page, before + 2) + wait_settled(page) + assert page.evaluate("document.getElementById('series-s1-legend-name').dataset.mark") is None From 3aa27c4bc3811949b14510ceae3dcf1c7a40f518 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 21:27:26 +0900 Subject: [PATCH 17/18] Ask for the paste area when data is loaded; keep the drop overlay from sticking when dragleave has no types Co-Authored-By: Claude Opus 5.5 --- js/ui/dropPaste.js | 11 ++++++++++- tests/e2e/test_drop_paste.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/js/ui/dropPaste.js b/js/ui/dropPaste.js index ab09c4d..6d0b6bb 100644 --- a/js/ui/dropPaste.js +++ b/js/ui/dropPaste.js @@ -6,6 +6,8 @@ import { setStatus } from "./notify.js"; export const PASTED_FILENAME = "pasted_data.tsv"; export const NOT_A_TABLE_MESSAGE = "貼り付けた内容が表ではありません。Excel などで表の範囲をコピーしてから貼り付けてください。"; +export const PASTE_WHILE_LOADED_MESSAGE = + "表を貼り付けて読み込むときは、「表を貼り付け」欄に貼り付けてください(読み込み済みのデータは置き換わります)。"; const NO_FILE_MESSAGE = "ドロップされたものから読み込めるファイルが見つかりませんでした(フォルダは読み込めません)。"; const hasFiles = (event) => { @@ -89,7 +91,9 @@ const bindDrop = () => { highlightZone(event.target); }); window.addEventListener("dragleave", (event) => { - if (!hasFiles(event)) return; + // オーバーレイを出している間は、types が空になるブラウザ(Safari)でも数える(出しっぱなしにしない) + const el = overlay(); + if (!hasFiles(event) && !(el && !el.hidden)) return; dragDepth -= 1; if (dragDepth <= 0) hideOverlay(); }); @@ -140,6 +144,11 @@ const bindPaste = () => { const text = event.clipboardData ? event.clipboardData.getData("text/plain") : ""; if (text.trim() === "") return; event.preventDefault(); + if (hasLoadedFiles()) { + // 読み込み済みのデータを、うっかり貼り付けで置き換えない(「表を貼り付け」欄なら置き換える) + setStatus(PASTE_WHILE_LOADED_MESSAGE, "warning"); + return; + } loadPastedText(text); }); }; diff --git a/tests/e2e/test_drop_paste.py b/tests/e2e/test_drop_paste.py index 6f426b0..75b8e35 100644 --- a/tests/e2e/test_drop_paste.py +++ b/tests/e2e/test_drop_paste.py @@ -25,6 +25,7 @@ pytestmark = pytest.mark.e2e +PASTE_WHILE_LOADED = "表を貼り付けて読み込むときは、「表を貼り付け」欄に貼り付けてください(読み込み済みのデータは置き換わります)。" NOT_A_TABLE = "貼り付けた内容が表ではありません。Excel などで表の範囲をコピーしてから貼り付けてください。" @@ -272,3 +273,34 @@ def test_edit_mode_code_can_read_pasted_file(start): ) run_edited_code(page) assert "rows 4" in page.inner_text("#codeOutput") + + +def test_document_paste_with_data_loaded_warns_and_does_not_load(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "utf8.csv")) + wait_data_ready(page) + wait_settled(page) + before = load_count(page) + page.evaluate("document.activeElement && document.activeElement.blur()") + assert paste_text(page, EXCEL_TABLE, selector=None) is True + page.wait_for_timeout(600) + assert load_count(page) == before + assert page.inner_text("#fileNameLabel") == "utf8.csv" + assert status_kind(page) == "warning" and PASTE_WHILE_LOADED in status_text(page) + + +def test_paste_area_replaces_even_when_data_is_loaded(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "utf8.csv")) + wait_data_ready(page) + wait_settled(page) + paste_and_wait(page, EXCEL_TABLE) + assert "貼り付けたデータ" in page.inner_text("#fileNameLabel") + + +def test_overlay_hides_when_dragleave_has_no_types(start, fixtures_dir): + page = start + drag_event(page, "dragenter", "body", [fixture_file(fixtures_dir, "utf8.csv")]) + assert drop_overlay_visible(page) + drag_event(page, "dragleave", "body") # Safari: types が空になる + assert not drop_overlay_visible(page) From 9e1c550c45e88aff40d02ee4b42236e7c44ee804 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 21:50:09 +0900 Subject: [PATCH 18/18] Document Phase 4 (data import) in AGENTS.md and README Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 107 ++++++++++++++++++++++++++++++++++++++---------------- README.md | 20 +++++++--- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3bb0197..0beb805 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,20 @@ # matplotlib GUI 引き継ぎ資料(AI エージェント向け) 要件の正は `docs/REQUIREMENTS.md`。このファイルと食い違う場合は REQUIREMENTS.md を優先する。 -この資料は Phase 3(出力品質: 保存 DPI・図サイズの単位・欧文フォント・プリセット・クリップボード・パネルの区分)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 +この資料は Phase 4(データ取り込み: ドラッグ&ドロップ・貼り付け・シート選択・読み込み設定(ヘッダ前の読み飛ばし・桁区切り・小数点・コメント記号)・日時の列と日時軸・複数ファイル)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 ## 1. ゴールと前提 -- CSV / TXT / XLSX のデータから、ブラウザ上の GUI だけで matplotlib の図を作る。 +- CSV / TXT / TSV / XLSX のデータから、ブラウザ上の GUI だけで matplotlib の図を作る。 - 利用形態は GitHub Pages 配信(公開 URL: https://mioupa.github.io/matplotlib_gui/)。`file://` で直接開く使い方は非対応。 - サーバー処理を持たない。計算はすべてブラウザ内の Python(PyScript / Pyodide)で行う。GitHub Pages は静的配信のみに使う。 - 読み込んだデータは外部に送信しない。外部への通信はライブラリとフォントの取得だけ(フォントは日本語の Noto Sans JP(TrueType 版)、選んだときだけ欧文の Arimo / Tinos。画面用の Web フォントは fontsource の Noto Sans JP)。 - 列の指定は列番号(`__idx__N`)で行う。同名の列があっても区別できる。 - 自動読込・自動描画を保つ(「読み込む」「描画する」ボタンは置かない)。 - 図は、設定から生成した Python スクリプトの実行だけで描く(描画経路は1本)。画面の図と、書き出した `.py` の結果は一致する。 -- データ確認タブは、描画から除外される先頭行(`skipRows`)をグレー(`.skipped-row`)で表示する。 +- データ確認タブは、描画から除外される先頭行(`skipRows`)をグレー(`.skipped-row`)で表示する。ヘッダより前に読み飛ばした行(`load.skipLines`)は、表の上に枠で囲んで別に表示する(`#dataPreamble`。グレー表示とは見た目を分ける)。 - 保存 DPI(既定 300)、図サイズの単位(inch / cm / mm)、欧文フォント、スタイルプリセット、カラーパレット、クリップボードへのコピー、数式(mathtext)の案内が加わった。いずれも設定 → 生成コード → 実行の1本の経路に載る(GUI だけの特別扱いは無い)。 +- Phase 4 でデータの取り込みが広がった。ファイルのドラッグ&ドロップ(ページ全体)と表の貼り付け(`pasted_data.tsv` として扱う)、`.tsv`、xlsx のシート選択、ヘッダより前に読み飛ばす行数、桁区切り・小数点・コメント記号、日時の列の自動認識と日時軸(X 軸の表示書式)、複数ファイル(最大10個。系列ごとにデータ元を選ぶ)。読込の引数は、生成コードの読込部にすべて明記する(区切り文字などの自動判定は足していない)。いずれも設定 → 生成コード → 実行の1本の経路に載る。 - CDN の URL はすべてバージョン固定にする。`tests/unit/test_cdn_pinning.py` が `index.html`・`js/`・`css/`・`py/` の全 URL を走査して強制する。取得しない説明リンクの `matplotlib.org` は、バージョンを含むパス(`/3.10.8/...`)だけ許可する。 ## 2. ファイル構成 @@ -24,36 +25,40 @@ | `pyscript.toml` | PyScript 設定(パッケージ、`packages_cache`、`[files]` による Python ファイルの配置) | | `css/style.css` | スタイル | | `js/main.js` | エントリ。UI を設定オブジェクトに結び付ける | -| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`、`SCHEMA_VERSION` = 2)、UD カラーの色(`PALETTE`) | +| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`、`SCHEMA_VERSION` = 3)、UD カラーの色(`PALETTE`) | | `js/units.js` | 図サイズの単位(`UNITS`、`UNIT_NAMES`、`UNIT_STEP`)と換算 `convertLength(value, from, to)`(mm を介し、to の単位で丸める: in 3 桁 / cm 2 桁 / mm 1 桁)。純粋な ES module | | `js/palettes.js` | カラーパレット(`PALETTES`: `ud` / `tab10` / `gray`)、`getPalette`、`paletteColor(id, index)`。id は `settings.PALETTES` と一致させる。色は JS だけが持つ | | `js/presets.js` | スタイルプリセット(`PRESETS`: standard / paper1 / paper2 / slide)、`planPreset`(適用内容を計算するだけ)、`newSeriesDefaults`(直前のプリセットを新しい系列へ)。名前は設定に入れない | -| `js/code-state.js` | Pythonコードタブの状態(モード `sync` / `edit`、直近に生成したコードとその世代)。設定オブジェクトには入れない | -| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`applyPalette`、`setLastPreset` / `getLastPreset`、`subscribe`、`toJson`)。直近に適用したプリセットはセッション中だけここに持つ | -| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存・コピー(`makeClipboardImage`)・コードだけの更新(`script`)の制御、世代管理、日本語・欧文フォント取得の起動と待ち | +| `js/code-state.js` | Pythonコードタブの状態(モード `sync` / `edit`、直近に生成したコードとその世代、「最新でない」印 `isCodeStale` / `setCodeStale`)。設定オブジェクトには入れない | +| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`applyPalette`、`setFileSheet`、`setLastPreset` / `getLastPreset`、`subscribe`、`toJson`)。直近に適用したプリセットはセッション中だけここに持つ | +| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存・コピー(`makeClipboardImage`)・コードだけの更新(`script`)の制御、世代管理、日本語・欧文フォント取得の起動と待ち。読み込んだファイル(データN)ごとの状態(`File`・バイト列・貼り付けか・`loading` / `ready` / `error`・結果)を持ち、ファイルの追加・置き換え・削除(`selectFiles` / `selectFile` / `removeFile`)、読込の予約(`scheduleLoad`。ファイルごと)、最後のファイルを外したときの初期状態への復帰、コードの「最新でない」印を扱う | | `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 への保存。種類(`japanese` / `arimo` / `tinos`)ごとの URL(`FONT_URLS`)、種類ごとに1セッション1回の取得、旧 OTF のキャッシュ削除 | | `js/ui/codeTab.js` | Pythonコードタブ(行番号、モード別のボタン、コピー、.py 保存、Ctrl/Cmd+Enter) | | `js/ui/codeOutput.js` | 「実行結果」欄(`#codeOutput`)。print の出力とトレースバックを表示する | +| `js/ui/loadSection.js` | データ読み込み区分のシート選択(`#sheetGroup` / `#sheetSelect`。xlsx のときだけ表示し、選んだシートは `load.files` に書く) | +| `js/ui/dropPaste.js` | ファイルのドロップ(ページ全体。`#dropOverlay` と、`#dropLoad` / `#dropReplace` / `#dropAdd` の区画)、表の貼り付け(`#pasteArea` とページ上の paste)、「貼り付けたデータを保存(.tsv)」(`#savePastedBtn`)。どれも `bridge.js` の `selectFiles` / `selectFile` に渡すだけ | +| `js/ui/fileList.js` | 2ファイル以上のときのファイル一覧(`#fileList`。各行 `file-`・シートの選択・削除)と、データ確認タブの表示元の切替(`#previewSource`)。`bridge.js` が作る表示用のデータを描くだけ | +| `js/ui/dateFormat.js` | 軸・目盛区分の X 軸の日時の書式(`#xDateFormat` のプリセットと「任意」の `#xDateFormatCustom`)。値は `axes.x.dateFormat` に書く。表示の可否(`#xDateFormatGroup`)は `series.js` が決める | | `js/ui/panelSections.js` | 設定パネルの区分(`details.panel-section`)の折りたたみ。状態は localStorage(キー `mplgui.panelSections`、`{区分の id: 開いているか}`)だけに持ち、設定オブジェクトには入れない。localStorage が使えなくても動く(すべて開く) | | `js/ui/styleSection.js` | 体裁区分: プリセットの「適用」、カラーパレット、図サイズの単位切替(数値を換算し、ラベルと step を単位に合わせる) | | `js/ui/saveSection.js` | 保存区分: DPI(72 / 150 / 300 / 600 のセレクトと「任意」の入力欄)。値は `save.dpi`(数値)に書く | | `js/ui/clipboard.js` | 「クリップボードにコピー」。`` を更新する | -| `js/ui/*.js`(上記以外) | 画面部品(`colorPicker`、`dataPreview`、`fileInfo`(選択中のファイル名と文字コードの表示)、`forms`、`notify`、`plotView`(画像と `data-summary`)、`progress`、`saveFormat`(形式に応じた透過の可否と、SVG の文字・PDF の説明の出し分け)、`series`、`tabs`) | -| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録(`render` / `save` / `copyImage` / `script` / `registerFont` など)、Excel 用ライブラリの遅延導入、作業フォルダ `api.SESSION.workdir` の設定) | -| `py/mplgui/settings.py` | 設定スキーマ(version 2)、既定値、検証、日本語エラー(凍結 dataclass へ変換)、`migrate_settings`(v1 → v2) | -| `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`)。純粋な関数 | +| `js/ui/*.js`(上記以外) | 画面部品(`colorPicker`、`dataPreview`(先頭100行、前置き行の枠、日時の列の印)、`fileInfo`(選択中のファイル名と文字コードの表示)、`forms`、`notify`、`plotView`(画像と `data-summary`)、`progress`、`saveFormat`(形式に応じた透過の可否と、SVG の文字・PDF の説明の出し分け)、`series`(系列カード。複数ファイルのときはデータ元の選択と、データ元ごとの列の選択肢。X の日時の列の有無)、`tabs`) | +| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録(`loadFile` / `removeSource` / `clearSources` / `render` / `save` / `copyImage` / `script` / `registerFont` など)、Excel 用ライブラリの遅延導入、作業フォルダ `api.SESSION.workdir` の設定) | +| `py/mplgui/settings.py` | 設定スキーマ(version 3)、既定値、検証、日本語エラー(凍結 dataclass へ変換。`FileEntry`、読込設定の検証、`axes.x.dateFormat`)、`migrate_settings`(v1 → v2 → v3) | +| `py/mplgui/loader.py` | バイト列から DataFrame(文字コード判定、区切り文字、読込設定(`skip_lines` / `thousands` / `decimal` / `comment`)、xlsx のシート、日時の列の認識、プレビューと前置き行)。読込結果に `SourceInfo`(ファイル名、種別(csv / txt / tsv / xlsx)、文字コード、区切り文字、ヘッダの有無、読込設定、シート名とシート一覧、日時に変換した列 `datetime_columns`、貼り付けたデータか `pasted`)を付ける。`SourceInfo.read_kwargs()` が `pd.read_csv` / `pd.read_excel` の引数の唯一の定義で、loader と codegen が共有する。`split_lines`(pandas と同じく `\r` / `\n` / `\r\n` だけで行を分ける)、`detect_datetime_columns` / `convert_datetime` / `apply_datetime_columns`、`open_excel`(`pd.ExcelFile`)も持つ | +| `py/mplgui/dataprep.py` | DataFrame(または `{ファイル id: DataFrame}`)を見て決める処理。`plan_plot` が描画計画 `PlotPlan` を作る(データ元ごとの `skipRows` と Y 列の自動割り当て、X の数値変換・日時・文字列の別、描ける点の有無、A7 の警告(桁区切り・小数点のヒント付き)、日時の X の検査、データ由来の日本語 `UserError`)。描画に使うデータ元は `PlotPlan.sources`(`PlanSource`: id・番号・変数名・ファイル名)と `SeriesPlan.data_id` / `data_var` に持つ | +| `py/mplgui/codegen.py` | 設定・`SourceInfo`(または `{ファイル id: SourceInfo}`)・`PlotPlan` から matplotlib スクリプトを生成する(`generate_script`)。純粋な関数 | | `py/mplgui/formats.py` | 保存形式の表(`FORMATS`)、保存解像度の既定値(`DEFAULT_SAVE_DPI` = 300。実際の値は `save.dpi`)、`build_filename`、`savefig_kwargs`、形式ごとの rcParams(`savefig_rc`)、PNG / JPG の画素数の上限(`MAX_RASTER_PIXELS`、`MAX_RASTER_SIDE`、`raster_pixels`、`check_raster_size`)。runner と codegen が共有する | | `py/mplgui/runner.py` | スクリプトの実行(`run_script` / `run_to_image`)、Figure → 画像(PNG/JPG/SVG/PDF。`savefig_rc` を `rc_context` で適用、画素数の検査)、図の要約(`figure_summary`)、実行時エラーの整形、数式の誤りの判定(`is_mathtext_error`、`MathtextError`) | | `py/mplgui/fonts.py` | フォントの登録と rcParams。日本語の一覧 `SANS_SERIF_PRIORITY`(先頭は Noto Sans JP)と、欧文フォントの表 `LATIN_FONTS`(Arimo / Tinos の表示名・候補名・mathtext フォントセット)が唯一の定義で、生成スクリプトもこれを使う。`register_font_bytes(data, kind=)` は種類(`japanese` / `arimo` / `tinos`)ごとに1回だけ登録する | | `py/mplgui/errors.py` | `UserError`(利用者が直せるエラー) | -| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数(`render_json` / `save_json` / `copy_image_json` / `script_json` など)。読込済みデータ(`SESSION`)と、アップロードしたファイルの作業フォルダへの書き出しを持つ | +| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数(`render_json` / `save_json` / `copy_image_json` / `script_json` など)。読込済みデータ(`SESSION`。ファイル id ごとの `LoadedSource`(`df`・`SourceInfo`・書き出したファイル)と、xlsx の `ExcelFile` のキャッシュ(同じバイト列の間だけ)。`remove_source_json` / `clear_sources_json` も持つ)と、アップロードしたファイルの作業フォルダへの書き出しを持つ | | `py/mplgui/runtime.py` | 警告フィルタ(起動時) | | `tests/unit/` | 単体テスト(CPython、Agg)。`test_js_modules.py` は node で純粋な JS モジュール(units / palettes / presets / state)を検査する | | `tests/e2e/` | E2E テスト(pytest-playwright、Chromium)。`run_script_summary.py` は、生成スクリプトを CPython で実行して図の要約を出す補助(一致確認用) | -| `tests/fixtures/` | 合成テストデータ(`make_fixtures.py` が生成) | +| `tests/fixtures/` | 合成テストデータ(`make_fixtures.py` が生成。説明は `README.md`。Phase 4 で `preamble` / `european` / `thousands` / `comments` / `datetime` / `datetime_iso_tz` / `dates_ja` などを追加) | | `tests/perf/bench.py` | 起動・描画の計測。結果は `docs/PERFORMANCE.md` | | `.github/workflows/tests.yml` | CI | | `_config.yml` | GitHub Pages(Jekyll)の include / exclude | @@ -62,18 +67,25 @@ ## 3. 設定オブジェクト -- スキーマのバージョンは 2(`version`)。JSON のキーは camelCase。 +- スキーマのバージョンは 3(`version`)。JSON のキーは camelCase。 - 唯一の正は JS 側(`js/state.js`)の設定オブジェクト。Python は DOM を読まず、渡された JSON を `settings.parse_settings` / `parse_load_settings` で凍結 dataclass に変換して使う。 - 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`、`latinFont`、`palette`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`)、`series[]`、`save`(`filename`、`format`、`transparent`、`dpi`、`svgText`)。 +- 主な構造: `load`(`delimiter`、`hasHeader`、`skipLines`、`thousands`、`decimal`、`comment`、`parseDates`、`files`)、`plot`(`type`、`skipRows`、`xColumn`、`title`、`fontSize`、`figure`、`latinFont`、`palette`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`。`x` だけ `dateFormat`)、`series[]`、`save`(`filename`、`format`、`transparent`、`dpi`、`svgText`)。 - Phase 3 で足した項目と既定値: - `plot.figure`: `{width: 8, height: 6, unit: "in"}`。`unit` は `in` / `cm` / `mm`。`width` / `height` は選んだ単位の値で持つ(インチに直して持たない)。Python が検証するときだけ `FigureSettings.width_in` で「値 / 1インチあたりの長さ(2.54 / 25.4)」と割ってインチにする。上限は 50 inch を選んだ単位に直した値(in 50 / cm 127 / mm 1270)で、超えると単位つきの日本語エラー。 - `plot.latinFont`: `default` / `arimo` / `tinos`(既定 `default`)。`plot.palette`: `ud` / `tab10` / `gray`(既定 `ud`。Python は選択肢の検証だけで、色は JS の `palettes.js` が持つ)。 - `save.dpi`: 整数(既定 300、50〜1200、整数に見える数値・数値文字列・空欄=既定値を受け付ける。それ以外は「保存 DPI」の日本語エラー)。`save.svgText`: `path`(文字を図形にする。既定)/ `text`(文字のまま)。 -- `settings.migrate_settings(raw)`: `parse_settings` / `parse_load_settings` / `parse_save_settings` が最初に通す(純粋。引数は変更しない)。version が無ければ現在のバージョン扱い。v1 → v2 は `save.dpi` が無ければ 120 にする(v1 の保存は常に 120 dpi だったので同じ出力になる)。図の単位・欧文フォント・パレット・SVG の文字は既定値で補われる。1 と 2 以外は「設定のバージョン…に対応していません」の `UserError`。 +- `settings.migrate_settings(raw)`: `parse_settings` / `parse_load_settings` / `parse_save_settings` が最初に通す(純粋。引数は変更しない)。version が無ければ現在のバージョン扱い。v1 → v2 は `save.dpi` が無ければ 120 にする(v1 の保存は常に 120 dpi だったので同じ出力になる)。図の単位・欧文フォント・パレット・SVG の文字は既定値で補われる。v2 → v3 は `load.parseDates` が無ければ **false** にする(v2 までの出力を保つ。Phase 3 の dpi 120 と同じ考え方。v3 で新しく作る設定の既定値は true)。他の新項目は既定値で補われる。1・2・3 以外は「設定のバージョン…に対応していません」の `UserError`。 +- Phase 4 で足した項目と既定値(スキーマ version 3): + - `load.skipLines`(0): ヘッダより前に読み飛ばす行数。整数(上限 1,000,000)。`field` は「ヘッダより前に読み飛ばす行数」。`plot.skipRows`(描画から除外する先頭行数。ヘッダの後のデータ行を数える)とは別の項目。 + - `load.thousands`(`""`。`""` / `,` / `.` / 空白 / `'`)、`load.decimal`(`.`。`.` / `,`)、`load.comment`(`""` または1文字)。thousands と decimal が同じ記号、comment が `"`・空白・thousands・decimal と同じ、はいずれも日本語の `UserError`(`field` は「桁区切り」「コメント記号」)。pandas の `thousands` / `decimal` / `comment` にそのまま渡す。 + - `load.parseDates`(true): 日時の列を自動で認識する(§7)。 + - `load.files`(`[]`): `[{id, name, sheet}]`。`id` は `d` + 数字(`d1`, `d2`, ...。削除しても再利用しない)、`sheet` が `""` なら先頭シート。最大 10 個(超えると「読み込めるファイルは10個までです。」)。画面の「データN」は、この配列の位置(N = 位置 + 1)。名前・シートの変更は、そのファイルだけを読み直す。 + - `series[].source`(`""`): データ元のファイル id。`""` は `load.files` の先頭。`load.files` に無い id は「系列Nのデータ元が見つかりません。…」(`field` は「系列Nのデータ元」)。 + - `axes.x.dateFormat`(`""`): X 軸の日時の表示書式(strftime)。`""` は自動。空でなければ `%` を含み、`strftime` が通ること(64 文字以下)。誤りは「X軸の日時の書式」の日本語エラー。`axes.y` / `axes.y2` には無い。 - パレットの色、プリセットの値、単位の換算は JS だけが持つ(`js/palettes.js`、`js/presets.js`、`js/units.js`)。プリセット名は設定に入れない。「適用」は図サイズ・単位・フォントサイズ・各系列の線幅と点サイズを個別の設定に書き込むだけで、直近のプリセットは `state.js` がセッション中だけ覚える(新しい系列に、線幅と、scatter のときの点サイズを引き継ぐ)。点サイズは、プリセットが自動(標準)なら全系列を自動に戻し、数値なら scatter か点サイズを入力済みの系列だけ書き換える。パレットの切替(`state.applyPalette`)は、全系列を系列の順に新しいパレットの色で塗り直してから `plot.palette` を書く。新しい系列の既定色は現在のパレットの未使用色。 -- 系列: `id`、`x`、`y`、`color`、`lineWidth`、`lineStyle`、`markerSize`、`label`、`secondaryAxis`。`markerSize: null` は「自動」(line は 0、scatter は 24)。ユーザーが数値を入れたら自動切替の対象外。 -- Pythonコードタブの状態(モード `sync` / `edit`、生成したコード)は設定オブジェクトに入れず、`js/code-state.js` に持つ。スキーマは version 1 のまま。編集モードのコード本文は、`render` / `save` の別引数 `code` で渡す。「使用する」(`useCustomCode`)は廃止した。 +- 系列: `id`、`x`、`y`、`color`、`lineWidth`、`lineStyle`、`markerSize`、`label`、`secondaryAxis`、`source`。`markerSize: null` は「自動」(line は 0、scatter は 24)。ユーザーが数値を入れたら自動切替の対象外。 +- Pythonコードタブの状態(モード `sync` / `edit`、生成したコード)は設定オブジェクトに入れず、`js/code-state.js` に持つ。編集モードのコード本文は、`render` / `save` の別引数 `code` で渡す。「使用する」(`useCustomCode`)は廃止した。 ## 4. Python ⇄ JS の API(`window.mplgui`) @@ -82,7 +94,7 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 | 関数 | 引数 | 戻り値(`ok: true` のとき) | |---|---|---| | `ensureExcel()` | なし(非同期) | `{ok}`。初回の .xlsx 読込前に openpyxl を導入する | -| `loadFile(name, bytes, loadJson)` | ファイル名、内容、`{version, load}` | `{encoding, columns, preview, warnings}`(.xlsx は `encoding: null`) | +| `loadFile(name, bytes, loadJson, sourceId, pasted)` | ファイル名、内容、`{version, load}`、データ元の id(省略時 `d1`)、貼り付けたデータか(省略時 false) | `{encoding, columns, preview, sheets, sheet, warnings}`。.xlsx は `encoding: null`、`sheets` はシート名の配列(xlsx 以外は `[]`)、`sheet` は読んだシート名(xlsx 以外は `null`)。`columns` の各要素は `{value, label, kind}`(`kind` は `datetime` / `number` / `text`)。`preview` は `{columns, columnKinds, rows, …, preamble: {lines, total}}`(`preamble` は読み飛ばした行の先頭最大20行と総数。`skipLines` が 0 のときは空) | | `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}` | | `copyImage(settingsJson, code)` | 同上 | `{mime, dataUri, width, height, output}`(保存形式にかかわらず PNG) | @@ -97,7 +109,9 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - `code` が null(GUI 同期): 設定から生成したスクリプトの「自動描画用」(§4.1)を、読込済みの DataFrame(`df`)を渡して実行する。`image` は PNG の data URI、`code` は表示用の完全なスクリプト、`output` は実行中の print / stderr、`summary` は `runner.figure_summary` の軸ごとの要約。読込済みデータが必要。`save` は保存設定の形式・背景透過・`save.dpi`・`save.svgText` で書き出す(`render` のプレビューは dpi 100、`PREVIEW_DPI`)。形式ごとの rcParams(`formats.savefig_rc`: PDF は `pdf.fonttype=42`、SVG は `svg.fonttype` = `path` / `none`)は `matplotlib.rc_context` で `savefig` の間だけ適用する(生成コードの `plt.rcParams[...]` と同じ値)。`copyImage` は保存形式にかかわらず PNG を `save.dpi` で作り、背景透過は保存設定に従う(クリップボードにコピーする画像。C5)。`script` は、保存設定(`save.*`)の変更で表示中のコードだけを作り直すのに使う(§5)。 - `code` が文字列(編集モード。`save` / `copyImage` も同様): そのスクリプトを丸ごと、アップロードしたファイルのある作業フォルダ(`SESSION.workdir`)で実行する。`render` は設定を読まない(設定が不正でも、読込済みデータが無くても動く)。`save` は保存設定(`save.*`)だけを `settings.parse_save_settings` で検証して使う(描画設定が不正でも、編集したコードの図は保存できる)。GUI の設定をコードの結果に上書き適用することはしない(A8)。 -- `loadFile` は、読み込んだファイルを元のファイル名(フォルダ部分は除く)で作業フォルダに書き出す(前のファイルは消す。失敗したら読込済みデータと書き出したファイルを破棄する)。こうして、表示中のスクリプトの `pd.read_csv("ファイル名", ...)` がそのまま動く(P5)。 +- `loadFile` は、読み込んだファイルを元のファイル名(フォルダ部分は除く)で作業フォルダに書き出す(同じ id の前のファイルは消す。失敗したらその id の読込済みデータと書き出したファイルを破棄する)。貼り付けたデータは `pasted_data.tsv`(UTF-8、タブ区切り)という名前のファイルとして同じ経路で読む。こうして、表示中のスクリプトの `pd.read_csv("ファイル名", ...)` がそのまま動く(P5)。 +- 読込の規則(`loader.load_file`): 拡張子は `.csv` / `.txt` / `.tsv` / `.xlsx`(それ以外は「対応していない拡張子です。xlsx/csv/txt/tsvを選択してください。」)。区切り文字の既定は拡張子で決まる(csv はカンマ、txt は空白、tsv はタブ。自動判定はしない)。pandas に渡す引数は `SourceInfo.read_kwargs()`(csv: `sep`・`header`・`skiprows`・`thousands`・`decimal`・`comment`・`engine="python"`。xlsx: `sheet_name`・`header`・`skiprows`・`thousands`・`decimal`・`comment`)。xlsx のシートは `loadJson` の `load.files` のうち `sourceId` の項目から決め(無ければ先頭)、無い名前は「シート「X」がファイルにありません。…」(`field` は「シート」)。`pd.ExcelFile` は同じバイト列の間だけ id ごとにキャッシュする(シートの切替を速くする)。 +- 読込のエラーと警告(利用者に案内する): 区切りが空白で `thousands` が空白、`comment` が区切り文字と同じ、は `UserError`。列数が揃わない `ParserError` と、`skipLines` が大きすぎてデータが無いときは、「ヘッダより前に読み飛ばす行数」を案内する文を添える。小数点と区切り文字が同じ記号(1文字の区切りで `decimal == sep`)はエラーにせず警告にする(引用符で囲まれた値は読めるため)。結果が1列で先頭のデータ行に `;` かタブがあれば、区切り文字の指定漏れの警告(`DELIMITER_HINT`)を返す。前置き行は、csv / txt / tsv ではデコード後のテキストを `split_lines`、xlsx では先頭 `skipLines` 行をタブでつないで作る。 - `warnings` は `{series, message}` の配列(読込時は文字列配列の場合もある。JS の `warningTexts` が吸収する)。 - 失敗: `{ok: false, error: {message, field?, detail?, traceback?, line?}, output?}`。 - 利用者が直せる原因は `UserError`。`message` は日本語で、どの項目が悪いかを含める(`field`)。 @@ -110,7 +124,7 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 ### 複数ファイル(Phase 4 D6) -- `loadFile(name, bytes, loadJson, id, pasted)` は id ごとに読み込み、その id のデータ元だけを置き換える(失敗したら、その id だけを破棄。ほかは残る)。`api.Session.sources` は `{id: LoadedSource(df, info, written)}`、Excel の `ExcelFile` も id ごと。 +- `loadFile` は id ごとに読み込み、その id のデータ元だけを置き換える(失敗したら、その id だけを破棄。ほかは残る)。`api.Session.sources` は `{id: LoadedSource(df, info, written)}`、Excel の `ExcelFile` も id ごと。 - データ元の解決: `series.source` が空なら `load.files` の先頭、`load.files` が空なら読み込んだ先頭。読み込めていないデータ元を使う系列は「データN(name)を読み込めていません…」(`field` は「系列Nのデータ元」)。`dataprep.plan_plot(data, settings)` は DataFrame または `{id: DataFrame}` を受け、`skipRows` と Y の自動割り当てをデータ元ごとに行う。棒グラフは全系列が同じデータ元(違えば「データ元」のエラー)。 - 生成コード: `load.files` が 1 件以下なら従来どおり(`DATA_FILE` / `df`)。2 件以上なら、系列が使うファイルだけを `DATA_FILE_N` / `dfN`(N は一覧の位置 = 画面の「データN」)で読む。自動描画では `{"df1": ..., "df2": ...}` を注入する。 - JS(`bridge.js`): ファイルごとに `{File, bytes, pasted, state(loading|ready|error), result, error}` を持つ。`#fileInput`(置き換え)、`#addFileBtn` / `#addFileInput`(追加)、ドロップの `#dropReplace` / `#dropAdd`、一覧 `#fileList`(行 `file-`、`file--sheet`、`file--remove`)、データ確認の `#previewSource`、系列の `series--source`。同じ名前のファイルは同じ id で置き換え、上限は 10 個。`data-data-state` は読込中が1つでもあれば `loading`、失敗が1つでもあれば `error`(ほかのファイルが読めていれば描画は行い、失敗は警告に出す)。`data-load-count` は、読み込めたデータ元がある読込の完了ごとに増える。 @@ -119,18 +133,21 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 描画経路は1本: 設定 → `dataprep.plan_plot`(データを見て決める処理と検査)→ `codegen.generate_script`(スクリプトの文字列)→ `runner`(実行して Figure を得て画像にする)。GUI の図と、書き出す `.py` は同じコードから作る。 -- `dataprep.plan_plot(df, settings)`: `skipRows` の適用、Y 列の自動割り当て、X の数値変換の要否、描ける点があるか、数値に変換できず除外した値の警告(A7)を決める。データ由来の問題は日本語の `UserError`(列が範囲外、除外行数が多すぎる、描ける数値データが無い、など)にする。結果は凍結 dataclass の `PlotPlan` / `SeriesPlan`。 +- `dataprep.plan_plot(data, settings)`(`data` は DataFrame または `{ファイル id: DataFrame}`): データ元の解決(`series.source` が空なら `load.files` の先頭)、データ元ごとの `skipRows` の適用と Y 列の自動割り当て、X の数値・日時・文字列の別、描ける点があるか、日時の X の検査(日時と日時でない X の混在、X が日時のときの対数軸・範囲指定、Y が日時、はいずれも日本語の `UserError`)、数値に変換できず除外した値の警告(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. 保存と表示`(形式ごとの rcParams、`fig.savefig(...)`、`plt.show()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。 +- スクリプトの構成: ヘッダ(使い方、`import`、日本語フォントの rcParams、欧文フォントの設定(下記)、`FONT_SIZE`)→ `1. データの読み込み`(`DATA_FILE`、`pd.read_csv` / `pd.read_excel`。読込の引数はすべて1行に1つ、コメント付きで明記する(下記))→ `2. 描画に使う行`(`skipRows`)→ `3. 図と軸`(`fig, ax`。単位ごとの書き方は下記)→ `4. 系列` → `5. 軸(ラベル・スケール・範囲・目盛)` → `6. グリッドと凡例` → `7. 余白` → `8. 保存と表示`(形式ごとの rcParams、`fig.savefig(...)`、`plt.show()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。 +- `1. データの読み込み`(Phase 4): 引数は `SourceInfo.read_kwargs()` の順に、既定値も含めてすべて書く(csv: `encoding`(自動判定)、`sep`、`header`、`skiprows`、`thousands`、`decimal`、`comment`、`engine="python"`。xlsx: `sheet_name`、`header`、`skiprows`、`thousands`、`decimal`、`comment`。xlsx では「ファイル内のシート」の一覧もコメントに書く)。ヘッダなしのときは続けて `df.columns = [f"column_{i}" ...]`。日時の列があれば、`# 日時の列を日時に変換する…` に続けて列ごとに `df.isetitem(N, pd.to_datetime(df.iloc[:, N], format="…", errors="coerce")) # 列名 [N]`(前後の空白があれば `.str.strip()`、タイムゾーンの混在は `utc=True`、タイムゾーン付きは `.dt.tz_localize(None)`)。loader も同じ式(`convert_datetime`)を同じ順で適用するので、画面のデータと生成コードの読込結果は一致する。貼り付けたデータ(`SourceInfo.pasted`)のときは、読込部の先頭と「使い方」に「貼り付けたデータを保存して `pasted_data.tsv` として置く」案内を書く(データはスクリプトに埋め込まない)。 +- 複数ファイル: `load.files` が1件以下なら `DATA_FILE` / `df`。2件以上なら、系列が使うファイルだけを `DATA_FILE_N` / `dfN`(N = `load.files` の位置 + 1 = 画面の「データN」。使わないファイルがあっても番号は詰めない)で、「データN: ファイル名」の見出しコメント付きで読む。`2. 描画に使う行`(`skipRows`)も `dfN` ごと。系列のコメントと変数は `p.data_var` を使う。 +- 軸(Phase 4): X が日時のとき(line / scatter)、`axes.x.dateFormat` が空なら `mdates.AutoDateLocator()` と `mdates.ConciseDateFormatter(date_locator)`、指定があれば `mdates.DateFormatter()`。`import matplotlib.dates as mdates` は日時軸のときだけ書く。棒グラフで X が日時のときは `.dt.strftime(<書式>)` でカテゴリの文字列にして並べる(書式は `dateFormat`、空なら全部 0 時 `%Y-%m-%d` / 秒が全部 0 `%Y-%m-%d %H:%M` / それ以外 `%Y-%m-%d %H:%M:%S`)。X が日時でない文字列の列のときは、Phase 2 の `ax.set_xscale("linear")` を入れず、目盛に X の値(カテゴリ)を出す。描く系列の X のカテゴリ(重複を除く)が 25 個を超えるときだけ `ticker.MaxNLocator(nbins=25, integer=True)` で間引く(`import matplotlib.ticker as ticker` もそのときだけ)。 - 欧文フォント(`plot.latinFont` が `arimo` / `tinos` のときだけ。`default` ではヘッダは Phase 2 と同じ): ヘッダに `from matplotlib import font_manager` を足し、`installed = {font.name for font in font_manager.fontManager.ttflist}`、`latin = [name for name in [候補…] if name in installed][:1]`、`plt.rcParams["font.family"] = latin + ["sans-serif"]` を書く。候補は `fonts.LATIN_FONTS` の並び(Arimo: `Arimo`、`Arial`、`Liberation Sans`。Tinos: `Tinos`、`Times New Roman`、`Liberation Serif`)。入っているフォントだけを選ぶのは、無い名前を `font.family` に並べると、matplotlib が描画のたびに `findfont` の警告を出すため(ローカルで実行しても警告を出さず、どれも無ければ日本語フォントで書く)。日本語の文字は、リストの後ろの `sans-serif`(`font.sans-serif` の日本語フォント)に、matplotlib の文字単位のフォールバックで回る。Tinos(`mathtext_fontset` = `stix`)のときは `plt.rcParams["mathtext.fontset"] = "stix"` も書く(数式も Times 系の字形にする。STIX は matplotlib 同梱)。 - `3. 図と軸`: `figsize` はインチ。`unit` が `in` なら `figsize=(幅, 高さ)`。`cm` / `mm` なら `CM_PER_INCH = 2.54`(`MM_PER_INCH = 25.4`)を定義し、`figsize=(幅 / CM_PER_INCH, 高さ / CM_PER_INCH)` と割る(コメントに単位と値を書く)。`1 / 2.54` を掛けると、20.32 cm が 7.999… inch になり、Agg の画素数(切り捨て)が 1 画素欠けるため、割り算にする(Python 側の `FigureSettings.width_in` も同じ割り算)。 - `8. 保存と表示`: `formats.savefig_rc(format, svg_text)` が返す rcParams を、`plt.rcParams["pdf.fonttype"] = 42` / `plt.rcParams["svg.fonttype"] = "path"|"none"` としてコメント付きで `fig.savefig(...)` の前に書く(runner が `rc_context` で適用する値と同じ)。`savefig` の `dpi` は PNG / JPG のときだけ入れ、値は `save.dpi`(コメントに「N dpi」)。PDF のときは、手元の日本語フォントが OpenType(CFF)形式(macOS のヒラギノなど)だと文字が正しく表示されないことがあるという注意を、コメント2行で添える(原因は §6)。 - 変数名は、`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` の注入テストが検査する。 +- **安全性(必須)**: 列名・タイトル・ラベル・色・ファイル名・文字コード・シート名・コメント記号・日時の書式・貼り付けたデータのファイル名などは信頼できない入力で、実行されるコードに入る。値は必ず `literal()`(str / int / 有限の float / bool / None だけを `repr` で出す。他の型は例外)を通して書き出し、コメントに入れる文字列は必ず `comment_text()`(改行・制御文字を空白にする)を通す。f 文字列などで、これ以外の方法で信頼できない文字列をコードに埋め込まないこと。`tests/unit/test_codegen.py`(と `test_load_options.py` / `test_datetime.py` / `test_multi_source.py` / `test_paste_tsv.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 でそのまま動かすため)。新しい行を足したら、単体テストで「全体を実行した図」と「自動描画用の図」が一致することも確かめる。保存形式ごとの rcParams は `formats.savefig_rc` に足す(runner と codegen が同じ表を使う)。欧文フォントを足すときは `fonts.LATIN_FONTS`・`FONT_FILENAMES`、JS の `font-cache.js` の `FONT_URLS`・`bridge.js` の `LATIN_NAMES`、`settings.LATIN_FONTS`、`index.html` の選択肢を揃える。 +- 機能追加(Phase 5〜)の手順: 描画の種類は `SERIES_EMITTERS`(プロット種別 → 系列の出力関数)に追加する。出力関数は `_Builder` に行を足し、失敗しうる行は `with b.step(メッセージ, field)` で包む。軸・グリッド・余白などは対応する `_emit_*` 関数に足す。データを見ないと決められないこと(列の割り当て、変換の要否、警告、データ由来のエラー)は `dataprep` の `PlotPlan` / `SeriesPlan` に項目を足して渡す(データ元ごとに違うことは `SeriesPlan.data_id` / `data_var` と `PlotPlan.sources` に持つ。読込の仕方に関わる引数は `SourceInfo.read_kwargs()` に足し、codegen の `_READ_COMMENTS` にコメントを足す)。設定項目を足すときは JS の `defaults.js` と `settings.py` の両方に足す。生成するコードは pandas / matplotlib / numpy だけを使う(ローカルの Python でそのまま動かすため)。新しい行を足したら、単体テストで「全体を実行した図」と「自動描画用の図」が一致することも確かめる。保存形式ごとの rcParams は `formats.savefig_rc` に足す(runner と codegen が同じ表を使う)。欧文フォントを足すときは `fonts.LATIN_FONTS`・`FONT_FILENAMES`、JS の `font-cache.js` の `FONT_URLS`・`bridge.js` の `LATIN_NAMES`、`settings.LATIN_FONTS`、`index.html` の選択肢を揃える。 - 生成スクリプトの保存ファイル名(`savefig` の引数)は `formats.build_filename` で決める。保存形式・解像度・形式ごとの rcParams は `formats.savefig_kwargs`(解像度は `save.dpi`)/ `formats.savefig_rc` で、GUI の「保存」と同じにする。 ### runner(スクリプトの実行) @@ -144,21 +161,25 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 ## 5. 描画パイプライン -- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更で 0 ms、区切り文字の変更で 450 ms 後。保存設定(`save.*`)の変更では再描画しない(画像の画素は保存設定に依存しないため)。`skipRows` の変更は Python を呼ばず、データ確認表のグレー表示だけ即時に更新する。 +- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更・シート変更・桁区切り・小数点・日時の自動認識の変更で 0 ms、入力欄(区切り文字・ヘッダより前に読み飛ばす行数・コメント記号)の変更で 450 ms 後(`bridge.js` の `LOAD_DELAY_MS` / `SLOW_LOAD_PATHS`)。読込設定(`load.*`)の変更は全ファイルを読み直す。ファイル一覧のシート変更は、そのファイルだけを読み直す(`load.files` の変更に `fileId` が付く)。保存設定(`save.*`)の変更では再描画しない(画像の画素は保存設定に依存しないため)。`skipRows` の変更は Python を呼ばず、データ確認表のグレー表示だけ即時に更新する。 - 起動中の `#progress`(`aria-live="polite"`)は段階表示: 「Python 実行環境を読み込み中…」→「ライブラリを読み込み中…(pandas ✓, matplotlib …)」→「ライブラリを初期化中…」→ 準備完了で消える(フォント取得の表示が続く場合あり)。`` の `data-startup-stage`(`runtime` / `packages` / `init` / `ready`)に現在の段階が入る。新しい id は無い。読込失敗の案内(`data-load-failed`)は上書きしない。 - 保存設定(`save.*`: ファイル名・形式・背景透過・DPI・SVG の文字)の変更は、GUI 同期のときだけ、250 ms 後に `script` API でコードだけを作り直す(`bridge.js` の `refreshScript`)。画像は描き直さず、コードの世代(`#customPyCode` の `data-generation`)は表示中の画像のまま(`code-state.getGeneratedGeneration()`)。描画が予約中・実行中のときは何もしない(描画が新しいコードを返す。実行中だったら終了後にもう一度確かめる)。コードの更新に失敗したら(保存設定の不正など)エラーを表示し、次に成功したときに直近の描画成功のステータスへ戻す。編集モードでは何もしない。 - 描画要求には世代番号を付ける。Python の描画は同時に最大1つ。実行中に新しい要求が来たら「やり直し」の印だけ付け、終了後に最新の設定で1回だけ描く(合流)。古い世代の結果は画像にもステータスにも反映しない。 - Python の起動前に選んだファイルや変えた設定は保持し、起動後に最新の内容で1回読み込んで描く(起動キュー)。 +- ファイルの操作(Phase 4): 「ファイルを選択」`#fileInput`(複数可)と貼り付けは、読み込み済みのデータをすべて置き換える(Python 側は `clearSources` を呼んでから読む)。「+ ファイルを追加」`#addFileBtn`(`#addFileInput`)とドロップの追加区画は追加。同じ名前のファイルは、その id のまま置き換えて読み直す。上限 10 個(超えた分は警告「読み込めるファイルは10個までです。…」で読み込まない)。削除(一覧の `file--remove`)は `removeSource` を呼び、そのファイルを使っていた系列は `source` を `""`、X / Y を自動に戻して警告を出す。最後のファイルを削除したら、起動直後の状態(図・データ確認・コード・ステータスを空にし、ファイルなし)に戻す(編集モードのコードはそのまま)。一部のファイルの読込に失敗しても、ほかのファイルは使える(失敗したファイルは一覧に残り、設定を変えると再試行する。それを使う系列はデータ元のエラーになる)。 +- ドロップ: ページ全体で受ける(`dataTransfer.types` に `Files` があるときだけ `#dropOverlay` を出す。文字列のドラッグには反応しない)。未読込のとき `#dropLoad` の1区画、読み込み済みのとき `#dropReplace`(置き換え)と `#dropAdd`(追加)の2区画で、ポインタの下の区画で決める(区画の外は置き換え)。ドラッグ中の preventDefault でブラウザがファイルを開かない。`dragleave` の `types` が空のブラウザ(Safari)でも出しっぱなしにしない。Esc でも閉じる。フォルダは読み込めない(警告)。 +- 貼り付け: `#pasteArea` への貼り付け(paste、または値が入った input)は常に読み込み、すべてのデータを置き換える。ページ上の paste(貼り付け先が input / textarea / select / contenteditable でないとき)は、データが未読込のときだけ読み込む。読込済みなら置き換えず、「「表を貼り付け」欄に貼り付けてください」と案内する警告を出す(うっかり置き換えない)。表とみなす条件は、タブを含む、または空でない行が2行以上。そうでなければ警告。改行は `\n` にそろえ、UTF-8 の `pasted_data.tsv` として `loadFile`(`pasted: true`)に渡す。`#savePastedBtn` は、データ元に貼り付けたデータがあるときだけ表示し、同じバイト列を JS だけでダウンロードする。 +- コードタブの「最新でない」印: GUI 同期のとき、描画またはコードだけの更新(`refreshScript`)が失敗して新しいコードを作れなかったら、`#customPyCode` に `data-stale="true"` を付け、`#codeStaleNote`(既定は hidden)を出す。新しいコードを表示したとき・編集モードに入ったときに消える(`code-state.js` の `setCodeStale`)。既存の data 属性の値は変えない。 - 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 が動いていればよい(コードが自分でファイルを読む)。 - 欧文フォント(`plot.latinFont`): 選んだときだけ取得する(既定の `default` では取得しない)。Python の起動前に選んでいたら、起動後に取得する。取得中の描画・保存・コピーは、`waitForFonts` が最大 `FONT_FIRST_RENDER_WAIT_MS`(8 秒)待つ。超えたらその種類は待ちを打ち切り、標準フォントで描き、取得・登録が済んだら(選択中のままなら)現在の設定で1回だけ描き直す(編集モードでは「実行」したことがあるときだけ)。取得は種類ごとにセッション中1回で、失敗しても再試行しない。失敗したら、選択中のあいだ警告「欧文フォント Arimo(または Tinos)を取得できなかったため、標準のフォントで描画しています。」を常時警告に1度だけ出し、標準(`default`)に戻すと消える(取得を再試行しない)。編集モードでも欧文フォントの取得・登録はするが、描画もコードも触らない。取得中は進捗バナーに「欧文フォントを取得中…」。 -- 左パネルは `details.panel-section`(`#sectionLoad` データ読み込み、`#sectionSeries` 系列、`#sectionAxes` 軸・目盛、`#sectionStyle` 体裁、`#sectionSave` 保存)に分かれ、既定はすべて開く。折りたたみの状態は localStorage の `mplgui.panelSections` に持つ(`panelSections.js`)。ステータス(`#status`)は区分の外にあり、閉じていても見える。「注釈・解析」の区分は Phase 5 まで作らない。体裁区分は、プリセット(`#stylePreset`、「適用」`#applyPresetBtn`)、`#colorPalette`、`#latinFont`、`#fontSize`、図サイズ(`#figWidth` / `#figHeight` / `#figUnit`)、凡例位置、グリッド、余白。軸・目盛区分には数式の案内(`#mathtextHelp`。matplotlib 3.10.8 の mathtext ページへのリンク)がある。保存区分は `#saveDpi`(+ 任意の `#saveDpiCustom`)、SVG のときだけ `#svgTextGroup`(`#svgText`)、PDF のときだけ `#pdfHelp`、`#savePlotBtn`、`#copyPlotBtn`。 +- 左パネルは `details.panel-section`(`#sectionLoad` データ読み込み(ファイル・貼り付け・シート・区切り文字・ヘッダ・読み飛ばす行数・桁区切り・小数点・コメント記号・日時の自動認識)、`#sectionSeries` 系列(描画から除外する先頭行数を含む)、`#sectionAxes` 軸・目盛、`#sectionStyle` 体裁、`#sectionSave` 保存)に分かれ、既定はすべて開く。折りたたみの状態は localStorage の `mplgui.panelSections` に持つ(`panelSections.js`)。ステータス(`#status`)は区分の外にあり、閉じていても見える。「注釈・解析」の区分は Phase 5 まで作らない。体裁区分は、プリセット(`#stylePreset`、「適用」`#applyPresetBtn`)、`#colorPalette`、`#latinFont`、`#fontSize`、図サイズ(`#figWidth` / `#figHeight` / `#figUnit`)、凡例位置、グリッド、余白。軸・目盛区分には数式の案内(`#mathtextHelp`。matplotlib 3.10.8 の mathtext ページへのリンク)と、X が日時の列のときだけ出る日時の書式(`#xDateFormatGroup`。line / scatter はどれかの系列の X、bar は `#xColumn` が日時の列のとき)がある。保存区分は `#saveDpi`(+ 任意の `#saveDpiCustom`)、SVG のときだけ `#svgTextGroup`(`#svgText`)、PDF のときだけ `#pdfHelp`、`#savePlotBtn`、`#copyPlotBtn`。 - 図サイズの単位を替えると、入力欄の数値を換算し(`convertLength`。空欄・不正な入力はそのまま)、図の大きさは変えない。ラベル(`図幅(inch)` など)と step は単位に合わせる。プリセットの「適用」は値を書き込むだけで、描画は通常の変更どおり。パレットを替えると、色の選択肢と各系列の色を描き直す。 - 「クリップボードにコピー」(`clipboard.js`): 保存 DPI の PNG を `copyImage` で作り、`ClipboardItem` に `Promise` として渡して `navigator.clipboard.write` する(Safari がクリックの中での呼び出しを要求するため、クリックの処理では何も `await` せずに `write` を呼び、画像は渡した Promise の中で作る)。対応は `window.isSecureContext`(https または localhost)、`navigator.clipboard.write`、`ClipboardItem`(`ClipboardItem.supports("image/png")` があれば true)。編集モードでは編集中のコードの図をコピーする。 -- 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-latin-font-state`(選択中の欧文フォント。`idle`(標準を選択中)/ `loading` / `ready` / `failed`)、`data-copy-state`(`idle` / `copying` / `done` / `failed` / `unsupported`)、`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`)がある。 +- 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-latin-font-state`(選択中の欧文フォント。`idle`(標準を選択中)/ `loading` / `ready` / `failed`)、`data-copy-state`(`idle` / `copying` / `done` / `failed` / `unsupported`)、`data-load-count`(読み込めたデータ元がある読込のまとまりの完了ごとに増える。ファイルを外しただけ・全ファイルの失敗では増えない)。複数ファイルのとき、`data-data-state` は読込中が1つでもあれば `loading`、読込中がなく失敗が1つでもあれば `error`、すべて成功なら `ready`、ファイルが無ければ `none`。表示中の画像は `#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`)、`#customPyCode` の `data-stale`(最新でないときだけ `true`)、`#codeStaleNote` がある。Phase 4 の画面の id: データ読み込み区分の `#fileInput` / `#addFileBtn` / `#addFileInput` / `#fileList` / `#pasteArea` / `#savePastedBtn` / `#sheetGroup` / `#sheetSelect` / `#skipLines`(+ `#skipLinesHelp`)/ `#thousands` / `#decimal` / `#commentChar` / `#parseDates`、系列区分の `#skipRowsHelp` / 系列カードの `series--source`、軸・目盛区分の `#xDateFormatGroup` / `#xDateFormat` / `#xDateFormatCustom`、データ確認の `#dataPreamble` / `#previewSource`(+ `#previewSourceGroup`)、ドロップの `#dropOverlay` / `#dropLoad` / `#dropReplace` / `#dropAdd`。 ## 6. 起動とフォント @@ -176,6 +197,8 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - 文字コード(`loader.detect_encoding`): BOM 付きなら utf-8-sig → UTF-8 → CP932 と EUC-JP。両方でデコードできるときは、半角カナ・私用領域・制御文字などの「文字化けらしい文字」が少ないほうを採用し、同数なら CP932。どれでも読めなければ日本語のエラーにする。判定結果は `#encodingLabel` に表示する。 - 数値に変換できず除外した値は、系列ごとに警告にする(`dataprep.dropped_warning`。`dataprep.plan_plot` が集める)。 +- 数値に変換できず除外した値の例が「1,234」のような桁区切りの数で `thousands` がカンマでないとき、「1,5」のような小数点カンマで `decimal` がカンマでないときは、警告の文に「(桁区切りのカンマなら、「データ読み込み」の「桁区切り」をカンマにしてください)」「(小数点がカンマなら、「小数点」をカンマにしてください)」を添える(`dataprep.load_hint`。両方に当てはまる `1,234` は桁区切りのヒントだけ)。 +- 日時の列の認識(`loader.detect_datetime_columns`。`load.parseDates` が true のときだけ。Excel の日付セル(もともと datetime の列)は設定にかかわらず日時): 読込後に数値でない文字列の列のうち、空欄以外の半数以上が数値に読める列は除く。書式は年が先頭の固定の候補だけ(日付 `%Y-%m-%d` / `%Y/%m/%d` / `%Y年%m月%d日`、時刻は無し / ` %H:%M` / ` %H:%M:%S` / ` %H:%M:%S.%f`、`%Y-%m-%d` には ISO の `T` 区切りも、時刻のあるものには末尾 `%z`(`Z` と `+09:00`)も)。前後の空白を除いた先頭 100 個の空欄以外の値の 90% 以上が読める書式のうち最も多く読めるものを選び、列全体でも 90% 以上が読めれば日時の列にする。月日の順が曖昧な `01/02/2024`、時刻だけ、年月だけは認識しない。読めない値は NaT にして、列ごとに警告(「列「name」[i]: 日時として読めない値がN件あったため、欠損として扱います(例: …)」。例は最大3件)。`%z` 付きは、全行が同じオフセットなら書かれた時刻のまま、混在していれば UTC にそろえて警告する。日時の列は `columns[].kind` が `datetime` になり、データ確認の列見出しに「日時」の印が付く。日時の X で描くときの検査は §4.1。 - ステータス(`#status`)の種別は `ok` / `warning` / `error`。警告は成功メッセージに添えて表示し、`error` のときは `data-kind="error"`。日本語フォント取得失敗のような常時有効な警告は、以後の表示にも添える。欧文フォントの取得失敗の警告は、そのフォントを選んでいるあいだだけ常時警告に出し(1度だけ)、選び直すと消える(`removeStickyWarning`)。 - コピーのメッセージ: 成功は `ok`「図をクリップボードにコピーしました(PNG、{dpi} dpi、{幅} × {高さ} ピクセル)。」、非対応のブラウザは `warning`「このブラウザでは図をクリップボードにコピーできません。「保存する」で PNG を保存してください。」、ブラウザが書き込みを拒否したときは `error`「クリップボードにコピーできませんでした。ページをクリックしてから、もう一度試してください。」。Python 側の失敗(データ無し、保存設定の不正、画素数、数式など)は保存と同じ表示で、上の拒否のメッセージは重ねない。 @@ -220,6 +243,12 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 実フォントを jsDelivr から取得するテストがある(`test_latin_font.py` の一部、`test_save_section.py` の PDF 埋め込みなど。インターネット接続が必要)。`test_font.py`(日本語フォントの失敗・停止・遅延・キャッシュ)は、matplotlib 同梱の小さな TTF を `page.route` で代役にして、5.7 MB の取得を避ける。 - クリップボードのテストは `page.context.grant_permissions(["clipboard-read", "clipboard-write"])` を許可してから、`navigator.clipboard.read()` で画像を読み戻す。 - `helpers.py` の Phase 3 用ヘルパー: `natural_size`、`act_and_wait_render`、`series_color`、`palette_grid_colors`、`ARIMO_GLOB` / `TINOS_GLOB`、`image_size`、`save_bytes`、`set_save_dpi`、`latin_font_state` / `wait_latin_state`、`copy_state` / `wait_copy_state`、`read_clipboard_png_size`、`wait_code_contains`。 +- Phase 4 で加えた単体テスト: + - `test_load_options.py`: `skip_lines` / `thousands` / `decimal` / `comment`(csv・xlsx)、前置き行(上限20行)と、指定漏れの ParserError のヒント、xlsx のシート(先頭・無い名前・`ExcelFile` の再利用と id ごとのキャッシュ)、区切り文字との衝突、区切り指定漏れの警告、decimal == 区切り文字の警告(引用符付きの値は読める)、`read_kwargs` が loader と codegen の共通定義であること、生成した読込部と loader の結果の一致、すべての読込引数が明記されること、シート名・ファイル名・コメント記号の注入、A7 のヒント、`split_lines`。 + - `test_datetime.py`: 書式ごとの認識、タイムゾーン(同一オフセット・`Z`・混在で UTC)、前後の空白、90% の境界と先頭 100 個、書式の選び方、警告(例は最大3件)、数値っぽい文字列・曖昧な書式は認識しない、`parseDates` false と Excel の日時セル、loader と生成コードの変換式の一致、日時軸・日時の棒グラフ・文字列の X(25 個超で間引き)の生成コード、`mdates` / `ticker` の import が必要なときだけであること、日時の書式と列名の注入、1万行の読込。 + - `test_multi_source.py`: `Session` の追加・置き換え・削除・全消去と作業フォルダ、一部の失敗、同名ファイル、データ元の解決とエラー、`plan_plot` のデータ元ごとの処理(`skipRows`、Y の自動割り当て、棒グラフは同じデータ元、日時の規則)、2ファイルのスクリプト(`DATA_FILE_N` / `dfN`、使うファイルだけ、番号は位置)、自動描画用の注入、悪意のあるファイル名。`test_paste_tsv.py`: `.tsv`(タブ区切り・空白を含む値・区切りの上書き)と、貼り付けたデータの読込部・使い方のコメント。`test_settings.py`: v3 の既定値と検証、v2 → v3 の移行(`parseDates` false)。`test_js_modules.py`: 新しい系列の `source` が空、`version` が 3。 +- Phase 4 で加えた E2E: `test_drop_paste.py`(ドロップの読込とオーバーレイの出入り、文字列のドラッグは無反応、複数ファイル、未対応拡張子、`.tsv`、貼り付け(`#pasteArea`・input・ページ上)、表でない内容の警告、貼り付けたデータのコードと保存、編集モードで `pasted_data.tsv` を読めること、読込済みのときのページ上の貼り付けは置き換えず警告・`#pasteArea` は置き換え)、`test_load_options.py`(シート切替、前置き行の表示、ヨーロッパ形式の数値、コメント記号、桁区切りの警告とヒント、コードの追従、編集モードで読込設定を変えてもコードが変わらない、「最新でない」印)、`test_datetime.py`(日時軸、書式のプリセットと任意、不正な書式、X の範囲指定のエラー、`parseDates` を外す、日本語の日付の棒グラフ、Excel の日時セル、日時とそれ以外の X の混在、タイムゾーン混在の警告)、`test_multi_files.py`(一覧とデータ元の表示、2ファイルの系列、データ元の切替、データ確認の切替、削除と系列の戻し、置き換え、同名、上限、ドロップの追加・置き換え区画、一部の失敗と再試行、シート変更は該当ファイルだけ、編集モードで2ファイルを読む、貼り付けは全置き換え、全削除で初期状態、列が変わらない読込で系列カードを保つ)。`test_codegen_consistency.py` は、前置き行、ヨーロッパ形式、日時の線、xlsx の日時、2枚目のシート、2つの CSV、CSV と xlsx の2枚目、貼り付けたデータの各ケースも一致確認の対象にした。 + - `helpers.py` の Phase 4 用ヘルパー: `change_load_option` / `set_load_options`、`sheet_options`、`code_is_stale`、`drag_event` / `drop_files` / `drop_overlay_visible`、`paste_text`、`add_files`、`file_row_ids` / `file_row_states`、`settings_files`、`add_series_with_source`、`remove_file`。 - E2E は要素を id だけで選ぶ。`index.html` の id は安定させ、変えるときはテストも直す。アプリ固有の待機・操作は `tests/e2e/helpers.py` にまとめる。 - テストデータは `tests/fixtures/` の合成データだけ(公開リポジトリなので実データを置かない)。 - テスト用サーバー(`tests/e2e/server.py`)は listen backlog を 128 に広げている。既定の 5 だと、Chromium が ES モジュールを一斉に取得したときに接続が切れて、アプリが起動しないことがある。 @@ -229,7 +258,7 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - CI(`.github/workflows/tests.yml`): push で単体テスト、プルリクエストで E2E。E2E は Jekyll でビルドした `_site` を `/matplotlib_gui/` 配下で配信して検証する。 - Jekyll は `_` で始まるファイルを既定で配信しない。`__init__.py` は `_config.yml` の `include` で明示している。開発用ファイル(`docs/`、`tests/`、`AGENTS.md`、`pyproject.toml`、`uv.lock`、`.github/` など)は `exclude`。 -- `test_cdn_pinning.py` は、フォント(`@expo-google-fonts/*@x.y.z`、`@fontsource/*@x.y.z`)の jsDelivr URL と、バージョン付きのパスの `matplotlib.org`(取得しない説明リンク)を許可する。新しい JS モジュール(`js/units.js` など)は静的ファイルとしてそのまま公開される(`pyscript.toml` は Python ファイルだけ)。 +- `test_cdn_pinning.py` は、フォント(`@expo-google-fonts/*@x.y.z`、`@fontsource/*@x.y.z`)の jsDelivr URL と、バージョン付きのパスの `matplotlib.org`(取得しない説明リンク)を許可する。新しい JS モジュール(`js/units.js`、`js/ui/dropPaste.js`、`js/ui/fileList.js` など)は静的ファイルとしてそのまま公開される(`pyscript.toml` は Python ファイルだけ)。Phase 4 では新しい Python モジュールも CDN も足していない(`pyscript.toml` と CI の変更なし)。 - 新しい Python モジュールを追加したら、`pyscript.toml` の `[files]` に必ず追加する(`tests/unit/test_pages_publish.py` が参照ファイルの公開を確認する)。 - matplotlib 3.10 では `tight_layout()` 後に `PlaceHolderLayoutEngine` が残り、`savefig` が余分に全体を再描画する。`runner.drop_placeholder_layout_engine` がこれを外している(出力は同一)。 @@ -247,7 +276,7 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 生成スクリプトの最後は `fig.savefig(...)`(GUI の「保存」と同じ形式・解像度)と `plt.show()`。 - 編集は明示的な「編集」ボタンで始める。`useCustomCode`(「使用する」)は廃止した。編集中は GUI の変更で、コードも図も変わらない。 - 棒グラフ(1系列・複数系列)は、カテゴリの目盛ラベルを保つ(Phase 1 の不具合を修正)。複数系列は、棒の位置にラベルを付ける(25個を超えたら間引く)。 -- line / scatter で X が数値でない列(文字列・日時)のとき、Phase 1 と同じ見た目を保つため、生成コードに `ax.set_xscale("linear")` を入れる(目盛は位置の番号になる)。Phase 4 の D5 で見直した: 日時の列は日時の軸(`AutoDateLocator` + `ConciseDateFormatter`)にし、文字列の列は `set_xscale("linear")` を入れず、目盛に値を出す(25 種を超えると `MaxNLocator` で間引く)。 +- line / scatter で X が数値でない列(文字列・日時)のとき、Phase 1 と同じ見た目を保つため、生成コードに `ax.set_xscale("linear")` を入れる(目盛は位置の番号になる)。D5(日付軸)で見直す(Phase 4)。→ Phase 4 でやめた(§13.3)。 - ファイル選択欄に、選んだファイル名を欄の中に表示する(`#fileNameLabel`。`input` は選択のたびに空に戻すため、JS が表示する)。 ### 13.2 Phase 3(2026-10-08) @@ -264,3 +293,17 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に整理し、各区分を `
` で折りたためるようにする。折りたたみの状態は localStorage に保持する。「注釈・解析」の区分は Phase 5 まで作らない。 - 設定スキーマを version 2 にした。v1 からの移行では、v1 の保存が常に 120 dpi だったため `save.dpi` を 120 にする。 - 「描画設定: 先頭からスキップする行数」の欄名を「描画から除外する先頭行数」に改めた(「描画設定」の区分が無くなったため)。 + +### 13.3 Phase 4(2026-10-08) + +- D6(複数ファイル)も Phase 4 に含める。読込設定(区切り文字、ヘッダ、読み飛ばす行数、桁区切り、小数点、コメント記号、日時の認識)は全ファイル共通で、シートだけファイルごとに選ぶ。棒グラフは、すべての系列が同じデータ元のときだけ描ける。同じ名前のファイルは置き換えとして扱う(作業フォルダと生成コードでファイルを名前で区別するため)。読み込めるのは 10 個まで。 +- 設定スキーマを version 3 にした。v2 からの移行では `load.parseDates` を false にする(v2 までの出力を保つため。新しく作る設定の既定値は true)。 +- ドラッグ&ドロップはページ全体で受ける。データを読み込み済みのときは、「置き換えて読み込む」「追加して読み込む」のどちらにドロップするかで選ぶ。 +- 貼り付けたデータは `pasted_data.tsv`(UTF-8、タブ区切り)というファイルとして扱い、生成コードはこのファイルを読む(データはスクリプトに埋め込まない)。「貼り付けたデータを保存」で同じ内容を保存し、スクリプトと同じフォルダに置いてもらう。あわせて `.tsv` を対応する拡張子に加えた(既定の区切り文字はタブ)。 +- 「ヘッダより前に読み飛ばす行数」は「データ読み込み」の区分に、「描画から除外する先頭行数」は「系列」の区分に置き、補足で何を数えるかを示す。読み飛ばした行は、データ確認タブの表の上に枠で囲んで表示する(描画から除外する行のグレー表示とは見た目を分ける)。 +- 桁区切り・小数点・コメント記号は pandas の `thousands` / `decimal` / `comment` で扱い、生成コードの読込部には、すべての読込引数を既定値も含めて明記する。区切り文字の自動判定は加えない(指定漏れが疑われるときは警告で案内する)。 +- 日時の列の認識(規則は §7): 年が先頭の書式だけ。月日の順が曖昧な書式、時刻だけ、年月だけは認識しない。読めない値は欠損にして警告する。タイムゾーンは、同じオフセットなら書かれた時刻のまま、混在していたら UTC にそろえる(警告する)。Excel の日付セルは常に日時として扱う。 +- 日時の X 軸の目盛は、自動(`AutoDateLocator` と `ConciseDateFormatter`)か、指定した書式(`DateFormatter`)にする。X が日時のときは、X 軸の対数と範囲の指定はできない(日本語のエラーで案内する)。棒グラフで X が日時のときは、表示書式で文字列にしてカテゴリとして並べる。 +- Phase 2 で入れた `ax.set_xscale("linear")`(X が数値でない列のとき)はやめた。日時の列は日時軸にし、それ以外の文字列の列は目盛に値を出す(25個を超えるときは間引く)。 +- Pythonコードタブが GUI 同期のとき、エラーで最新の設定のコードを作れなかったら、表示中のコードが最新でないことをタブ内に表示する(`#codeStaleNote`)。 +- レビュー後の決定: 小数点と区切り文字が同じ記号のときはエラーにせず警告にする(引用符で囲まれた値は読めるため)。ページ上の貼り付け(`#pasteArea` 以外)は、読み込み済みのデータを置き換えない(未読込のときだけ読み込む。置き換えは「表を貼り付け」欄で行う)。最後のファイルを削除したら、起動直後の状態に戻す。 diff --git a/README.md b/README.md index 37ac44e..9924afa 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,29 @@ # Matplotlib GUI -CSV / TXT / XLSX のデータから、ブラウザ上の操作だけで matplotlib の図を作るツールです。サーバーを使わず、Python(PyScript / Pyodide)をブラウザの中で実行します。 +CSV / TSV / TXT / XLSX のデータから、ブラウザ上の操作だけで matplotlib の図を作るツールです。サーバーを使わず、Python(PyScript / Pyodide)をブラウザの中で実行します。 ## 使い方 https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` を `file://` で直接開く使い方には対応していません。 -1. 「入力ファイル」で `.csv` / `.txt` / `.xlsx` を選びます。 +1. 「入力ファイル」で `.csv` / `.txt` / `.tsv` / `.xlsx` を選ぶか、ページにファイルをドラッグ&ドロップします。Excel などでコピーした表は、「表を貼り付け」欄に貼り付けても読み込めます。 2. 読み込みと描画は自動で行われます(「描画する」ボタンはありません)。設定を変えると、すぐ再描画されます。 -3. 「描画系列」でX列・Y列・色などを選び、必要なら系列を追加します。 +3. 「描画系列」でX列・Y列・色などを選び、必要なら系列を追加します。複数のファイルを読み込んだときは、系列ごとに「データ元」を選びます。 4. 「体裁」でフォント・図のサイズ・プリセットなどを整えます。 5. 「保存」でファイル名・形式・DPI を選び、「保存する」で書き出すか、「クリップボードにコピー」で他のアプリに貼り付けます。 左の設定パネルは「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に分かれていて、見出しを押すと折りたためます(折りたたみの状態は、このブラウザに覚えておきます)。 +### データの読み込み + +- ドラッグ&ドロップ: ページのどこにでも落とせます。すでにデータを読み込んでいるときは、「置き換えて読み込む」「追加して読み込む」のどちらかの枠に落として選びます。 +- 貼り付け: 貼り付けた表は `pasted_data.tsv`(UTF-8、タブ区切り)として扱います。「貼り付けたデータを保存(.tsv)」で保存でき、生成した `.py` を動かすときは、このファイルを同じフォルダに置きます。「表を貼り付け」欄に貼り付けると、読み込み済みのデータは置き換わります。 +- `.xlsx` は、複数のシートがあるとき「シート」で選べます(ファイルごとに選べます)。 +- 「ヘッダより前に読み飛ばす行数」: ファイルの先頭にある説明の行など、ヘッダ行より前の行を読み飛ばします。読み飛ばした行は、データ確認タブの表の上に枠で表示します。これとは別に、「系列」の「描画から除外する先頭行数」は、ヘッダの後のデータ行を描画から除外します(データ確認タブでグレーになります)。 +- 「桁区切り」「小数点」「コメント記号」: `1,234` や `1.234,5`(ヨーロッパ形式)、`#` で始まる説明の行などを読めます。数値に変換できない値があるときは、該当しそうな設定を警告で案内します。区切り文字は自動判定しないので、`;` やタブ区切りのファイルは「区切り文字」で指定してください。 +- 日時の列: `2026-01-31`、`2026/01/31 13:45`、`2026年1月31日`、ISO 8601(`2026-01-31T13:45:00+09:00`)など、年が先頭の書式の列を自動で日時として認識します(「日時の列を自動で認識する」で切り替え)。X に日時の列を選ぶと日時の軸になり、軸・目盛の区分で表示書式(`2024-01-31`、`01/31 13:45` など。任意の書式も可)を選べます。X が日時のときは、X軸の対数と範囲の指定はできません。棒グラフでは、日時を文字にして並べます。月日の順が曖昧な `01/02/2024` は認識しません。 +- 複数ファイル: 最大 10 個まで読み込めます(同じ名前のファイルは置き換え)。読込設定は全ファイル共通で、シートだけファイルごとに選びます。棒グラフは、すべての系列を同じデータ元にしたときだけ描けます。 + 対応する文字コードは UTF-8、UTF-8(BOM付き)、Shift_JIS(CP932)、EUC-JP です。判定した文字コードは、読み込み後に画面へ表示されます。 ## 主な機能 @@ -29,8 +39,8 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` - 欧文フォント: 標準(Noto Sans JP)、Arimo(Arial 互換)、Tinos(Times 互換)。英数字と記号だけを選んだフォントで書き、日本語は Noto Sans JP で書きます(Tinos のときは数式も Times 系の字形になります) - クリップボードへのコピー: プレビュー中の図を、保存 DPI の PNG として画像でコピーします(背景透過は保存設定に従います)。対応ブラウザは Chrome / Edge 98 以降、Safari 13.1 以降、Firefox 127 以降で、https で開いている必要があります。非対応のときは「保存する」で PNG を保存してください - 数式・上付き・下付き: タイトル・軸ラベル・凡例名で、`$` で囲んだ部分が数式になります。例: `m$^2$`(上付き)、`H$_2$O`(下付き)、`$\alpha$`(ギリシャ文字)。`$` を文字として書くときは `\$` とします。書き方を間違えると、日本語のエラーが表示されます -- データ確認タブ: 先頭100行を表示し、描画から除外される先頭行をグレーで表示 -- Pythonコードタブ: 設定から生成した matplotlib のスクリプトを表示(下記) +- データ確認タブ: 先頭100行を表示し、描画から除外される先頭行をグレーで、読み飛ばした行(ヘッダより前)を枠で表示。複数ファイルのときは表示するファイルを切り替えられます +- Pythonコードタブ: 設定から生成した matplotlib のスクリプトを表示(下記)。エラーで最新の設定のコードを作れないときは、表示中のコードが最新でないことをタブ内に表示します - 数値に変換できず除外した値の警告、日本語のエラーメッセージ ## Pythonコードタブ