From 44b9c1a70e46c03a72d9884725ca129baadaa64b Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 03:07:47 +0900 Subject: [PATCH 01/16] Record the Phase 3 decisions Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 18 +++++++++++++++++- docs/REQUIREMENTS.md | 21 +++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 800add7..c31f294 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,9 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - push やプルリクエストの作成は、オーナーの確認を取ってから行う。 - ローカル環境のパスを、リポジトリ内の文書やコードに書かない。 -## 13. Phase 2 でオーナーが決めたこと(2026-10-08) +## 13. オーナーが決めたこと + +### 13.1 Phase 2(2026-10-08) - 旧「Pythonコード(beta)」タブの名前空間(`get_plot_data`、従来形式の settings dict、`plotted_count`)は、互換を残さずに廃止した。 - 生成スクリプトの最後は `fig.savefig(...)`(GUI の「保存」と同じ形式・解像度)と `plt.show()`。 @@ -196,3 +198,17 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 棒グラフ(1系列・複数系列)は、カテゴリの目盛ラベルを保つ(Phase 1 の不具合を修正)。複数系列は、棒の位置にラベルを付ける(25個を超えたら間引く)。 - line / scatter で X が数値でない列(文字列・日時)のとき、Phase 1 と同じ見た目を保つため、生成コードに `ax.set_xscale("linear")` を入れる(目盛は位置の番号になる)。D5(日付軸)で見直す(Phase 4)。 - ファイル選択欄に、選んだファイル名を欄の中に表示する(`#fileNameLabel`。`input` は選択のたびに空に戻すため、JS が表示する)。 + +### 13.2 Phase 3(2026-10-08) + +- 保存 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 にする。 +- 日本語フォントを、Noto Sans CJK JP(OTF、CFF 形式)から、同じデザインの TrueType 版 Noto Sans JP に替えた。matplotlib 3.10 は CFF 形式のフォントを `pdf.fonttype=42` で正しく埋め込めない(TrueType 用の FontFile2 に CFF を入れ、PDF が壊れる)ため。 +- PDF は Type 42(`pdf.fonttype=42`)で書き出す。SVG の文字は「パスに変換」(既定)と「テキストのまま」を選べる。 +- スタイルプリセット(標準 / 論文1段組 / 論文2段組 / スライド16:9)は「適用」ボタンで値をまとめて書き込むだけで、設定にプリセット名を残さない。点サイズは、点を表示している系列(scatter、または点サイズを入力した line)だけ書き換える。適用後に追加した系列は、直前に適用したプリセットの値を使う(セッション中だけ JS が覚える)。 +- カラーパレット(UD カラー / tab10 / グレースケール)を切り替えたら、すべての系列を、系列の順に新しいパレットの色で塗り直す(個別に選んだ色も上書きする)。 +- クリップボードにコピーする画像(C5)は、保存 DPI と同じ解像度の PNG。背景透過は保存設定に従う。 +- 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に整理し、各区分を `
` で折りたためるようにする。折りたたみの状態は localStorage に保持する。「注釈・解析」の区分は Phase 5 まで作らない。 +- 設定スキーマを version 2 にした。v1 からの移行では、v1 の保存が常に 120 dpi だったため `save.dpi` を 120 にする。 +- 「描画設定: 先頭からスキップする行数」の欄名を「描画から除外する先頭行数」に改めた(「描画設定」の区分が無くなったため)。 diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index d2ee263..2ae10ae 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -39,6 +39,15 @@ - 「実装ファイルは `index.html` 単体」「`index.html` を開くだけで動く」は廃止し、GitHub Pages 配信を前提にする。 - §12 の作業対象は絶対パスで書かれており、しかも実際の場所と合っていない。絶対パスはやめて「このリポジトリ配下のみ」と書く(ローカル環境のパスを公開リポジトリに載せないため)。 +### 2.3 Phase 3 の決定事項(2026-10-08 にユーザーが決定) + +- U1–U3 は 10 章の既定案のとおりにする。 +- 図サイズの単位を切り替えたら、入力欄の数値を換算する(図の大きさは変えない。例: 8 inch → 20.32 cm)。 +- カラーパレットを切り替えたら、すべての系列を、系列の順に新しいパレットの色で塗り直す(個別に選んだ色も上書きする)。 +- クリップボードにコピーする画像(C5)は、保存 DPI と同じ解像度の PNG にする。背景透過は保存設定に従う。 +- 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁(フォント・サイズ・プリセット)」「保存」の区分に整理し、各区分を折りたためるようにする。「注釈・解析」の区分は、中身が入る 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水準は含む)。 + --- ## 3. 現状の構成(2026-10-07 時点) @@ -145,9 +154,9 @@ docs/REQUIREMENTS.md 本書 | ID | 優先度 | 要件・受け入れ条件 | |---|---|---| -| C1 | Must | PNG/JPG の保存 DPI を指定できる(72 / 150 / 300 / 600、または任意の値)。既定値は 300(未決事項 U1) | -| C2 | Must | 図のサイズを inch / cm / mm で指定できる。PDF は `pdf.fonttype=42` で書き出し、Illustrator などで文字を編集できるようにする。SVG では、文字をテキストのまま残すかパスに変換するかを選べる | -| C3 | Should | 欧文フォントを選べる(Arial 互換、Times 互換など。未決事項 U3)。日本語には Noto Sans CJK JP を組み合わせる | +| C1 | Must | PNG/JPG の保存 DPI を指定できる(72 / 150 / 300 / 600、または任意の値)。既定値は 300(U1 で決定) | +| C2 | Must | 図のサイズを inch / cm / mm で指定できる。PDF は `pdf.fonttype=42` で書き出し、Illustrator などで文字を編集できるようにする。SVG では、文字をテキストのまま残すかパスに変換するかを選べる。※ Type 42 で正しく埋め込めるのは TrueType 形式のフォントだけなので、日本語フォントを TrueType 版の Noto Sans JP に替えた(2.3 を参照) | +| C3 | Should | 欧文フォントを選べる(Arial 互換、Times 互換など。U3 で決定)。日本語には Noto Sans JP(Noto Sans CJK JP と同じデザインの TrueType 版。2.3 を参照)を組み合わせる | | C4 | Should | スタイルプリセットを用意する。例: 「論文1段組(幅8.5cm、8pt)」「論文2段組(幅17cm)」「スライド16:9」。選ぶと、図のサイズ、フォントサイズ、線幅、マーカーサイズをまとめて設定する。カラーパレット(UD カラー、tab10、グレースケールなど)も切り替えられる | | C5 | Should | プレビュー中の図を、クリップボードに画像としてコピーできる | | C6 | Could | ラベルで数式・上付き・下付き(matplotlib の mathtext。例: `m$^2$`、`$\alpha$`)を使えることを画面上で案内する | @@ -236,9 +245,9 @@ docs/REQUIREMENTS.md 本書 | ID | 論点 | 既定案 | |---|---|---| -| U1 | 保存 DPI の既定値 | 300 | -| U2 | 図サイズの既定の単位 | 既定は inch(現行の 8×6 を維持)。cm と mm も選べる | -| U3 | 追加する欧文フォント | Arimo(Arial 互換)、Tinos(Times 互換)。どちらも Apache 2.0 ライセンスで、バージョン固定の CDN から取得する | +| U1 | 保存 DPI の既定値 | **決定(2026-10-08)**: 300 | +| U2 | 図サイズの既定の単位 | **決定(2026-10-08)**: 既定は inch(現行の 8×6 を維持)。cm と mm も選べる | +| U3 | 追加する欧文フォント | **決定(2026-10-08)**: Arimo(Arial 互換)、Tinos(Times 互換)。バージョン固定の CDN から取得する。※ ライセンスは、どちらも SIL Open Font License 1.1(当初 Apache 2.0 と書いたのは誤り。現行版は OFL 1.1) | | U4 | グラフ種別を追加する順番 | 5.6 の G7 → G8 の順 | | U5 | scipy を導入するか | 任意関数のフィッティング(G10)を使うときだけ読み込む | | U6 | GitHub Actions を使うか(F5) | 使う | From 517e8e5354b60737cc0a5f77d4da228ea7ec8f33 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 03:13:04 +0900 Subject: [PATCH 02/16] Switch the Japanese font to the TrueType Noto Sans JP Co-Authored-By: Claude Opus 5.5 --- js/font-cache.js | 23 ++++++++++++++++++++--- py/mplgui/fonts.py | 4 ++-- tests/e2e/test_excel_lazy.py | 2 +- tests/e2e/test_font.py | 6 +++--- tests/unit/test_cdn_pinning.py | 1 + tests/unit/test_codegen.py | 2 +- tests/unit/test_fonts.py | 4 ++-- 7 files changed, 30 insertions(+), 12 deletions(-) diff --git a/js/font-cache.js b/js/font-cache.js index 567023f..76b8bd1 100644 --- a/js/font-cache.js +++ b/js/font-cache.js @@ -1,7 +1,11 @@ -// 日本語フォント(Noto Sans CJK JP)の取得。Cache Storage に保存し、再訪問ではダウンロードしない。 +// 日本語フォント(Noto Sans JP、TrueType 版、約5.7 MB)の取得。Cache Storage に保存し、再訪問ではダウンロードしない。 // - セッション中の取得は最大1回(失敗しても再試行しない)。 // - Cache Storage が使えない環境では通常の fetch にフォールバックする。 -export const FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; +// TrueType 版を使う理由: matplotlib は CFF 形式の OTF を pdf.fonttype=42 で正しく埋め込めない(PDF が壊れる)ため。 +// ライセンス: SIL OFL 1.1(https://cdn.jsdelivr.net/npm/@expo-google-fonts/noto-sans-jp@0.4.4/LICENSE_FONT) +export const FONT_URL = "https://cdn.jsdelivr.net/npm/@expo-google-fonts/noto-sans-jp@0.4.4/400Regular/NotoSansJP_400Regular.ttf"; +// 以前に使っていた約16 MB の OTF。同じキャッシュに残っていれば消す(ベストエフォート) +const LEGACY_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; export const FONT_CACHE_NAME = "mplgui-fonts-v1"; // テスト専用の上書き: E2E が addInitScript で window.__MPLGUI_TEST_OVERRIDES__ = {fontStallMs, fontFirstRenderWaitMs} を @@ -24,6 +28,18 @@ const openCache = async () => { } }; +// 古い OTF のキャッシュを1回だけ削除する(失敗しても無視する) +let legacyCleaned = false; +const cleanupLegacy = async (cache) => { + if (!cache || legacyCleaned) return; + legacyCleaned = true; + try { + await cache.delete(LEGACY_FONT_URL); + } catch (_) { + // 無視 + } +}; + const readCached = async (cache) => { if (!cache) return null; try { @@ -97,6 +113,7 @@ export const loadFont = (onProgress = () => {}) => { if (!fontPromise) { fontPromise = (async () => { const cache = await openCache(); + await cleanupLegacy(cache); const cached = await readCached(cache); if (cached) { onProgress(100); @@ -107,7 +124,7 @@ export const loadFont = (onProgress = () => {}) => { const bytes = await download(onProgress); if (cache) { try { - await cache.put(FONT_URL, new Response(bytes, { headers: { "content-type": "font/otf" } })); + await cache.put(FONT_URL, new Response(bytes, { headers: { "content-type": "font/ttf" } })); } catch (_) { // 保存に失敗しても今回のセッションでは使える } diff --git a/py/mplgui/fonts.py b/py/mplgui/fonts.py index 416b003..e305533 100644 --- a/py/mplgui/fonts.py +++ b/py/mplgui/fonts.py @@ -9,8 +9,8 @@ # 日本語フォントの優先順(唯一の定義)。codegen が生成するスクリプトの rcParams もこの並びを使う。 SANS_SERIF_PRIORITY = [ - "Noto Sans CJK JP", "Noto Sans JP", + "Noto Sans CJK JP", "IPAexGothic", "Yu Gothic", "Hiragino Sans", @@ -33,7 +33,7 @@ def register_font_file(path) -> None: configure_rcparams() -def register_font_bytes(data: bytes, filename: str = "NotoSansCJKjp-Regular.otf") -> bool: +def register_font_bytes(data: bytes, filename: str = "NotoSansJP-Regular.ttf") -> bool: """フォントのバイト列を登録する。登録は1セッション1回だけ(2回目以降は何もしない)。""" global _registered if _registered: diff --git a/tests/e2e/test_excel_lazy.py b/tests/e2e/test_excel_lazy.py index b8f56cb..9bfdb81 100644 --- a/tests/e2e/test_excel_lazy.py +++ b/tests/e2e/test_excel_lazy.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.e2e -FONT_GLOB = "**/NotoSansCJKjp-Regular.otf" +FONT_GLOB = "**/NotoSansJP_400Regular.ttf" EXCEL_MARKERS = ("openpyxl", "et_xmlfile") diff --git a/tests/e2e/test_font.py b/tests/e2e/test_font.py index 6f43372..245676e 100644 --- a/tests/e2e/test_font.py +++ b/tests/e2e/test_font.py @@ -19,8 +19,8 @@ pytestmark = pytest.mark.e2e -FONT_GLOB = "**/NotoSansCJKjp-Regular.otf" -# 16MB の実フォントの代わりに、matplotlib 同梱の小さな TTF を CDN の代役として配信する(登録・Cache Storage の経路は本物と同じ) +FONT_GLOB = "**/NotoSansJP_400Regular.ttf" +# 5.7MB の実フォントの代わりに、matplotlib 同梱の小さな TTF を CDN の代役として配信する(登録・Cache Storage の経路は本物と同じ) STAND_IN_FONT = Path(matplotlib.get_data_path()) / "fonts" / "ttf" / "DejaVuSans.ttf" @@ -59,7 +59,7 @@ def test_font_failure_warns_once_and_plot_still_renders(page, app_url, fixtures_ def test_font_is_served_from_cache_storage_on_reload(page, app_url, console_log): requests = [] - page.on("request", lambda r: requests.append(r.url) if r.url.endswith("NotoSansCJKjp-Regular.otf") else None) + page.on("request", lambda r: requests.append(r.url) if r.url.endswith("NotoSansJP_400Regular.ttf") else None) page.route( FONT_GLOB, lambda route: route.fulfill(status=200, content_type="font/ttf", body=STAND_IN_FONT.read_bytes()), diff --git a/tests/unit/test_cdn_pinning.py b/tests/unit/test_cdn_pinning.py index 19a2ac6..58e70d7 100644 --- a/tests/unit/test_cdn_pinning.py +++ b/tests/unit/test_cdn_pinning.py @@ -77,6 +77,7 @@ def test_checker_rejects_unpinned(url): "url", [ "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf", + "https://cdn.jsdelivr.net/npm/@expo-google-fonts/noto-sans-jp@0.4.4/400Regular/NotoSansJP_400Regular.ttf", "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-jp@5.3.0/400.css", "https://pyscript.net/releases/2026.7.3/core.js", "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", diff --git a/tests/unit/test_codegen.py b/tests/unit/test_codegen.py index 7e456b7..2706bcb 100644 --- a/tests/unit/test_codegen.py +++ b/tests/unit/test_codegen.py @@ -380,7 +380,7 @@ def test_font_list_comes_from_fonts_module(tmp_path, data): b = Built(tmp_path, data, None, [S(1)]) expected = "[" + ", ".join(literal(n) for n in SANS_SERIF_PRIORITY) + "]" assert f'plt.rcParams["font.sans-serif"] = {expected}' in b.script.text - assert SANS_SERIF_PRIORITY[0] == "Noto Sans CJK JP" + assert SANS_SERIF_PRIORITY[0] == "Noto Sans JP" def test_script_only_imports_pandas_matplotlib_numpy(tmp_path, data): diff --git a/tests/unit/test_fonts.py b/tests/unit/test_fonts.py index 638d466..6fdfbd6 100644 --- a/tests/unit/test_fonts.py +++ b/tests/unit/test_fonts.py @@ -15,8 +15,8 @@ def test_configure_rcparams_uses_priority_list(): assert plt.rcParams["font.family"] == ["sans-serif"] and plt.rcParams["axes.unicode_minus"] is False -def test_priority_starts_with_noto_sans_cjk_jp(): - assert fonts.SANS_SERIF_PRIORITY[:2] == ["Noto Sans CJK JP", "Noto Sans JP"] +def test_priority_starts_with_noto_sans_jp(): + assert fonts.SANS_SERIF_PRIORITY[:2] == ["Noto Sans JP", "Noto Sans CJK JP"] assert fonts.SANS_SERIF_PRIORITY[-1] == "DejaVu Sans" From a8b7c4c7a47caa4f2bd1192a71e5e6c8c5746dac Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 03:14:11 +0900 Subject: [PATCH 03/16] Add settings schema version 2 with migration Co-Authored-By: Claude Opus 5.5 --- js/defaults.js | 8 +- py/mplgui/settings.py | 91 ++++++++++++++++++---- tests/unit/test_settings.py | 148 +++++++++++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 21 deletions(-) diff --git a/js/defaults.js b/js/defaults.js index 8a1f8c2..a7a0755 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 = 1; +export const SCHEMA_VERSION = 2; // UDカラーセット(系列の既定色の候補。この順で未使用の色を割り当てる) export const PALETTE = [ @@ -39,7 +39,9 @@ export const DEFAULT_SETTINGS = { xColumn: "", title: "", fontSize: 15, - figure: { width: 8, height: 6 }, + figure: { width: 8, height: 6, unit: "in" }, + latinFont: "default", + palette: "ud", legend: { location: "best" }, grid: { major: false, minor: false }, margins: { left: null, right: null, bottom: null, top: null }, @@ -50,5 +52,5 @@ export const DEFAULT_SETTINGS = { y2: { label: "", scale: "linear", min: null, max: null }, }, series: [DEFAULT_SERIES], - save: { filename: "", format: "png", transparent: false }, + save: { filename: "", format: "png", transparent: false, dpi: 300, svgText: "path" }, }; diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py index 685b822..01ded2c 100644 --- a/py/mplgui/settings.py +++ b/py/mplgui/settings.py @@ -1,4 +1,4 @@ -"""設定オブジェクト(JSON, camelCase, version=1)の既定値・検証・型付きデータクラス。 +"""設定オブジェクト(JSON, camelCase, version=2)の既定値・検証・型付きデータクラス。 JS 側の js/defaults.js と default_settings() は同一でなければならない(単体テストで比較する)。 このモジュールは js / pyodide / pyscript に依存しない。 @@ -14,12 +14,20 @@ from .errors import UserError -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 PLOT_TYPES = ("line", "scatter", "bar") LINE_STYLES = ("solid", "dashed", "dashdot", "dotted") SCALES = ("linear", "log") SAVE_FORMATS = ("png", "jpg", "svg", "pdf") +FIGURE_UNITS = ("in", "cm", "mm") +LATIN_FONTS = ("default", "arimo", "tinos") +PALETTES = ("ud", "tab10", "gray") # 色そのものは JS 側だけが持つ。Python は選択肢を検証するだけ +SVG_TEXT_MODES = ("path", "text") +DEFAULT_DPI = 300 +MIN_DPI = 50 +MAX_DPI = 1200 +_INCHES_PER_UNIT = {"in": 1.0, "cm": 1 / 2.54, "mm": 1 / 25.4} LEGEND_LOCATIONS = ( "best", "upper right", @@ -59,7 +67,9 @@ def default_settings() -> dict: "xColumn": "", "title": "", "fontSize": 15, - "figure": {"width": 8, "height": 6}, + "figure": {"width": 8, "height": 6, "unit": "in"}, + "latinFont": "default", + "palette": "ud", "legend": {"location": "best"}, "grid": {"major": False, "minor": False}, "margins": {"left": None, "right": None, "bottom": None, "top": None}, @@ -70,7 +80,7 @@ def default_settings() -> dict: "y2": {"label": "", "scale": "linear", "min": None, "max": None}, }, "series": [copy.deepcopy(_DEFAULT_SERIES)], - "save": {"filename": "", "format": "png", "transparent": False}, + "save": {"filename": "", "format": "png", "transparent": False, "dpi": DEFAULT_DPI, "svgText": "path"}, } @@ -93,8 +103,17 @@ class AxisSettings: @dataclass(frozen=True) class FigureSettings: - width: float = 8.0 + width: float = 8.0 # unit で指定した単位の値 height: float = 6.0 + unit: str = "in" + + @property + def width_in(self) -> float: + return self.width * _INCHES_PER_UNIT[self.unit] + + @property + def height_in(self) -> float: + return self.height * _INCHES_PER_UNIT[self.unit] @dataclass(frozen=True) @@ -116,6 +135,8 @@ class PlotSettings: title: str = "" font_size: float = 15.0 figure: FigureSettings = field(default_factory=FigureSettings) + latin_font: str = "default" + palette: str = "ud" legend_location: str = "best" grid_major: bool = False grid_minor: bool = False @@ -156,6 +177,8 @@ class SaveSettings: filename: str = "" format: str = "png" transparent: bool = False + dpi: int = DEFAULT_DPI + svg_text: str = "path" @dataclass(frozen=True) @@ -264,10 +287,26 @@ def _merge(defaults: dict, given: Any) -> dict: MAX_FIGURE_INCH = 50 # これより大きいと画像サイズが過大になり描画できない -def _check_version(raw: dict) -> None: +def migrate_settings(raw: dict) -> dict: + """古いバージョンの設定を現在のバージョンにする(純粋。引数は変更しない)。 + + バージョンが無いものは現在のバージョンとして扱う。対応していないバージョンは UserError。 + - 1 → 2: 保存 DPI を追加する。v1 は常に 120 dpi で保存していたので、同じ出力になるよう 120 にする。 + 図の単位・欧文フォント・カラーパレット・SVG の文字は、既定値で補われる。 + """ version = raw.get("version", SCHEMA_VERSION) - if isinstance(version, bool) or version != SCHEMA_VERSION: + if isinstance(version, bool) or version not in (1, 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 + return out # ---------------------------------------------------------------- parsing @@ -275,8 +314,7 @@ def _check_version(raw: dict) -> None: def parse_load_settings(raw: dict | None) -> LoadSettings: """読み込み設定だけを検証する(描画設定の不備でファイル読込を止めないため)。""" - raw = raw if isinstance(raw, dict) else {} - _check_version(raw) + raw = migrate_settings(raw if isinstance(raw, dict) else {}) load = _merge(default_settings()["load"], raw.get("load")) return LoadSettings( delimiter=_string(load["delimiter"], "区切り文字"), @@ -338,8 +376,7 @@ def parse_settings(raw: dict | None) -> Settings: 不正な値は UserError(日本語・項目名つき)。 """ - raw = raw if isinstance(raw, dict) else {} - _check_version(raw) + raw = migrate_settings(raw if isinstance(raw, dict) else {}) d = default_settings() merged = { "load": _merge(d["load"], raw.get("load")), @@ -351,9 +388,11 @@ def parse_settings(raw: dict | None) -> Settings: p = merged["plot"] fig = p["figure"] if isinstance(p["figure"], dict) else {} + unit = _choice(fig.get("unit", "in"), "図のサイズの単位", FIGURE_UNITS) figure = FigureSettings( - width=_number(fig.get("width"), "図幅", default=8.0, exclusive_min=0, maximum=MAX_FIGURE_INCH), - height=_number(fig.get("height"), "図高さ", default=6.0, exclusive_min=0, maximum=MAX_FIGURE_INCH), + width=_figure_length(fig.get("width"), "図幅", 8.0, unit), + height=_figure_length(fig.get("height"), "図高さ", 6.0, unit), + unit=unit, ) legend = p["legend"] if isinstance(p["legend"], dict) else {} grid = p["grid"] if isinstance(p["grid"], dict) else {} @@ -378,6 +417,8 @@ def parse_settings(raw: dict | None) -> Settings: title=_string(p["title"], "タイトル").strip(), font_size=_number(p["fontSize"], "フォントサイズ", default=15.0, exclusive_min=0), figure=figure, + latin_font=_choice(p["latinFont"], "欧文フォント", LATIN_FONTS), + palette=_choice(p["palette"], "カラーパレット", PALETTES), legend_location=_choice(legend.get("location", "best"), "凡例位置", LEGEND_LOCATIONS), grid_major=_boolean(grid.get("major", False), "主目盛線を表示"), grid_minor=_boolean(grid.get("minor", False), "副目盛線を表示"), @@ -400,16 +441,36 @@ def parse_settings(raw: dict | None) -> Settings: return Settings(version=SCHEMA_VERSION, load=load, plot=plot, axes=axes, series=series, save=save) +def _figure_length(value: Any, label: str, default: float, unit: str) -> float: + """図幅・図高さ。上限は 50 inch を選んだ単位に直した値(in: 50, cm: 127, mm: 1270)。""" + num = _number(value, label, default=default, exclusive_min=0) + limit = MAX_FIGURE_INCH / _INCHES_PER_UNIT[unit] + if num > limit + 1e-9: + raise UserError(f"「{label}」は{_fmt(round(limit, 6))}以下の数値で入力してください(単位: {unit})。", field=label) + return num + + +def _dpi(value: Any) -> int: + label = "保存 DPI" + if _is_blank(value): + return DEFAULT_DPI + num = _to_float(value) + if num is None or not num.is_integer() or not MIN_DPI <= num <= MAX_DPI: + raise UserError(f"「{label}」は{MIN_DPI}〜{MAX_DPI}の整数で入力してください。", field=label) + return int(num) + + def _parse_save(sv: dict) -> SaveSettings: return SaveSettings( filename=_string(sv["filename"], "保存ファイル名").strip(), format=_choice(str(sv["format"]).strip().lower() if isinstance(sv["format"], str) else sv["format"], "保存形式", SAVE_FORMATS), transparent=_boolean(sv["transparent"], "背景を透過して保存"), + dpi=_dpi(sv["dpi"]), + svg_text=_choice(sv["svgText"], "SVG の文字", SVG_TEXT_MODES), ) def parse_save_settings(raw: dict | None) -> SaveSettings: """保存設定だけを検証する(編集モードの保存を、使わない描画設定の不備で止めないため)。""" - raw = raw if isinstance(raw, dict) else {} - _check_version(raw) + raw = migrate_settings(raw if isinstance(raw, dict) else {}) return _parse_save(_merge(default_settings()["save"], raw.get("save"))) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index d91ba0e..b03649e 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -8,6 +8,7 @@ SCHEMA_VERSION, default_settings, parse_load_settings, + migrate_settings, parse_save_settings, parse_settings, ) @@ -17,7 +18,7 @@ def test_defaults_are_complete_and_parse(): d = default_settings() - assert d["version"] == SCHEMA_VERSION == 1 + assert d["version"] == SCHEMA_VERSION == 2 assert set(d) == {"version", "load", "plot", "axes", "series", "save"} assert set(d["axes"]) == {"x", "y", "y2"} s = parse_settings(d) @@ -35,7 +36,7 @@ def test_default_settings_returns_fresh_objects(): def test_missing_keys_are_filled_from_defaults(): - s = parse_settings({"version": 1, "plot": {"title": " T "}, "series": [{"y": "__idx__2"}]}) + s = parse_settings({"version": 2, "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" @@ -142,7 +143,7 @@ def test_unknown_version_rejected(): raw = default_settings() del raw["version"] parse_settings(raw) # version 欠落は現行版として扱う - for bad in (2, 0, "1", None, True): + for bad in (3, 0, "1", None, True, "2"): raw = default_settings() raw["version"] = bad with pytest.raises(UserError) as info: @@ -202,3 +203,144 @@ def test_parse_does_not_mutate_input(): before = copy.deepcopy(raw) parse_settings(raw) assert raw == before + + +# ---------------------------------------------------------------- Phase 3: schema version 2 + + +def test_new_defaults(): + d = default_settings() + assert d["plot"]["figure"] == {"width": 8, "height": 6, "unit": "in"} + assert d["plot"]["latinFont"] == "default" and d["plot"]["palette"] == "ud" + assert d["save"] == {"filename": "", "format": "png", "transparent": False, "dpi": 300, "svgText": "path"} + s = parse_settings(d) + assert s.plot.figure.unit == "in" and s.plot.latin_font == "default" and s.plot.palette == "ud" + assert s.save.dpi == 300 and s.save.svg_text == "path" + + +def test_figure_inches_per_unit(): + raw = default_settings() + for unit, w, h, win, hin in (("in", 8, 6, 8, 6), ("cm", 8.5, 5, 8.5 / 2.54, 5 / 2.54), ("mm", 85, 50, 85 / 25.4, 50 / 25.4)): + raw["plot"]["figure"] = {"width": w, "height": h, "unit": unit} + fig = parse_settings(raw).plot.figure + assert fig.unit == unit and fig.width == w and fig.height == h # 設定は選んだ単位の値のまま + assert fig.width_in == pytest.approx(win) and fig.height_in == pytest.approx(hin) + + +@pytest.mark.parametrize( + "unit,ok,bad,limit", + [("in", 50, 50.5, "50"), ("cm", 127, 128, "127"), ("mm", 1270, 1271, "1270")], +) +def test_figure_limit_depends_on_unit(unit, ok, bad, limit): + raw = default_settings() + raw["plot"]["figure"] = {"width": ok, "height": ok, "unit": unit} + parse_settings(raw) + for key, label in (("width", "図幅"), ("height", "図高さ")): + raw["plot"]["figure"] = {"width": 5, "height": 5, "unit": unit, key: bad} + with pytest.raises(UserError) as info: + parse_settings(raw) + assert info.value.field == label + assert f"「{label}」は{limit}以下の数値で入力してください(単位: {unit})。" == info.value.message + raw["plot"]["figure"] = {"width": 0, "height": 5, "unit": unit} + with pytest.raises(UserError) as info: + parse_settings(raw) + assert "0より大きい" in info.value.message + + +@pytest.mark.parametrize( + "mutator,field_name", + [ + (lambda r: r["plot"]["figure"].__setitem__("unit", "ft"), "図のサイズの単位"), + (lambda r: r["plot"].__setitem__("latinFont", "comic"), "欧文フォント"), + (lambda r: r["plot"].__setitem__("palette", "rainbow"), "カラーパレット"), + (lambda r: r["save"].__setitem__("svgText", "outline"), "SVG の文字"), + (lambda r: r["save"].__setitem__("dpi", 49), "保存 DPI"), + (lambda r: r["save"].__setitem__("dpi", 1201), "保存 DPI"), + (lambda r: r["save"].__setitem__("dpi", 300.5), "保存 DPI"), + (lambda r: r["save"].__setitem__("dpi", "abc"), "保存 DPI"), + (lambda r: r["save"].__setitem__("dpi", True), "保存 DPI"), + ], +) +def test_new_fields_reject_invalid_values(mutator, field_name): + raw = default_settings() + mutator(raw) + with pytest.raises(UserError) as info: + parse_settings(raw) + assert info.value.field == field_name and field_name in info.value.message + + +def test_new_choices_accept_valid_values(): + raw = default_settings() + for latin in ("default", "arimo", "tinos"): + for palette in ("ud", "tab10", "gray"): + raw["plot"].update(latinFont=latin, palette=palette) + p = parse_settings(raw).plot + assert p.latin_font == latin and p.palette == palette + for mode in ("path", "text"): + raw["save"]["svgText"] = mode + assert parse_settings(raw).save.svg_text == mode + + +def test_dpi_accepts_int_integral_float_string_and_blank(): + raw = default_settings() + for given, expected in ((50, 50), (1200, 1200), (150, 150), (300.0, 300), ("600", 600), (" 72 ", 72), ("", 300), (None, 300)): + raw["save"]["dpi"] = given + assert parse_settings(raw).save.dpi == expected + assert parse_save_settings(raw).dpi == expected + raw["save"]["dpi"] = 150.0 + assert isinstance(parse_save_settings(raw).dpi, int) + assert parse_save_settings(raw).dpi == 150 + + +def test_dpi_message(): + with pytest.raises(UserError) as info: + parse_save_settings({"save": {"dpi": 10}}) + assert info.value.message == "「保存 DPI」は50〜1200の整数で入力してください。" + + +def _v1(): + raw = default_settings() + raw["version"] = 1 + raw["plot"]["figure"] = {"width": 7, "height": 5} + for key in ("latinFont", "palette"): + del raw["plot"][key] + raw["save"] = {"filename": "x", "format": "jpg", "transparent": False} + return raw + + +def test_migrate_v1_to_v2(): + raw = _v1() + 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["save"]["filename"] == "x" + s = parse_settings(raw) + assert s.version == 2 and s.save.dpi == 120 and s.save.format == "jpg" + 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" + + +def test_migrate_v1_keeps_an_existing_dpi_and_handles_missing_save(): + raw = _v1() + raw["save"]["dpi"] = 200 + assert migrate_settings(raw)["save"]["dpi"] == 200 + raw = _v1() + del raw["save"] + assert migrate_settings(raw)["save"] == {"dpi": 120} + + +def test_migrate_v1_through_save_and_load_parsers(): + assert parse_save_settings(_v1()).dpi == 120 + assert parse_load_settings(_v1()).has_header is True + + +def test_migrate_v2_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): + with pytest.raises(UserError) as info: + migrate_settings({"version": bad}) + assert "バージョン" in info.value.message From 6b40331a6be6543fc2bee5dfce1e0f1769231d53 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 03:20:29 +0900 Subject: [PATCH 04/16] Add output quality options to codegen, runner and API Co-Authored-By: Claude Opus 5.5 --- py/main.py | 10 ++- py/mplgui/api.py | 54 +++++++++--- py/mplgui/codegen.py | 60 +++++++++++-- py/mplgui/fonts.py | 53 +++++++++--- py/mplgui/formats.py | 38 +++++++- py/mplgui/runner.py | 28 ++++-- tests/unit/test_api.py | 172 +++++++++++++++++++++++++++++++++++-- tests/unit/test_codegen.py | 160 +++++++++++++++++++++++++++++++++- tests/unit/test_runner.py | 50 ++++++++++- 9 files changed, 574 insertions(+), 51 deletions(-) diff --git a/py/main.py b/py/main.py index 395f934..aa11042 100644 --- a/py/main.py +++ b/py/main.py @@ -89,8 +89,13 @@ def _save(settings_json, custom_code=None): return api.save_json(str(settings_json), _code(custom_code)) -def _register_font(data): - return api.register_font(data.to_py()) +def _copy_image(settings_json, custom_code=None): + return api.copy_image_json(str(settings_json), _code(custom_code)) + + +def _register_font(data, kind=None): + # kind は JS が渡さない(null / undefined)ときは日本語フォント + return api.register_font(data.to_py(), kind if isinstance(kind, str) else "japanese") def _script_filename(save_filename): @@ -103,6 +108,7 @@ def _script_filename(save_filename): "loadFile": create_proxy(_load_file), "render": create_proxy(_render), "save": create_proxy(_save), + "copyImage": create_proxy(_copy_image), "scriptFilename": create_proxy(_script_filename), "registerFont": create_proxy(_register_font), "fontStatus": create_proxy(api.font_status), diff --git a/py/mplgui/api.py b/py/mplgui/api.py index c19ed22..dc468e7 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -7,6 +7,7 @@ import base64 import json +import struct import traceback from pathlib import Path @@ -14,7 +15,7 @@ from .codegen import GENERIC_ERROR_MESSAGE, GeneratedScript, generate_script from .dataprep import plan_plot from .errors import UserError -from .formats import SAVE_DPI, build_filename +from .formats import build_filename from .loader import PREVIEW_ROWS, SourceInfo, build_preview, column_options, load_file from .runner import ScriptError, run_to_image from .settings import Settings, parse_load_settings, parse_save_settings, parse_settings @@ -154,20 +155,22 @@ def _generate(settings: Settings): return plan, generate_script(settings, _source_info(), plan) -def _run(settings: Settings | None, code: str | None, *, file_format: str, transparent: bool, dpi: int): +def _run(settings: Settings | None, code: str | None, *, file_format: str, transparent: bool, dpi: int, svg_text: str = "path"): """GUI 同期(code が None): 設定から生成したスクリプトを、読込済みの df で実行する(読込・保存の行は空にする)。 編集モード(code が文字列): そのスクリプトを、アップロードしたファイルのある作業フォルダで実行する。設定は使わない。""" if code is None: plan, script = _generate(settings) try: result = run_to_image( - script.auto_render_text(), file_format=file_format, transparent=transparent, dpi=dpi, + script.auto_render_text(), file_format=file_format, transparent=transparent, dpi=dpi, svg_text=svg_text, injected={"df": SESSION.df}, ) except ScriptError as err: raise _step_error(script, err) from err return plan, script, result - return None, None, run_to_image(code, file_format=file_format, transparent=transparent, dpi=dpi, cwd=SESSION.workdir) + return None, None, run_to_image( + code, file_format=file_format, transparent=transparent, dpi=dpi, svg_text=svg_text, cwd=SESSION.workdir + ) def _data_uri(result) -> str: @@ -185,14 +188,21 @@ def render_json(settings_json: str, code: str | None = None) -> dict: return payload -@_guard("save") -def save_json(settings_json: str, code: str | None = None) -> dict: - """保存設定の形式で図を書き出し、data URI とファイル名を返す(ダウンロードは JS が行う)。""" +def _save_settings(settings_json: str, code: str | None): + """保存系の API が使う保存設定を返す。編集モードは保存設定だけを検証する(描画設定が不正でも保存できる)。""" raw = _parse_json(settings_json) - # 編集モードは保存設定だけを使う(描画設定が不正でも、編集したコードの図は保存できる) settings = parse_settings(raw) if code is None else None save = settings.save if settings is not None else parse_save_settings(raw) - _, _, result = _run(settings, code, file_format=save.format, transparent=save.transparent, dpi=SAVE_DPI) + return settings, save + + +@_guard("save") +def save_json(settings_json: str, code: str | None = None) -> dict: + """保存設定の形式・解像度で図を書き出し、data URI とファイル名を返す(ダウンロードは JS が行う)。""" + settings, save = _save_settings(settings_json, code) + _, _, result = _run( + settings, code, file_format=save.format, transparent=save.transparent, dpi=save.dpi, svg_text=save.svg_text + ) return { "ok": True, "filename": build_filename(save.filename, result.ext), @@ -202,18 +212,36 @@ def save_json(settings_json: str, code: str | None = None) -> dict: } +@_guard("copy_image") +def copy_image_json(settings_json: str, code: str | None = None) -> dict: + """クリップボードにコピーする画像。保存形式にかかわらず、保存 DPI の PNG(背景透過は保存設定に従う)。""" + settings, save = _save_settings(settings_json, code) + _, _, result = _run(settings, code, file_format="png", transparent=save.transparent, dpi=save.dpi) + width, height = struct.unpack(">II", result.data[16:24]) # PNG の IHDR(幅・高さ) + return { + "ok": True, + "mime": result.mime, + "dataUri": _data_uri(result), + "width": width, + "height": height, + "output": result.output, + } + + def script_filename(save_filename: str) -> str: """「.py で保存」のファイル名。保存ファイル名と同じ規則(formats.build_filename)で拡張子を .py にする。""" return build_filename(str(save_filename or ""), "py") -def register_font(data: bytes) -> str: - """JS が取得した日本語フォントを登録する。""" +def register_font(data: bytes, kind: str = "japanese") -> str: + """JS が取得したフォントを登録する。kind は "japanese" | "arimo" | "tinos"(種類ごとに1回だけ登録する)。""" try: - return _dumps({"ok": True, "registered": fonts.register_font_bytes(bytes(data))}) + if kind not in fonts.FONT_FILENAMES: + raise UserError(f"未対応のフォントの種類です({kind})。", field="フォント") + return _dumps({"ok": True, "registered": fonts.register_font_bytes(bytes(data), kind=kind), "kind": kind}) except Exception as exc: # noqa: BLE001 return _dumps(_error_payload(exc, "register_font")) def font_status() -> str: - return _dumps({"ok": True, "registered": fonts.is_registered()}) + return _dumps({"ok": True, "registered": fonts.is_registered(), "kinds": fonts.registered_kinds()}) diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py index f146eb2..86eb99e 100644 --- a/py/mplgui/codegen.py +++ b/py/mplgui/codegen.py @@ -14,8 +14,8 @@ from typing import Callable from .dataprep import PlotPlan, SeriesPlan -from .fonts import SANS_SERIF_PRIORITY -from .formats import FORMATS, SAVE_DPI, build_filename, savefig_kwargs +from .fonts import LATIN_FONTS, SANS_SERIF_PRIORITY +from .formats import FORMATS, build_filename, savefig_kwargs, savefig_rc from .loader import SourceInfo from .settings import SeriesSettings, Settings @@ -153,22 +153,41 @@ def _label_size_expr(font_size: float) -> str: # ---------------------------------------------------------------- sections -def _emit_header(b: _Builder, source: SourceInfo) -> None: +def _emit_header(b: _Builder, source: SourceInfo, latin_font: str = "default") -> None: name = comment_text(source.filename) + latin = LATIN_FONTS.get(latin_font) b.add( "# matplotlib GUI が生成したスクリプト", f"# 使い方: データファイル「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。", "# 必要なライブラリ: pandas, matplotlib, numpy", "import matplotlib.pyplot as plt", + ) + if latin is not None: + b.add("from matplotlib import font_manager") + b.add( "import numpy as np", "import pandas as pd", "", "# 日本語フォント: 一覧の先頭から、見つかったものを使う。", "# どれも無い環境では DejaVu Sans になる(日本語は豆腐になるが、実行はできる)", - 'plt.rcParams["font.family"] = "sans-serif"', + ) + if latin is None: + b.add('plt.rcParams["font.family"] = "sans-serif"') + b.add( f'plt.rcParams["font.sans-serif"] = {_font_list_literal()}', 'plt.rcParams["axes.unicode_minus"] = False', ) + if latin is not None: + names = "[" + ", ".join(literal(n) for n in latin.candidates) + "]" + b.add( + "", + f"# 欧文フォント: {comment_text(latin.display)}。無ければ一覧の次のフォントを使い、どれも無ければ日本語フォントで書く", + "installed = {font.name for font in font_manager.fontManager.ttflist}", + f"latin = [name for name in {names} if name in installed][:1]", + 'plt.rcParams["font.family"] = latin + ["sans-serif"] # 欧文は latin のフォント、日本語は font.sans-serif のフォントで書く', + ) + if latin.mathtext_fontset is not None: + b.add(f'plt.rcParams["mathtext.fontset"] = {literal(latin.mathtext_fontset)} # 数式($...$)も Times 系の字形にする(STIX は matplotlib に同梱)') def _font_list_literal() -> str: @@ -211,7 +230,15 @@ def _emit_skip_rows(b: _Builder, plan: PlotPlan) -> None: def _emit_figure(b: _Builder, settings: Settings) -> None: b.section("3. 図と軸") fig = settings.plot.figure - b.add(f"fig, ax = plt.subplots(figsize=({literal(fig.width)}, {literal(fig.height)}), dpi=100)") + w, h = literal(fig.width), literal(fig.height) + if fig.unit == "in": + b.add(f"fig, ax = plt.subplots(figsize=({w}, {h}), dpi=100) # 幅 {comment_text(w)} × 高さ {comment_text(h)} インチ") + else: + const, factor = ("CM", "2.54") if fig.unit == "cm" else ("MM", "25.4") + b.add( + f"{const} = 1 / {factor} # {fig.unit} → インチ(figsize はインチで指定する)", + f"fig, ax = plt.subplots(figsize=({w} * {const}, {h} * {const}), dpi=100) # 幅 {comment_text(w)} {fig.unit} × 高さ {comment_text(h)} {fig.unit}", + ) # ---- series emitters (registry) ---- @@ -465,14 +492,31 @@ def _emit_margins(b: _Builder, settings: Settings) -> None: b.add(f"fig.subplots_adjust({args})") +_RC_COMMENTS = { + ("pdf", "pdf.fonttype"): "PDF に文字をフォントとして埋め込む(Illustrator などで文字を編集できる)", + ("svg", "svg.fonttype", "path"): "SVG の文字を図形(パス)にする(どの環境でも同じ見た目)。\"none\" にすると文字のまま残る", + ("svg", "svg.fonttype", "none"): "SVG の文字をテキストのまま残す(編集できる。開く環境に同じフォントが無いと見た目が変わる)", +} + +_PDF_CFF_NOTE = ( + "# ※ 手元の日本語フォントが OpenType(CFF)形式(macOS のヒラギノなど)だと、この PDF の文字は正しく表示されないことがある。" + "そのときは Noto Sans JP(TrueType 版)を入れるか、42 を 3 にする(文字は編集できなくなる)" +) + + def _emit_output(b: _Builder, settings: Settings) -> None: start = b.section("8. 保存と表示") save = settings.save _, _, ext = FORMATS[save.format] filename = build_filename(save.filename, ext) - kwargs = savefig_kwargs(save.format, save.transparent) + for key, value in savefig_rc(save.format, save.svg_text).items(): + comment = _RC_COMMENTS.get((save.format, key)) or _RC_COMMENTS[(save.format, key, value)] + if save.format == "pdf": + b.add(_PDF_CFF_NOTE) + b.add(f"plt.rcParams[{literal(key)}] = {literal(value)} # {comment}") + kwargs = savefig_kwargs(save.format, save.transparent, save.dpi) args = "".join(f", {k}={literal(v)}" for k, v in kwargs.items()) - note = f"GUI の「保存」と同じ形式({ext})" + ("と解像度" if "dpi" in kwargs else "") + note = f"GUI の「保存」と同じ形式({ext})" + (f"と解像度({save.dpi} dpi)" if "dpi" in kwargs else "") b.add(f"fig.savefig({literal(filename)}{args}) # {note}", "plt.show()") b.output_lines = (start, len(b.lines)) @@ -483,7 +527,7 @@ def _emit_output(b: _Builder, settings: Settings) -> None: def generate_script(settings: Settings, source: SourceInfo, plan: PlotPlan) -> GeneratedScript: """設定・読み込んだファイルの情報・描画計画から、完全なスクリプトを作る。""" b = _Builder() - _emit_header(b, source) + _emit_header(b, source, settings.plot.latin_font) b.add(f"FONT_SIZE = {literal(settings.plot.font_size)} # 文字の大きさ(pt)") _emit_load(b, source) _emit_skip_rows(b, plan) diff --git a/py/mplgui/fonts.py b/py/mplgui/fonts.py index e305533..4b98a48 100644 --- a/py/mplgui/fonts.py +++ b/py/mplgui/fonts.py @@ -2,6 +2,7 @@ from __future__ import annotations import tempfile +from dataclasses import dataclass from pathlib import Path import matplotlib.pyplot as plt @@ -18,7 +19,28 @@ "DejaVu Sans", ] -_registered = False + +@dataclass(frozen=True) +class LatinFont: + display: str + candidates: tuple[str, ...] # 先頭から、入っているものを使う + mathtext_fontset: str | None = None + + +# 欧文フォントの表(唯一の定義)。codegen が生成するスクリプトもこれを使う。 +LATIN_FONTS = { + "arimo": LatinFont("Arimo(Arial 互換)", ("Arimo", "Arial", "Liberation Sans")), + "tinos": LatinFont("Tinos(Times 互換)", ("Tinos", "Times New Roman", "Liberation Serif"), "stix"), +} + +# 登録するフォントの種類 → 作業ファイル名 +FONT_FILENAMES = { + "japanese": "NotoSansJP-Regular.ttf", + "arimo": "Arimo-Regular.ttf", + "tinos": "Tinos-Regular.ttf", +} + +_registered: set[str] = set() def configure_rcparams() -> None: @@ -33,17 +55,28 @@ def register_font_file(path) -> None: configure_rcparams() -def register_font_bytes(data: bytes, filename: str = "NotoSansJP-Regular.ttf") -> bool: - """フォントのバイト列を登録する。登録は1セッション1回だけ(2回目以降は何もしない)。""" - global _registered - if _registered: +def register_font_bytes(data: bytes, filename: str | None = None, kind: str = "japanese") -> bool: + """フォントのバイト列を登録する。登録は種類ごとに1セッション1回だけ(2回目以降は何もせず False)。 + + kind は "japanese" | "arimo" | "tinos"。日本語は rcParams も生成コードと同じ並びにする。 + """ + if kind not in FONT_FILENAMES: + raise ValueError(f"未対応のフォントの種類です: {kind}") + if kind in _registered: return False - target = Path(tempfile.gettempdir()) / filename + target = Path(tempfile.gettempdir()) / (filename or FONT_FILENAMES[kind]) target.write_bytes(bytes(data)) - register_font_file(target) - _registered = True + if kind == "japanese": + register_font_file(target) + else: + font_manager.fontManager.addfont(str(target)) + _registered.add(kind) return True -def is_registered() -> bool: - return _registered +def is_registered(kind: str = "japanese") -> bool: + return kind in _registered + + +def registered_kinds() -> list[str]: + return sorted(_registered) diff --git a/py/mplgui/formats.py b/py/mplgui/formats.py index 927977e..be37d96 100644 --- a/py/mplgui/formats.py +++ b/py/mplgui/formats.py @@ -1,8 +1,11 @@ """保存形式の表と保存ファイル名(runner と codegen が共有する。js / pyodide には依存しない)。""" from __future__ import annotations +import math import re +from .errors import UserError + # 保存形式 → (savefig の format, MIME, 拡張子) FORMATS = { "png": ("png", "image/png", "png"), @@ -11,7 +14,11 @@ "pdf": ("pdf", "application/pdf", "pdf"), } -SAVE_DPI = 120 # 保存時の解像度(GUI の「保存」と生成コードで共通) +DEFAULT_SAVE_DPI = 300 # 保存時の解像度の既定値(実際の値は設定 save.dpi。GUI の「保存」と生成コードで共通) + +# PNG / JPG の画素数の上限(Pyodide のメモリを守る。計測のうえ、必要なら見直す) +MAX_RASTER_PIXELS = 100_000_000 +MAX_RASTER_SIDE = 65536 # 1辺の画素数はこれ未満 _INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]') @@ -28,7 +35,7 @@ def build_filename(raw_filename: str, ext: str) -> str: return f"plot.{ext}" -def savefig_kwargs(file_format: str, transparent: bool = False, dpi: int = SAVE_DPI) -> dict: +def savefig_kwargs(file_format: str, transparent: bool = False, dpi: int = DEFAULT_SAVE_DPI) -> dict: """figure.savefig に渡す引数(format 以外)。""" save_format = FORMATS[file_format][0] kwargs: dict = {} @@ -39,3 +46,30 @@ def savefig_kwargs(file_format: str, transparent: bool = False, dpi: int = SAVE_ if transparent: kwargs["transparent"] = True return kwargs + + +def savefig_rc(file_format: str, svg_text: str = "path") -> dict: + """保存形式ごとの rcParams(runner が savefig の間だけ適用し、生成コードも同じ値を書く)。 + + - pdf: 文字を TrueType(Type 42)として埋め込む(Illustrator などで文字を編集できる)。 + - svg: "path" は文字を図形にする。"text"(none)は文字をテキストのまま残す。 + """ + if file_format == "pdf": + return {"pdf.fonttype": 42} + if file_format == "svg": + return {"svg.fonttype": "path" if svg_text == "path" else "none"} + return {} + + +def raster_pixels(width_in: float, height_in: float, dpi: float) -> tuple[int, int]: + return math.ceil(width_in * dpi), math.ceil(height_in * dpi) + + +def check_raster_size(width_in: float, height_in: float, dpi: float) -> None: + """PNG / JPG の画素数が大きすぎるときは UserError にする(ベクター形式には使わない)。""" + width_px, height_px = raster_pixels(width_in, height_in, dpi) + if width_px * height_px > MAX_RASTER_PIXELS or max(width_px, height_px) >= MAX_RASTER_SIDE: + raise UserError( + f"保存する画像が大きすぎます(幅 {width_px} × 高さ {height_px} ピクセル)。保存 DPI か図のサイズを小さくしてください。", + field="保存 DPI", + ) diff --git a/py/mplgui/runner.py b/py/mplgui/runner.py index e1a8add..23e139d 100644 --- a/py/mplgui/runner.py +++ b/py/mplgui/runner.py @@ -22,7 +22,7 @@ from matplotlib.layout_engine import PlaceHolderLayoutEngine from .errors import UserError -from .formats import FORMATS, build_filename, savefig_kwargs # noqa: F401 (build_filename は runner からも使えるようにする) +from .formats import DEFAULT_SAVE_DPI, FORMATS, build_filename, check_raster_size, savefig_kwargs, savefig_rc # noqa: F401 (build_filename は runner からも使えるようにする) SCRIPT_FILENAME = "plot.py" CODE_FIELD = "Pythonコード" @@ -45,8 +45,14 @@ def drop_placeholder_layout_engine(fig) -> None: fig.set_layout_engine(None) -def figure_to_bytes(fig, file_format: str, transparent: bool = False, dpi: int = 120) -> tuple[bytes, str, str]: - """Figure を指定形式のバイト列にする。戻り値は (bytes, mime, 拡張子)。""" +def figure_to_bytes( + fig, file_format: str, transparent: bool = False, dpi: int = DEFAULT_SAVE_DPI, svg_text: str = "path" +) -> tuple[bytes, str, str]: + """Figure を指定形式のバイト列にする。戻り値は (bytes, mime, 拡張子)。 + + 保存形式ごとの rcParams(PDF の fonttype、SVG の文字)は savefig の間だけ適用する(生成スクリプトと同じ値)。 + PNG / JPG は、画素数が大きすぎるときは書き出す前に UserError にする。 + """ fmt = (file_format or "").lower() if fmt not in FORMATS: raise UserError("「保存形式」の値が不正です。png / jpg / svg / pdf から選んでください。", field="保存形式") @@ -54,10 +60,15 @@ def figure_to_bytes(fig, file_format: str, transparent: bool = False, dpi: int = raise UserError("背景透過を有効にした場合、保存形式はpngまたはsvgを選択してください。", field="保存形式") save_format, mime, ext = FORMATS[fmt] + if save_format in {"png", "jpeg"}: + width_in, height_in = fig.get_size_inches() + check_raster_size(float(width_in), float(height_in), dpi) + buffer = io.BytesIO() drop_placeholder_layout_engine(fig) try: - fig.savefig(buffer, format=save_format, **savefig_kwargs(fmt, transparent, dpi)) + with matplotlib.rc_context(savefig_rc(fmt, svg_text)): + fig.savefig(buffer, format=save_format, **savefig_kwargs(fmt, transparent, dpi)) except (ValueError, OverflowError, MemoryError) as exc: raise UserError( "画像を書き出せませんでした。図幅・図高さを小さくするか、保存形式を変えてください。", @@ -67,8 +78,10 @@ def figure_to_bytes(fig, file_format: str, transparent: bool = False, dpi: int = return buffer.getvalue(), mime, ext -def figure_to_data_uri(fig, file_format: str, transparent: bool = False, dpi: int = 120) -> tuple[str, str]: - data, mime, ext = figure_to_bytes(fig, file_format, transparent, dpi) +def figure_to_data_uri( + fig, file_format: str, transparent: bool = False, dpi: int = DEFAULT_SAVE_DPI, svg_text: str = "path" +) -> tuple[str, str]: + data, mime, ext = figure_to_bytes(fig, file_format, transparent, dpi, svg_text) return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}", ext @@ -264,6 +277,7 @@ def run_to_image( file_format: str = "png", transparent: bool = False, dpi: int = 100, + svg_text: str = "path", injected: dict | None = None, cwd: str | Path | None = None, ) -> ImageResult: @@ -271,7 +285,7 @@ def run_to_image( with run_script(code, injected=injected, cwd=cwd) as run: output = run.output try: - data, mime, ext = figure_to_bytes(run.fig, file_format, transparent, dpi) + data, mime, ext = figure_to_bytes(run.fig, file_format, transparent, dpi, svg_text) summary = figure_summary(run.fig) except UserError as exc: exc.output = output diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index b81dd1c..7fd94c9 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -212,18 +212,180 @@ def test_save_with_code_ignores_invalid_plot_settings_but_checks_save_settings() assert r["ok"] is False and r["error"]["field"] == "保存形式" -def test_save_uses_120_dpi_and_render_100(): +def _png_size(data_uri): import base64 import struct + return struct.unpack(">II", base64.b64decode(data_uri.split(",")[1])[16:24]) + + +def test_save_uses_save_dpi_and_render_100(): load() s = default_settings() # 8 x 6 インチ r = json.loads(api.render_json(json.dumps(s))) - w, h = struct.unpack(">II", base64.b64decode(r["image"].split(",")[1])[16:24]) - assert (w, h) == (800, 600) + assert _png_size(r["image"]) == (800, 600) + r = json.loads(api.save_json(json.dumps(s))) # 既定は 300 dpi + assert _png_size(r["dataUri"]) == (2400, 1800) + s["save"]["dpi"] = 150 + r = json.loads(api.save_json(json.dumps(s))) + assert _png_size(r["dataUri"]) == (1200, 900) + + +def test_v1_settings_save_at_120_dpi(): + load() + s = default_settings() + s["version"] = 1 + del s["save"]["dpi"] + r = json.loads(api.save_json(json.dumps(s))) + assert _png_size(r["dataUri"]) == (960, 720) + + +def test_save_with_code_uses_save_dpi(workdir): + s = default_settings() + s["save"]["dpi"] = 100 + r = json.loads(api.save_json(json.dumps(s), "import matplotlib.pyplot as plt\nfig, ax = plt.subplots(figsize=(4, 3))\n")) + assert r["ok"] and _png_size(r["dataUri"]) == (400, 300) + + +PLAIN = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots(figsize=(4, 3))\nax.plot([1, 2], [3, 4])\nax.set_title('title')\n" + + +def test_pdf_embeds_truetype_not_type3_in_gui_and_edit_mode(): + load() + s = default_settings() + s["save"]["format"] = "pdf" + r = json.loads(api.save_json(json.dumps(s))) + import base64 + + pdf = base64.b64decode(r["dataUri"].split(",")[1]) + assert r["ok"] and b"/FontFile2" in pdf and b"/Type3" not in pdf + r = json.loads(api.save_json(json.dumps(s), PLAIN)) # コードが fonttype を指定しなくても Type 42 + pdf = base64.b64decode(r["dataUri"].split(",")[1]) + assert r["ok"] and b"/FontFile2" in pdf and b"/Type3" not in pdf + + +def test_pdf_save_output_has_no_font_log_lines(): + load() + s = default_settings() + s["save"]["format"] = "pdf" r = json.loads(api.save_json(json.dumps(s))) - w, h = struct.unpack(">II", base64.b64decode(r["dataUri"].split(",")[1])[16:24]) - assert (w, h) == (960, 720) + assert r["ok"] and r["output"] == "" + + +def test_svg_text_switch(): + import base64 + + load() + s = default_settings() + s["save"]["format"] = "svg" + s["plot"]["title"] = "Title" + r = json.loads(api.save_json(json.dumps(s))) + assert "II", data[16:24]) == (320, 240) + + +def test_raster_size_guard_in_runner_and_vector_unaffected(): + big, _ = plt.subplots(figsize=(50, 50)) + try: + with pytest.raises(UserError) as info: + figure_to_bytes(big, "png", dpi=1200) + assert info.value.field == "保存 DPI" and "幅 60000 × 高さ 60000 ピクセル" in info.value.message + with pytest.raises(UserError): + figure_to_bytes(big, "jpg", dpi=1200) + assert figure_to_bytes(big, "svg", dpi=1200)[0].startswith(b" Date: Thu, 8 Oct 2026 03:21:07 +0900 Subject: [PATCH 05/16] Report mathtext mistakes with a Japanese error Co-Authored-By: Claude Opus 5.5 --- py/mplgui/api.py | 17 ++++++++-- py/mplgui/runner.py | 29 +++++++++++++++++ tests/unit/test_api.py | 65 +++++++++++++++++++++++++++++++++++++++ tests/unit/test_runner.py | 24 +++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/py/mplgui/api.py b/py/mplgui/api.py index dc468e7..f22ed2a 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -17,10 +17,14 @@ from .errors import UserError from .formats import build_filename from .loader import PREVIEW_ROWS, SourceInfo, build_preview, column_options, load_file -from .runner import ScriptError, run_to_image +from .runner import MathtextError, ScriptError, is_mathtext_error, run_to_image from .settings import Settings, parse_load_settings, parse_save_settings, parse_settings PREVIEW_DPI = 100 +MATHTEXT_ERROR = ( + "タイトル・軸ラベル・凡例名の数式($ で囲んだ部分)を解釈できませんでした。書き方を確認してください" + "(例: m$^2$、H$_2$O、$\\alpha$)。$ を文字として使うときは \\$ と書きます。" +) INTERNAL_ERROR_MESSAGE = "内部エラーが発生しました。もう一度操作してください。" @@ -134,7 +138,12 @@ def _source_info() -> SourceInfo: def _step_error(script: GeneratedScript, err: ScriptError) -> ScriptError: - """生成コードの実行時エラーを、失敗した手順に応じた日本語メッセージにする。""" + """生成コードの実行時エラーを、失敗した手順に応じた日本語メッセージにする(数式の書き間違いは専用のメッセージ)。""" + if is_mathtext_error(err.exc): + return ScriptError( + MATHTEXT_ERROR, field="数式", detail=err.detail, traceback_text=err.traceback_text, + line=err.line, output=err.output, exc=err.exc, + ) step = script.step_for_line(err.line) mapped = ScriptError( step.message if step else GENERIC_ERROR_MESSAGE, @@ -167,6 +176,10 @@ def _run(settings: Settings | None, code: str | None, *, file_format: str, trans ) except ScriptError as err: raise _step_error(script, err) from err + except MathtextError as err: # スクリプトは動いたが、画像にするときに数式の誤りが見つかった + mapped = UserError(MATHTEXT_ERROR, field="数式", detail=err.detail) + mapped.output = getattr(err, "output", "") + raise mapped from err return plan, script, result return None, None, run_to_image( code, file_format=file_format, transparent=transparent, dpi=dpi, svg_text=svg_text, cwd=SESSION.workdir diff --git a/py/mplgui/runner.py b/py/mplgui/runner.py index 23e139d..3964cae 100644 --- a/py/mplgui/runner.py +++ b/py/mplgui/runner.py @@ -70,6 +70,8 @@ def figure_to_bytes( with matplotlib.rc_context(savefig_rc(fmt, svg_text)): fig.savefig(buffer, format=save_format, **savefig_kwargs(fmt, transparent, dpi)) except (ValueError, OverflowError, MemoryError) as exc: + if is_mathtext_error(exc): + raise MathtextError(MATHTEXT_HINT, field="数式", detail=f"{type(exc).__name__}: {exc}") from exc raise UserError( "画像を書き出せませんでした。図幅・図高さを小さくするか、保存形式を変えてください。", field="図幅", @@ -129,7 +131,34 @@ def figure_summary(fig) -> list[dict]: ) +MATHTEXT_HINT = "数式($ で囲んだ部分)の書き方が正しくありません。$ を文字として使うときは \\$ と書きます。" + + +class MathtextError(UserError): + """保存・画像化のときに見つかった、数式(mathtext)の書き間違い。""" + + +def is_mathtext_error(exc: BaseException | None) -> bool: + """matplotlib の数式(mathtext)の構文エラーか。例外(と __cause__ / __context__ の連鎖)を調べる。 + + matplotlib/_mathtext.py のパーサから出た ValueError、または pyparsing の ParseBaseException を数式のエラーとみなす。 + """ + seen: set[int] = set() + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + if any(c.__name__ == "ParseBaseException" for c in type(exc).__mro__): + return True + if isinstance(exc, ValueError): + for frame in traceback.extract_tb(exc.__traceback__): + if os.path.basename(frame.filename) in {"_mathtext.py", "mathtext.py"} and "matplotlib" in frame.filename: + return True + exc = exc.__cause__ or exc.__context__ + return False + + def japanese_hint(exc: BaseException) -> str | None: + if is_mathtext_error(exc): + return MATHTEXT_HINT for kind, hint in _HINTS: if isinstance(exc, kind): return hint diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 7fd94c9..7a6a43a 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -560,3 +560,68 @@ def test_script_filename_uses_save_filename_rules(raw, expected): assert api.script_filename(raw) == expected # 保存ファイル名と同じ規則: 拡張子だけが違う assert api.script_filename(raw or "").rsplit(".", 1)[0] == api.build_filename(raw or "", "png").rsplit(".", 1)[0] + + +# ---------------------------------------------------------------- C6: mathtext errors + +BAD_MATH = [r"$\alpah$", "m$^$"] + + +def _math_settings(title, margins): + s = default_settings() + s["plot"]["title"] = title + if margins: + s["plot"]["margins"] = {"left": 0.1, "right": 0.9, "bottom": 0.1, "top": 0.9} + return s + + +@pytest.mark.parametrize("margins", [False, True], ids=["tight_layout", "all_margins"]) +@pytest.mark.parametrize("title", BAD_MATH) +def test_mathtext_error_in_gui_mode_is_a_japanese_user_error(title, margins): + load() + s = _math_settings(title, margins) + for call in (api.render_json, api.save_json, api.copy_image_json): + r = json.loads(call(json.dumps(s))) + assert r["ok"] is False + assert r["error"]["message"] == api.MATHTEXT_ERROR and r["error"]["field"] == "数式" + assert "内部エラー" not in r["error"]["message"] and "レイアウト" not in r["error"]["message"] + + +def test_mathtext_error_in_axis_label_and_legend_name(): + load() + s = default_settings() + s["axes"]["x"]["label"] = "m$^$" + r = json.loads(api.render_json(json.dumps(s))) + assert r["error"]["message"] == api.MATHTEXT_ERROR + s = default_settings() + s["series"][0]["label"] = r"$\alpah$" + r = json.loads(api.render_json(json.dumps(s))) + assert r["error"]["message"] == api.MATHTEXT_ERROR + + +@pytest.mark.parametrize("title", BAD_MATH) +def test_mathtext_error_in_edit_mode_with_and_without_tight_layout(title): + s = default_settings() + base = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([1, 2], [3, 4])\nax.set_title(%r)\n" % title + # スクリプトの中で描画される(tight_layout) + r = json.loads(api.render_json(json.dumps(s), base + "fig.tight_layout()\n")) + assert r["ok"] is False and "数式" in r["error"]["message"] and "ValueError" in r["error"]["message"] + assert r["error"]["line"] == 5 and r["error"]["field"] == "Pythonコード" + # スクリプトは通り、画像にするときに見つかる + r = json.loads(api.render_json(json.dumps(s), base)) + assert r["ok"] is False and "数式" in r["error"]["message"] and r["error"]["field"] == "数式" + assert "内部エラー" not in r["error"]["message"] + r = json.loads(api.save_json(json.dumps(s), base)) + assert r["ok"] is False and "数式" in r["error"]["message"] + + +def test_valid_mathtext_renders(): + load() + s = default_settings() + s["plot"]["title"] = r"m$^2$ と $\alpha$" + s["axes"]["x"]["label"] = "H$_2$O" + s["plot"]["margins"] = {"left": 0.1, "right": 0.9, "bottom": 0.1, "top": 0.9} + assert json.loads(api.render_json(json.dumps(s)))["ok"] + s["plot"]["margins"] = {"left": None, "right": None, "bottom": None, "top": None} + assert json.loads(api.render_json(json.dumps(s)))["ok"] + assert json.loads(api.save_json(json.dumps(s)))["ok"] diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index c95f5d5..0fb8905 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -440,3 +440,27 @@ def test_check_raster_size_limits(): check_raster_size(50, 50, 201) # 画素数の上限を超える with pytest.raises(UserError): check_raster_size(55, 1, 1200) # 66000 画素の辺(1辺は 65536 未満) + + +def test_is_mathtext_error_detects_only_math_parse_errors(): + from mplgui.runner import MATHTEXT_HINT, is_mathtext_error, japanese_hint + + fig, ax = plt.subplots() + ax.set_title(r"$\alpah$") + try: + with pytest.raises(ValueError) as info: + fig.canvas.draw() + assert is_mathtext_error(info.value) and japanese_hint(info.value) == MATHTEXT_HINT + finally: + plt.close(fig) + assert not is_mathtext_error(ValueError("x")) and not is_mathtext_error(None) + assert japanese_hint(ValueError("x")) == "値が正しくありません" + assert "\\$" in MATHTEXT_HINT + + +def test_mathtext_error_during_conversion_is_a_user_error_with_field(): + from mplgui.runner import MathtextError + + with pytest.raises(MathtextError) as info: + run_to_image(SIMPLE + "ax.set_title('m$^$')\n") + assert info.value.field == "数式" and "数式" in info.value.message From dd8887a9c4c59eaf7537ce8a172c05472ddd18b5 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 03:25:54 +0900 Subject: [PATCH 06/16] Group the settings panel into collapsible sections Co-Authored-By: Claude Opus 5.5 --- css/style.css | 44 +++- index.html | 385 ++++++++++++++++--------------- js/main.js | 2 + js/ui/panelSections.js | 37 +++ tests/e2e/test_panel_sections.py | 102 ++++++++ 5 files changed, 379 insertions(+), 191 deletions(-) create mode 100644 js/ui/panelSections.js create mode 100644 tests/e2e/test_panel_sections.py diff --git a/css/style.css b/css/style.css index 3844322..6fcfd83 100644 --- a/css/style.css +++ b/css/style.css @@ -612,15 +612,47 @@ button.secondary:hover { font-size: 12px; color: var(--text-muted); } -.section-header { - margin: 14px 0 10px; - padding-top: 10px; +.panel-section + .panel-section { + margin-top: 14px; border-top: 1px solid var(--line); } -.section-header:first-of-type { - margin-top: 4px; +.section-header { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 10px 2px; + cursor: pointer; + list-style: none; + border-radius: 6px; + user-select: none; +} +.panel-section:first-of-type > .section-header { padding-top: 0; - border-top: none; +} +.section-header::-webkit-details-marker { + display: none; +} +.section-header::before { + content: ""; + flex: none; + width: 6px; + height: 6px; + margin: 0 3px 0 2px; + border-right: 2px solid var(--text-muted); + border-bottom: 2px solid var(--text-muted); + transform: rotate(-45deg); + transition: transform 0.12s ease; +} +.panel-section[open] > .section-header::before { + transform: rotate(45deg); +} +.section-header:hover .section-title { + color: var(--text); +} +.section-header:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .section-title { font-size: 12px; diff --git a/index.html b/index.html index 65d7e56..7509ca5 100644 --- a/index.html +++ b/index.html @@ -42,238 +42,253 @@
-
-
- - + + +
-
-
-
- 描画系列 - +
+ +
-
-
系列ごとにX/Y列・色・線幅・線種・点サイズ・第2軸使用を設定できます。
-
-
- - -
+
+
+ + +
+
-
-
- - +
+
+ 描画系列 + +
+
+
系列ごとにX/Y列・色・線幅・線種・点サイズ・第2軸使用を設定できます。
-
- -
-
- - - -
- - -
+ -
-
- - +
+ 軸・目盛 +
+
+ +
-
- - + +
+
+ + +
+
+ + +
-
-
- - -
+ -
-
- - +
+ +
-
- - + +
+
+ + +
+
+ + +
-
- -
- - -
+ -
-
- - +
+
+ + +
+
+ + +
-
- -
-
- -
- - -
+
-
-
- - +
+ 体裁 +
+
+ +
-
- - + +
+ +
-
-
-
- - + +
+
+ + +
+
+ + +
-
- - + +
+
+ + +
+
+ + +
-
-
-
- - + +
+
+ + +
+
+ + +
-
- -
-
- -
-
保存設定
-
+
-
- - -
+
+ 保存 +
+
+ + +
-
- - -
+
+ + +
-
-
- - +
+
+ + +
+
+ + +
-
- -
-
+
diff --git a/js/bridge.js b/js/bridge.js index be1b939..4fb8f6a 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -493,6 +493,38 @@ export const saveNow = async () => { } }; +// クリップボードにコピーする PNG を作る(保存と同じ設定: GUI 同期は現在の設定、編集モードは編集中のコード)。 +// js/ui/clipboard.js がクリックの中で ClipboardItem に渡す Promise になる。失敗は保存と同じ表示にして、reported = true で投げ直す。 +const reportedError = (message, original) => { + const error = new Error(message); + error.reported = true; + error.cause = original; + return error; +}; + +export const makeClipboardImage = async () => { + if (!api) { + setStatus(PYTHON_NOT_READY_MESSAGE, "warning"); + throw reportedError("python not ready"); + } + try { + await waitForFonts(); + const result = callPython("copyImage", toJson(), currentCode()); + if (!result.ok) { + reportError(result.error); + throw reportedError(result.error.message); + } + const binary = atob(result.dataUri.slice(result.dataUri.indexOf(",") + 1)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return { blob: new Blob([bytes], { type: result.mime || "image/png" }), width: result.width, height: result.height }; + } catch (err) { + if (err && err.reported) throw err; + reportException(err, "copyImage"); + throw reportedError("internal error", err); + } +}; + export const onPythonReady = (fn) => { if (api) fn(); else readyCallbacks.push(fn); diff --git a/js/main.js b/js/main.js index b0df46d..6927914 100644 --- a/js/main.js +++ b/js/main.js @@ -7,6 +7,7 @@ import { bindCodeTab } from "./ui/codeTab.js"; import { applyStateToForm, bindForms } from "./ui/forms.js"; import { bindSaveFormatEvents, syncSaveFormatOptions } from "./ui/saveFormat.js"; import { bindSaveSection } from "./ui/saveSection.js"; +import { bindClipboard } from "./ui/clipboard.js"; import { bindSeriesEvents, renderSeriesList, setColumns, syncMarkerSizeDisplay, syncVisibility } from "./ui/series.js"; import { bindStyleSection } from "./ui/styleSection.js"; import { initPanelSections } from "./ui/panelSections.js"; @@ -29,6 +30,7 @@ bindForms(); bindSeriesEvents(); bindSaveFormatEvents(); bindSaveSection(); +bindClipboard(); bindCodeTab(); bindColorPanelOutsideClick(); bindStyleSection(); diff --git a/js/ui/clipboard.js b/js/ui/clipboard.js new file mode 100644 index 0000000..f234fda --- /dev/null +++ b/js/ui/clipboard.js @@ -0,0 +1,74 @@ +// 「クリップボードにコピー」(C5)。保存 DPI の PNG を、ClipboardItem に Promise として渡して書き込む。 +// Safari はユーザー操作(クリック)の中で write を呼ぶ必要があるので、クリックの処理では何も await せずに write を呼び、 +// 画像は渡した Promise の中で作る(Chrome 98 以降、Edge、Safari 13.1 以降、Firefox 127 以降が Promise を受け付ける)。 +// 状態は 。 +import { getSettings } from "../state.js"; +import { makeClipboardImage } from "../bridge.js"; +import { setStatus } from "./notify.js"; + +const root = document.documentElement; + +const UNSUPPORTED_MESSAGE = "このブラウザでは図をクリップボードにコピーできません。「保存する」で PNG を保存してください。"; +const REJECTED_MESSAGE = "クリップボードにコピーできませんでした。ページをクリックしてから、もう一度試してください。"; + +const setCopyState = (state) => { + root.dataset.copyState = state; +}; + +export const isClipboardSupported = () => { + try { + return ( + window.isSecureContext === true && + !!(navigator.clipboard && navigator.clipboard.write) && + typeof ClipboardItem !== "undefined" && + (typeof ClipboardItem.supports === "function" ? ClipboardItem.supports("image/png") : true) + ); + } catch (_) { + return false; + } +}; + +const onCopyClick = () => { + if (root.dataset.copyState === "copying") return; + if (!isClipboardSupported()) { + setCopyState("unsupported"); + setStatus(UNSUPPORTED_MESSAGE, "warning"); + return; + } + setCopyState("copying"); + let info = null; + let pythonFailed = false; + const pngPromise = makeClipboardImage().then( + (image) => { + info = image; + return image.blob; + }, + (err) => { + pythonFailed = true; // 失敗の表示は bridge.js が済ませている(保存と同じ表示) + throw err; + }, + ); + let written; + try { + written = navigator.clipboard.write([new ClipboardItem({ "image/png": pngPromise })]); + } catch (err) { + written = Promise.reject(err); + } + written.then( + () => { + setCopyState("done"); + const dpi = getSettings().save.dpi; + setStatus(`図をクリップボードにコピーしました(PNG、${dpi} dpi、${info.width} × ${info.height} ピクセル)。`, "ok"); + }, + () => { + setCopyState("failed"); + if (!pythonFailed) setStatus(REJECTED_MESSAGE, "error"); + }, + ); +}; + +export const bindClipboard = () => { + setCopyState("idle"); + const button = document.getElementById("copyPlotBtn"); + if (button) button.addEventListener("click", onCopyClick); +}; diff --git a/tests/e2e/test_clipboard.py b/tests/e2e/test_clipboard.py new file mode 100644 index 0000000..6956317 --- /dev/null +++ b/tests/e2e/test_clipboard.py @@ -0,0 +1,75 @@ +"""C5: プレビュー中の図を、保存 DPI の PNG としてクリップボードにコピーする。""" +import pytest + +from helpers import ( + copy_state, + enter_edit_mode, + load_fixture, + read_clipboard_png_size, + set_save_dpi, + status_kind, + status_text, + wait_app_ready, + wait_copy_state, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +def _grant(page): + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + + +def test_copy_png_at_save_dpi(page, app_url, fixtures_dir, console_log): + _grant(page) + page.goto(app_url) + wait_app_ready(page) + assert copy_state(page) == "idle" + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + page.click("#copyPlotBtn") + wait_copy_state(page, "done") + assert read_clipboard_png_size(page) == (2400, 1800) + assert status_kind(page) == "ok" + assert "図をクリップボードにコピーしました(PNG、300 dpi、2400 × 1800 ピクセル)。" in status_text(page) + + set_save_dpi(page, 150) + page.select_option("#saveFormat", "pdf") # 保存形式にかかわらず PNG をコピーする + page.click("#copyPlotBtn") + wait_copy_state(page, "done") + assert read_clipboard_png_size(page) == (1200, 900) + assert "150 dpi、1200 × 900" in status_text(page) + + +def test_copy_in_edit_mode_uses_the_edited_code(page, app_url, console_log): + _grant(page) + page.goto(app_url) + wait_app_ready(page) + enter_edit_mode(page) + page.fill( + "#customPyCode", + "import matplotlib.pyplot as plt\nfig, ax = plt.subplots(figsize=(4, 3))\nax.plot([1, 2, 3])\n", + ) + page.click("#copyPlotBtn") + wait_copy_state(page, "done") + assert read_clipboard_png_size(page) == (1200, 900) + + +def test_copy_without_data_shows_the_python_error(page, app_url, console_log): + _grant(page) + page.goto(app_url) + wait_app_ready(page) + page.click("#copyPlotBtn") + wait_copy_state(page, "failed") + assert status_kind(page) == "error" and "ファイルを読み込んで" in status_text(page) + + +def test_copy_unsupported_browser_warns(page, app_url, console_log): + page.add_init_script("delete window.ClipboardItem;") + page.goto(app_url) + wait_app_ready(page) + page.click("#copyPlotBtn") + assert copy_state(page) == "unsupported" + assert status_kind(page) == "warning" + assert "このブラウザでは図をクリップボードにコピーできません。「保存する」で PNG を保存してください。" in status_text(page) From 020896b62401e618578b68cacf6997d53114e66b Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 04:06:10 +0900 Subject: [PATCH 14/16] Shorten the SVG text option labels so they fit the select Co-Authored-By: Claude Opus 5.5 --- index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 2552c46..d54c59f 100644 --- a/index.html +++ b/index.html @@ -341,8 +341,8 @@

Matplotlib GUI

From e049f61a6da874247ff86443e69f56de550c2897 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 04:10:46 +0900 Subject: [PATCH 15/16] Update README and AGENTS.md for Phase 3 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 103 ++++++++++++++++++++++++++++++++++++++---------------- README.md | 32 ++++++++++++++--- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c31f294..81fa763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,20 @@ # matplotlib GUI 引き継ぎ資料(AI エージェント向け) 要件の正は `docs/REQUIREMENTS.md`。このファイルと食い違う場合は REQUIREMENTS.md を優先する。 -この資料は Phase 2(描画経路のコード生成化と Pythonコードタブの刷新)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 +この資料は Phase 3(出力品質: 保存 DPI・図サイズの単位・欧文フォント・プリセット・クリップボード・パネルの区分)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 ## 1. ゴールと前提 - CSV / TXT / 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`)で表示する。 -- CDN の URL はすべてバージョン固定にする。`tests/unit/test_cdn_pinning.py` が `index.html`・`js/`・`css/`・`py/` の全 URL を走査して強制する。 +- 保存 DPI(既定 300)、図サイズの単位(inch / cm / mm)、欧文フォント、スタイルプリセット、カラーパレット、クリップボードへのコピー、数式(mathtext)の案内が加わった。いずれも設定 → 生成コード → 実行の1本の経路に載る(GUI だけの特別扱いは無い)。 +- CDN の URL はすべてバージョン固定にする。`tests/unit/test_cdn_pinning.py` が `index.html`・`js/`・`css/`・`py/` の全 URL を走査して強制する。取得しない説明リンクの `matplotlib.org` は、バージョンを含むパス(`/3.10.8/...`)だけ許可する。 ## 2. ファイル構成 @@ -23,27 +24,34 @@ | `pyscript.toml` | PyScript 設定(パッケージ、`packages_cache`、`[files]` による Python ファイルの配置) | | `css/style.css` | スタイル | | `js/main.js` | エントリ。UI を設定オブジェクトに結び付ける | -| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`)、色パレット(UD カラー) | +| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`、`SCHEMA_VERSION` = 2)、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`、`subscribe`、`toJson`) | -| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存の制御、世代管理、フォント取得の起動 | +| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`applyPalette`、`setLastPreset` / `getLastPreset`、`subscribe`、`toJson`)。直近に適用したプリセットはセッション中だけここに持つ | +| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存・コピー(`makeClipboardImage`)・コードだけの更新(`script`)の制御、世代管理、日本語・欧文フォント取得の起動と待ち | | `js/startup-progress.js` | Python 起動中の段階表示。Resource Timing(`PerformanceObserver`、取得済みエントリを含む)から Pyodide 本体(`pyodide.asm.wasm`・`python_stdlib.zip`)→ パッケージ wheel(pandas / matplotlib)→ 初期化(`/py/main.py` の取得)を推定する | -| `js/font-cache.js` | 日本語フォントの取得と Cache Storage への保存 | +| `js/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/*.js`(上記以外) | 画面部品(`colorPicker`、`dataPreview`、`fileInfo`(選択中のファイル名と文字コードの表示)、`forms`、`notify`、`plotView`(画像と `data-summary`)、`progress`、`saveFormat`、`series`、`tabs`) | -| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録、Excel 用ライブラリの遅延導入、作業フォルダ `api.SESSION.workdir` の設定) | -| `py/mplgui/settings.py` | 設定スキーマ、既定値、検証、日本語エラー(凍結 dataclass へ変換) | +| `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`)。純粋な関数 | -| `py/mplgui/formats.py` | 保存形式の表(`FORMATS`)、保存解像度(`SAVE_DPI` = 120)、`build_filename`、`savefig_kwargs`。runner と codegen が共有する | -| `py/mplgui/runner.py` | スクリプトの実行(`run_script` / `run_to_image`)、Figure → 画像(PNG/JPG/SVG/PDF)、図の要約(`figure_summary`)、実行時エラーの整形 | -| `py/mplgui/fonts.py` | 日本語フォントの登録と rcParams。フォント一覧 `SANS_SERIF_PRIORITY` が唯一の定義で、生成スクリプトの rcParams もこれを使う | +| `py/mplgui/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 入出力の関数。読込済みデータ(`SESSION`)と、アップロードしたファイルの作業フォルダへの書き出しを持つ | +| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数(`render_json` / `save_json` / `copy_image_json` / `script_json` など)。読込済みデータ(`SESSION`)と、アップロードしたファイルの作業フォルダへの書き出しを持つ | | `py/mplgui/runtime.py` | 警告フィルタ(起動時) | -| `tests/unit/` | 単体テスト(CPython、Agg) | +| `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/perf/bench.py` | 起動・描画の計測。結果は `docs/PERFORMANCE.md` | @@ -54,10 +62,16 @@ ## 3. 設定オブジェクト -- スキーマのバージョンは 1(`version`)。JSON のキーは camelCase。 +- スキーマのバージョンは 2(`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`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`)、`series[]`、`save`(`filename`、`format`、`transparent`)。 +- 主な構造: `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`)。 +- 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`。 +- パレットの色、プリセットの値、単位の換算は 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`)は廃止した。 @@ -71,14 +85,16 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 | `loadFile(name, bytes, loadJson)` | ファイル名、内容、`{version, load}` | `{encoding, columns, preview, warnings}`(.xlsx は `encoding: null`) | | `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) | +| `script(settingsJson)` | 設定 JSON | `{code}`(設定から生成した完全なスクリプトだけ。実行しない。読込済みデータが必要(無いと「先にファイルを読み込んでください。」)) | | `scriptFilename(saveFilename)` | 保存ファイル名の入力値 | 「.py で保存」のファイル名(文字列)。保存ファイル名と同じ規則(`formats.build_filename`)で拡張子を `.py` にする | -| `registerFont(bytes)` | フォントのバイト列 | `{registered}` | -| `fontStatus()` | なし | `{registered}` | +| `registerFont(bytes, kind)` | フォントのバイト列、種類(`japanese`(省略時)/ `arimo` / `tinos`。他は「未対応のフォントの種類」の `UserError`) | `{registered, kind}`(種類ごとに登録は1回だけ。2回目以降は `registered: false`) | +| `fontStatus()` | なし | `{registered, kinds}`(`registered` は日本語フォントの登録済みか、`kinds` は登録済みの種類の一覧) | `defaultCustomCode()` は廃止した(Phase 2)。 -- `code` が null(GUI 同期): 設定から生成したスクリプトの「自動描画用」(§4.1)を、読込済みの DataFrame(`df`)を渡して実行する。`image` は PNG の data URI、`code` は表示用の完全なスクリプト、`output` は実行中の print / stderr、`summary` は `runner.figure_summary` の軸ごとの要約。読込済みデータが必要。`save` は保存設定の形式・背景透過・`SAVE_DPI`(120)で書き出す(`render` のプレビューは dpi 100)。 -- `code` が文字列(編集モード): そのスクリプトを丸ごと、アップロードしたファイルのある作業フォルダ(`SESSION.workdir`)で実行する。`render` は設定を読まない(設定が不正でも、読込済みデータが無くても動く)。`save` は保存設定(`save.*`)だけを `settings.parse_save_settings` で検証して使う(描画設定が不正でも、編集したコードの図は保存できる)。GUI の設定をコードの結果に上書き適用することはしない(A8)。 +- `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)。 - `warnings` は `{series, message}` の配列(読込時は文字列配列の場合もある。JS の `warningTexts` が吸収する)。 - 失敗: `{ok: false, error: {message, field?, detail?, traceback?, line?}, output?}`。 @@ -87,6 +103,8 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - スクリプトの実行時エラーは `runner.ScriptError`。`message` は日本語の要約(編集モードは「N行目、エラーの型: 日本語の説明」入り)、`traceback` は `plot.py` の行だけのトレースバック、`line` は最後の `plot.py` の行番号、`detail` は完全な traceback。トップレベルの `output` は、失敗するまでの print の出力(`output` 属性を持つ例外だけ)。 - GUI 同期で生成コードの実行が失敗したときは、行番号から失敗した手順(`GeneratedScript.step_for_line`)を引き、手順ごとの日本語メッセージ(`field` 付き)に置き換える(`api._step_error`)。手順に当たらなければ `GENERIC_ERROR_MESSAGE`。 - `render` は読込済みデータが無いと(GUI 同期のとき)「先にファイルを読み込んでください。」を返す。 +- 画素数の検査(`formats.check_raster_size`): PNG / JPG は、書き出す前に `int(幅inch × dpi)` × `int(高さinch × dpi)`(Agg と同じ切り捨て。`raster_pixels`)が 1 億画素(`MAX_RASTER_PIXELS`)を超える、または1辺が 65536(`MAX_RASTER_SIDE`)以上なら、`UserError`(「保存する画像が大きすぎます(幅 N × 高さ M ピクセル)。保存 DPI か図のサイズを小さくしてください。」、`field` は「保存 DPI」)にする。`runner.figure_to_bytes` で行うので、`render`(dpi 100)・`save`・`copyImage`・編集モードのどれにも効く。SVG / PDF には使わない。 +- 数式(mathtext)の書き間違い(C6): `runner.is_mathtext_error` が、matplotlib の `_mathtext.py` から出た `ValueError` または pyparsing の `ParseBaseException`(`__cause__` / `__context__` の連鎖を含む)を判定する。GUI 同期では、実行中(`tight_layout` など)でも、画像にするとき(`MathtextError`)でも、`api.MATHTEXT_ERROR`(`field` は「数式」。例 `m$^2$`、`H$_2$O`、`$\alpha$`、`\$` を案内する日本語)にする。編集モードでは、`runner.MATHTEXT_HINT` が日本語の説明(`japanese_hint`)として「N行目、ValueError: …」に付く(実行中に見つかったとき。画像にするときに見つかった場合は `field` が「数式」)。内部エラーにはしない。 ## 4.1 コード生成(Phase 2) @@ -94,14 +112,17 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - `dataprep.plan_plot(df, settings)`: `skipRows` の適用、Y 列の自動割り当て、X の数値変換の要否、描ける点があるか、数値に変換できず除外した値の警告(A7)を決める。データ由来の問題は日本語の `UserError`(列が範囲外、除外行数が多すぎる、描ける数値データが無い、など)にする。結果は凍結 dataclass の `PlotPlan` / `SeriesPlan`。 - `codegen.generate_script(settings, source, plan)` は `GeneratedScript`(`text`、`load_lines`、`output_lines`、`steps`)を返す。純粋な関数で、データには触れない。 -- スクリプトの構成: ヘッダ(使い方、`import`、日本語フォントの rcParams、`FONT_SIZE`)→ `1. データの読み込み`(`DATA_FILE`、`pd.read_csv` / `pd.read_excel`。文字コード、区切り文字、ヘッダ、シート名を明記)→ `2. 描画に使う行`(`skipRows`)→ `3. 図と軸`(`fig, ax`)→ `4. 系列` → `5. 軸(ラベル・スケール・範囲・目盛)` → `6. グリッドと凡例` → `7. 余白` → `8. 保存と表示`(`fig.savefig(...)` と `plt.show()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。 +- スクリプトの構成: ヘッダ(使い方、`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()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。 +- 欧文フォント(`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` の注入テストが検査する。 - GUI の設定を、ユーザーが編集したコードの結果に上書き適用しない(A8)。編集モードのコードは、設定を使わずそのまま実行する。 -- 機能追加(Phase 3〜5)の手順: 描画の種類は `SERIES_EMITTERS`(プロット種別 → 系列の出力関数)に追加する。出力関数は `_Builder` に行を足し、失敗しうる行は `with b.step(メッセージ, field)` で包む。軸・グリッド・余白などは対応する `_emit_*` 関数に足す。データを見ないと決められないこと(列の割り当て、変換の要否、警告、データ由来のエラー)は `dataprep` の `PlotPlan` / `SeriesPlan` に項目を足して渡す。設定項目を足すときは JS の `defaults.js` と `settings.py` の両方に足す。生成するコードは pandas / matplotlib / numpy だけを使う(ローカルの Python でそのまま動かすため)。新しい行を足したら、単体テストで「全体を実行した図」と「自動描画用の図」が一致することも確かめる。 -- 生成スクリプトの保存ファイル名(`savefig` の引数)は `formats.build_filename` で決める。保存形式と解像度は `formats.savefig_kwargs` / `SAVE_DPI` で、GUI の「保存」と同じにする。 +- 機能追加(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` の選択肢を揃える。 +- 生成スクリプトの保存ファイル名(`savefig` の引数)は `formats.build_filename` で決める。保存形式・解像度・形式ごとの rcParams は `formats.savefig_kwargs`(解像度は `save.dpi`)/ `formats.savefig_rc` で、GUI の「保存」と同じにする。 ### runner(スクリプトの実行) @@ -114,8 +135,9 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 ## 5. 描画パイプライン -- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更で 0 ms、区切り文字の変更で 450 ms 後。保存設定(`save.*`)の変更では再描画しない。`skipRows` の変更は Python を呼ばず、データ確認表のグレー表示だけ即時に更新する。 +- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更で 0 ms、区切り文字の変更で 450 ms 後。保存設定(`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回読み込んで描く(起動キュー)。 - Pythonコードタブのモード(`js/code-state.js`): @@ -123,23 +145,30 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - `edit`: 利用者が書き換えたコードを「実行」(Ctrl/Cmd+Enter)したときだけ描く。GUI の変更でも、ファイルの読込でも自動では描かず、コードも更新しない(ステータスに warning で「編集中のため反映されません」と出す)。読込は行い、ファイルは作業フォルダに置かれる。フォント取得後の描き直しも、実行したことがあるときだけ行う。 - 「編集」で `edit`、「GUI から再生成」で `sync` に切り替える。切り替えのたびに、予約中・実行中の描画を無効にする(世代を進め、結果は画像にもコードにもステータスにも反映しない)。このため `data-render-generation` は、モード切替の直後に、表示中の画像の世代より先に進んだままになることがある。E2E は `tests/e2e/helpers.py` の専用ヘルパー(`wait_code_generated`、`wait_image_generation_changed`、`run_edited_code`、`wait_run_finished`)で待つこと。 - `sync` の描画は読込済みデータが必要。`edit` は Python が動いていればよい(コードが自分でファイルを読む)。 -- E2E が使う状態は `` の data 属性: `data-app-state`(`starting` / `ready` / `failed`)、`data-data-state`(`none` / `loading` / `ready` / `error`)、`data-render-state`(`idle` / `pending` / `rendering`)、`data-render-generation`、`data-font-state`(`idle` / `loading` / `ready` / `failed`)、`data-load-count`。表示中の画像は `#plotArea img` の `data-generation` と `data-summary`(`figure_summary` の JSON)を持つ。ステータスは `#status` の `data-kind`。Pythonコードタブ用に、`` の `data-code-mode`(`sync` / `edit`)、`#customPyCode`(textarea)の `data-generation`(表示中のコードを生成した世代。GUI 同期のとき、同じ世代の画像と対応する)、`#codeOutput` の `data-kind`(`ok` / `error`)、`#codeSyncStatus` の `data-state`(`sync` / `edit`)がある。 +- 欧文フォント(`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`。 +- 図サイズの単位を替えると、入力欄の数値を換算し(`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`)がある。 ## 6. 起動とフォント - PyScript 2026.7.3(Pyodide 314.0.3 / Python 3.14)。`pyscript.toml` は `packages = ["pandas", "matplotlib"]`、`packages_cache = "passthrough"`(micropip を使わない)。 - openpyxl と et_xmlfile は Pyodide 同梱ではないので、初めて .xlsx を読むときに `py/main.py` が、バージョン固定の wheel URL(`files.pythonhosted.org`)を `pyodide_js.loadPackage` に渡して導入する。起動時には導入しない。 - matplotlib のバックエンドは Agg(`py/main.py` で `matplotlib.use("Agg")`。pyplot を import する前に指定する)。 -- 日本語フォントは固定 URL の jsDelivr(`noto-cjk@Sans2.004` の `NotoSansCJKjp-Regular.otf`、約16 MB)から取得し、Cache Storage(キャッシュ名 `mplgui-fonts-v1`)に保存する。2回目以降の訪問ではダウンロードしない。取得はセッション中1回で、失敗しても再試行せず、警告を1度だけ出してフォールバックフォントで続行する。 -- フォント取得は、進みが 20 秒止まる(ヘッダが届かない、または本文が進まない)と失敗扱いにする(`FONT_STALL_MS`)。 -- 最初の描画はフォント取得を最大 8 秒待つ(`FONT_FIRST_RENDER_WAIT_MS`)。超えたらフォールバックフォントで描き、取得が終わったら現在の設定で1回だけ描き直す。 +- 日本語フォントは、固定 URL の jsDelivr(`@expo-google-fonts/noto-sans-jp@0.4.4/400Regular/NotoSansJP_400Regular.ttf`、約5.7 MB。TrueType 版 Noto Sans JP)から取得し、Cache Storage(キャッシュ名 `mplgui-fonts-v1`)に保存する。2回目以降の訪問ではダウンロードしない。取得はセッション中1回で、失敗しても再試行せず、警告を1度だけ出してフォールバックフォントで続行する。 +- Noto Sans CJK JP の OTF(CFF 形式、約16 MB)から替えた理由: matplotlib 3.10 は CFF 形式のフォントを `pdf.fonttype=42` で正しく埋め込めない(TrueType 用の FontFile2 に CFF を入れ、PDF が壊れる)。TrueType 版は同じデザイン・同じ文字幅。ハングルと中国語専用の漢字は含まれない(JIS 第1〜第4水準は含む)。旧 OTF のキャッシュ(URL `noto-cjk@Sans2.004/...NotoSansCJKjp-Regular.otf`)が `mplgui-fonts-v1` に残っていたら、日本語フォントの取得時に1回だけ削除する(失敗は無視する)。matplotlib に登録するフォント名は `Noto Sans JP`(`SANS_SERIF_PRIORITY` の先頭)。 +- 欧文フォント(選んだときだけ取得。起動時には取得しない): Arimo は `@expo-google-fonts/arimo@0.4.3/400Regular/Arimo_400Regular.ttf`、Tinos は `@expo-google-fonts/tinos@0.4.2/400Regular/Tinos_400Regular.ttf`(各 約0.3〜0.5 MB。jsDelivr、バージョン固定)。同じ Cache Storage に保存し、種類ごとにセッション中1回だけ取得する。ライセンスは3つとも SIL OFL 1.1(Noto Sans JP のライセンス全文: `https://cdn.jsdelivr.net/npm/@expo-google-fonts/noto-sans-jp@0.4.4/LICENSE_FONT`。Arimo は `@expo-google-fonts/arimo@0.4.3/LICENSE_FONT`、Tinos は `@expo-google-fonts/tinos@0.4.2/LICENSE_FONT`)。リポジトリにはフォントを置かない(実行時に取得するだけ)。登録は `registerFont(bytes, kind)`。画面用の Web フォントは `@fontsource/noto-sans-jp@5.3.0`(`index.html`)。 +- フォント取得は、進みが 20 秒止まる(ヘッダが届かない、または本文が進まない)と失敗扱いにする(`FONT_STALL_MS`。日本語・欧文とも)。 +- 日本語フォントは、最初の描画だけ取得を最大 8 秒待つ(`FONT_FIRST_RENDER_WAIT_MS`)。欧文フォントは、選んだフォントごとに1回だけ待つ。超えたらフォールバックフォントで描き、取得が終わったら現在の設定で1回だけ描き直す(§5)。保存とコピーも、取得中なら同じ時間まで待つ。 - テスト専用に、`window.__MPLGUI_TEST_OVERRIDES__ = {fontStallMs, fontFirstRenderWaitMs}` で上の時間を短縮できる。 ## 7. 文字コード判定・数値変換警告・ステータス - 文字コード(`loader.detect_encoding`): BOM 付きなら utf-8-sig → UTF-8 → CP932 と EUC-JP。両方でデコードできるときは、半角カナ・私用領域・制御文字などの「文字化けらしい文字」が少ないほうを採用し、同数なら CP932。どれでも読めなければ日本語のエラーにする。判定結果は `#encodingLabel` に表示する。 - 数値に変換できず除外した値は、系列ごとに警告にする(`dataprep.dropped_warning`。`dataprep.plan_plot` が集める)。 -- ステータス(`#status`)の種別は `ok` / `warning` / `error`。警告は成功メッセージに添えて表示し、`error` のときは `data-kind="error"`。日本語フォント取得失敗のような常時有効な警告は、以後の表示にも添える。 +- ステータス(`#status`)の種別は `ok` / `warning` / `error`。警告は成功メッセージに添えて表示し、`error` のときは `data-kind="error"`。日本語フォント取得失敗のような常時有効な警告は、以後の表示にも添える。欧文フォントの取得失敗の警告は、そのフォントを選んでいるあいだだけ常時警告に出し(1度だけ)、選び直すと消える(`removeStickyWarning`)。 +- コピーのメッセージ: 成功は `ok`「図をクリップボードにコピーしました(PNG、{dpi} dpi、{幅} × {高さ} ピクセル)。」、非対応のブラウザは `warning`「このブラウザでは図をクリップボードにコピーできません。「保存する」で PNG を保存してください。」、ブラウザが書き込みを拒否したときは `error`「クリップボードにコピーできませんでした。ページをクリックしてから、もう一度試してください。」。Python 側の失敗(データ無し、保存設定の不正、画素数、数式など)は保存と同じ表示で、上の拒否のメッセージは重ねない。 ## 8. 警告フィルタ @@ -170,6 +199,17 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - `test_fonts.py`: フォント一覧が生成スクリプトと rcParams で共通であること。`test_api.py`: 新しい `render` / `save` / `loadFile`(作業フォルダへの書き出し)/ エラーの形。 - E2E(`tests/e2e/`)のうち Phase 2 で加えたもの: `test_code_tab.py`(同期表示、GUI 変更への追従、コピー、.py 保存、編集・実行、編集中の GUI 変更が反映されないこと、「GUI から再生成」、Ctrl/Cmd+Enter、トレースバックと print の表示、編集モードでの保存、狭い画面)、`test_codegen_consistency.py`(下記)。 - 生成コードの一致確認(要件 7 章): `test_codegen_consistency.py` が、ブラウザで設定した図(line / scatter / bar)の `.py` を実際にダウンロードし、`tests/fixtures` のデータと同じフォルダに置いて CPython で実行する(`run_script_summary.py` が `MPLBACKEND=Agg`、`PYTHONPATH=py` で実行して `figure_summary` を出力する)。その要約を、ブラウザの画像の `data-summary` と比べる(系列数、軸範囲、ラベル、凡例、スケール、棒グラフの目盛ラベル)。フォントは環境で違うので、画素やレイアウトは比べない。 +- Phase 3 で加えた単体テスト: + - `test_settings.py`: 新しい既定値、単位ごとの図サイズ(インチへの換算と上限)、新しい項目の検証と日本語エラー、DPI の受け入れ(整数・整数に見える数値・数値文字列・空欄)、`migrate_settings`(v1 → v2 で dpi 120、既存の dpi を保つ、`save` が無い設定、3つのパーサ経由、v2 はそのまま、未対応バージョンはエラー)。 + - `test_codegen.py`: 単位ごとの `figsize` の実測値と、cm / mm の丸い値で画素数が欠けないこと、図サイズ部分の文面、`savefig` の `dpi` が PNG / JPG のときだけであること、PDF / SVG の rcParams の行(`formats` と同じ値)、PDF で TrueType が埋め込まれること、欧文フォントのヘッダ(`default` は従来どおり。入っていないフォントで `findfont` の警告が出ないこと、入っていれば使うこと)。 + - `test_runner.py`: PDF が Type 42 であること(スクリプトの助けなしで)、SVG の文字の切替、DPI で画素数が変わること、画素数の上限(ベクター形式には効かない)、`is_mathtext_error`、変換時の数式エラーが `field` 付きの `UserError` になること、`raster_pixels` が Agg と同じ切り捨てであること。 + - `test_api.py`: `save` の DPI と SVG の文字、v1 設定は 120 dpi、`copyImage`(PNG、保存 DPI、背景透過、編集モード、エラーの形)、PDF が TrueType 埋め込みで出力に font の行が混じらないこと、画素数の上限エラー、`registerFont` の種類と `fontStatus`、`script_json`(実行せずコードを返す、エラー)、数式エラー(GUI 同期の `render` / `save` / `copyImage`、軸ラベル・凡例名、編集モードの2経路)。 + - `test_fonts.py`(日本語の先頭が Noto Sans JP)、`test_js_modules.py`(node。単位換算が図の大きさを保つこと、プリセットの点サイズの規則、パレットと state、プリセット無しの `addSeries` が従来どおり)、`test_cdn_pinning.py`(`matplotlib.org` はバージョン付きのパスだけ許可)。 +- Phase 3 で加えた E2E: + - `test_panel_sections.py`(区分の順序・名前・既定で開く、閉じると中身が隠れる、閉じてもステータスが見える、リロードで状態が残る、localStorage が例外を投げても動く、折りたたみ後も描画できる)、`test_style_section.py`(単位切替で数値が換算され図の大きさが変わらない、空欄はそのまま、プリセットは「適用」でだけ効く、scatter の点サイズ、パレットで全系列と色選択パネルが塗り直される)、`test_save_section.py`(DPI の画素数とエラー、保存設定の変更にコードが追従して再描画しない、SVG の文字の切替と PDF の説明、PDF に日本語の TrueType が埋め込まれる)、`test_latin_font.py`(選んだときだけ取得、失敗の警告は1回で再試行しない、リロード後は Cache Storage から、遅いときはフォールバックで描いて1回だけ描き直す、編集モードでは取得するが描かない)、`test_clipboard.py`(保存 DPI の PNG、編集モード、データ無しのエラー、非対応のブラウザの警告)、`test_mathtext_help.py`(数式の案内とリンク、数式エラーの日本語メッセージと復帰)。`test_codegen_consistency.py` は、cm の図サイズなど Phase 3 の設定も一致確認の対象にした。 + - 実フォントを 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`。 - E2E は要素を id だけで選ぶ。`index.html` の id は安定させ、変えるときはテストも直す。アプリ固有の待機・操作は `tests/e2e/helpers.py` にまとめる。 - テストデータは `tests/fixtures/` の合成データだけ(公開リポジトリなので実データを置かない)。 - テスト用サーバー(`tests/e2e/server.py`)は listen backlog を 128 に広げている。既定の 5 だと、Chromium が ES モジュールを一斉に取得したときに接続が切れて、アプリが起動しないことがある。 @@ -179,6 +219,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 ファイルだけ)。 - 新しい Python モジュールを追加したら、`pyscript.toml` の `[files]` に必ず追加する(`tests/unit/test_pages_publish.py` が参照ファイルの公開を確認する)。 - matplotlib 3.10 では `tight_layout()` 後に `PlaceHolderLayoutEngine` が残り、`savefig` が余分に全体を再描画する。`runner.drop_placeholder_layout_engine` がこれを外している(出力は同一)。 @@ -206,7 +247,7 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 欧文フォントは Arimo(Arial 互換)と Tinos(Times 互換。U3)。ライセンスはどちらも SIL OFL 1.1(要件定義書の当初の「Apache 2.0」は誤り)。選んだときだけ、バージョン固定の jsDelivr から取得する。日本語の文字は Noto Sans JP で描く(matplotlib の文字単位のフォールバック)。Tinos のときは数式(mathtext)を STIX にする。 - 日本語フォントを、Noto Sans CJK JP(OTF、CFF 形式)から、同じデザインの TrueType 版 Noto Sans JP に替えた。matplotlib 3.10 は CFF 形式のフォントを `pdf.fonttype=42` で正しく埋め込めない(TrueType 用の FontFile2 に CFF を入れ、PDF が壊れる)ため。 - PDF は Type 42(`pdf.fonttype=42`)で書き出す。SVG の文字は「パスに変換」(既定)と「テキストのまま」を選べる。 -- スタイルプリセット(標準 / 論文1段組 / 論文2段組 / スライド16:9)は「適用」ボタンで値をまとめて書き込むだけで、設定にプリセット名を残さない。点サイズは、点を表示している系列(scatter、または点サイズを入力した line)だけ書き換える。適用後に追加した系列は、直前に適用したプリセットの値を使う(セッション中だけ JS が覚える)。 +- スタイルプリセット(標準 / 論文1段組 / 論文2段組 / スライド16:9)は「適用」ボタンで値をまとめて書き込むだけで、設定にプリセット名を残さない。点サイズは、点を表示している系列(scatter、または点サイズを入力した line)だけ書き換える(標準は点サイズを「自動」に戻す)。適用後に追加した系列は、直前に適用したプリセットの値(線幅と、scatter のときの点サイズ)を使う(セッション中だけ JS が覚える)。 - カラーパレット(UD カラー / tab10 / グレースケール)を切り替えたら、すべての系列を、系列の順に新しいパレットの色で塗り直す(個別に選んだ色も上書きする)。 - クリップボードにコピーする画像(C5)は、保存 DPI と同じ解像度の PNG。背景透過は保存設定に従う。 - 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に整理し、各区分を `
` で折りたためるようにする。折りたたみの状態は localStorage に保持する。「注釈・解析」の区分は Phase 5 まで作らない。 diff --git a/README.md b/README.md index 5a080ed..37ac44e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` 1. 「入力ファイル」で `.csv` / `.txt` / `.xlsx` を選びます。 2. 読み込みと描画は自動で行われます(「描画する」ボタンはありません)。設定を変えると、すぐ再描画されます。 3. 「描画系列」でX列・Y列・色などを選び、必要なら系列を追加します。 -4. 「保存設定」でファイル名と形式を選び、「保存する」で書き出します。 +4. 「体裁」でフォント・図のサイズ・プリセットなどを整えます。 +5. 「保存」でファイル名・形式・DPI を選び、「保存する」で書き出すか、「クリップボードにコピー」で他のアプリに貼り付けます。 + +左の設定パネルは「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に分かれていて、見出しを押すと折りたためます(折りたたみの状態は、このブラウザに覚えておきます)。 対応する文字コードは UTF-8、UTF-8(BOM付き)、Shift_JIS(CP932)、EUC-JP です。判定した文字コードは、読み込み後に画面へ表示されます。 @@ -18,7 +21,14 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` - 系列カード(複数系列、X/Y列、色、線幅、線種、点サイズ、凡例名)と、第2Y軸 - プロット種別: line / scatter / bar - 軸の対数表示と範囲指定、余白指定、主目盛線・副目盛線、凡例位置 -- 保存: png / jpg / svg / pdf、背景透過 +- 保存: png / jpg / svg / pdf、背景透過。PNG・JPG は保存 DPI(72 / 150 / 300 / 600 または任意の値。既定は 300)を指定できます。プレビューは 100 dpi で表示します +- PDF は文字をフォントとして埋め込みます(Type 42)。Illustrator などで文字を編集できます。SVG は、文字を「パスに変換」(既定。どの環境でも同じ見た目)か「テキストのまま」(編集できる。開く環境に同じフォントが無いと見た目が変わります)から選べます +- 図のサイズ: inch / cm / mm で指定(単位を切り替えると、図の大きさを変えずに数値を換算します) +- スタイルプリセット(標準 / 論文1段組 / 論文2段組 / スライド16:9): 「適用」で、図のサイズ・フォントサイズ・線幅・点サイズをまとめて設定します +- カラーパレット(UD カラー / tab10 / グレースケール): 切り替えると、すべての系列の色を、系列の順に新しいパレットの色へ塗り直します +- 欧文フォント: 標準(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 のスクリプトを表示(下記) - 数値に変換できず除外した値の警告、日本語のエラーメッセージ @@ -38,7 +48,7 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` 2. `pip install pandas matplotlib numpy` を実行します。`.xlsx` を読むときは `openpyxl` も入れます。 3. `python plot.py` で実行します(ファイル名は書き出した名前に読み替えてください)。 -日本語フォントは、見つかったものを使います。無い場合も実行はできますが、日本語は豆腐(□)で表示されます。 +日本語フォントは、見つかったものを使います。無い場合も実行はできますが、日本語は豆腐(□)で表示されます。欧文フォントを選んだ図は、Arimo / Tinos(または Arial / Times New Roman、Liberation)が入っていればそれを使い、無ければ日本語フォントで書きます。PDF の文字は TrueType 形式のフォントなら正しく埋め込まれます。macOS のヒラギノなど OpenType(CFF)形式のフォントでは、PDF の文字が正しく表示されないことがあります。そのときは Noto Sans JP(TrueType 版)を入れてください。 ## プライバシー @@ -47,10 +57,22 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` | ホスト | 取得するもの | |---|---| | `pyscript.net` | PyScript 本体 | -| `cdn.jsdelivr.net` | Pyodide 本体と同梱パッケージ(pandas / matplotlib など)、画面用フォント(Noto Sans JP)、グラフ用の日本語フォント(Noto Sans CJK JP) | +| `cdn.jsdelivr.net` | Pyodide 本体と同梱パッケージ(pandas / matplotlib など)、画面用フォント(Noto Sans JP)、グラフ用のフォント(日本語の Noto Sans JP、選んだときだけ Arimo / Tinos) | | `files.pythonhosted.org` | openpyxl の wheel(`.xlsx` を初めて開くときだけ) | -初回の訪問では、Pyodide とライブラリ(数十 MB)と、日本語フォント(約 16 MB)をダウンロードするため、時間がかかります。2回目以降はキャッシュを使います(フォントはブラウザの Cache Storage に保存し、再ダウンロードしません)。 +初回の訪問では、Pyodide とライブラリ(数十 MB)と、日本語フォント(約 5.7 MB)をダウンロードするため、時間がかかります。欧文フォントは、選んだときだけ取得します(各 約 0.3〜0.5 MB)。2回目以降はキャッシュを使います(フォントはブラウザの Cache Storage に保存し、再ダウンロードしません)。 + +## フォントとライセンス + +グラフに使うフォントは、実行時に jsDelivr から取得します(このリポジトリには含めていません)。いずれも SIL Open Font License 1.1 です。 + +| フォント | 用途 | ライセンス全文 | +|---|---|---| +| Noto Sans JP(TrueType 版) | グラフの日本語、画面用のフォント | https://cdn.jsdelivr.net/npm/@expo-google-fonts/noto-sans-jp@0.4.4/LICENSE_FONT | +| Arimo | 欧文フォント(Arial 互換) | https://cdn.jsdelivr.net/npm/@expo-google-fonts/arimo@0.4.3/LICENSE_FONT | +| Tinos | 欧文フォント(Times 互換) | https://cdn.jsdelivr.net/npm/@expo-google-fonts/tinos@0.4.2/LICENSE_FONT | + +画面用の Web フォントは、`@fontsource/noto-sans-jp@5.3.0`(Noto Sans JP、同じく SIL OFL 1.1)を jsDelivr から読み込みます。グラフの日本語に TrueType 版を使うのは、PDF に文字をフォントとして埋め込む(Type 42)には TrueType 形式が必要なためです。 ## 開発 From a141091b7198b2e909765bd57c4b045326dae882 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 8 Oct 2026 04:24:53 +0900 Subject: [PATCH 16/16] Record Phase 3 performance Co-Authored-By: Claude Opus 5.5 --- docs/PERFORMANCE.md | 78 ++++++++++++++++++++++++++ tests/perf/bench.py | 133 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 8212281..482cddc 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -154,3 +154,81 @@ Phase 1 後より悪化した項目はなかった。このため、悪化の内 編集モードの実行(438 ms)は、設定変更の描画(デバウンス除く 216 ms)より約 220 ms 長い。仮想 FS からのファイル再読込と、生成スクリプトの全体実行(読込・整形・描画)を含むためとみられるが、内訳は未計測。短縮が必要になったら、Pyodide 内で読込・コンパイル・実行・PNG 変換の時間を分けて測ることから始める。 + +## Phase 3 後 + +### 計測条件 + +| 項目 | 内容 | +|---|---| +| 日付 | 2026-10-08 | +| 対象 | `020896b`(Phase 3: 日本語フォントを TrueType の Noto Sans JP へ変更、保存 DPI の選択(既定 300)、欧文フォント、クリップボードへのコピー、保存設定の変更でコードだけ更新)に、Phase 3 の計測行を追加した `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、ヘッドレス) | +| ネットワーク | 実ネットワーク(ベースラインと同じ。欧文フォントの初回取得と日本語フォントのコールド取得は jsDelivr からの実ダウンロード) | +| 配信 | ローカルの静的 HTTP サーバー(127.0.0.1、サブパスなし) | +| 反復 | 各項目 N=5。既存項目の計測方法は Phase 2 後と同じ(描画系の項目は 10,000 行 × 系列 5 本) | + +追加した項目(既存項目の方法は変えていない。新項目は、起動 → CSV 読込 → 系列5本設定 → 3秒待機の後に測る): + +- 「保存(PNG、120 dpi)」: `#saveDpi` を「任意」、`#saveDpiCustom` を 120 にして、既存の「保存」と同じ方法(クリック → ダウンロード開始)で測る。Phase 2 の固定 120 dpi と直接比較できる。 +- 「保存(PNG、300 dpi 既定)」: 既存の「保存」項目の計測条件。Phase 3 では既定が 300 dpi になったので、既存行の値は 300 dpi の値になる(Phase 2 後の値は 120 dpi)。別ページ・別コンテキストでも測り直して一致を確認した(下表の2行)。 +- 「欧文フォント選択 → 最初のプロット更新」: `#latinFont` を Arimo にして(「設定変更」と同じ `CHANGE_JS`。`input` イベント発行から新しい画像の表示まで。250 ms のデバウンスを含む)。(a) 初回は反復ごとに新規コンテキスト(Cache Storage も HTTP キャッシュも空)で jsDelivr から実取得、(b) 2回目以降は Cache Storage に Arimo がある状態。 +- 「クリップボードへのコピー」: `#copyPlotBtn` のクリックから `data-copy-state` が `done` になるまで(300 dpi 既定)。`clipboard-read` / `clipboard-write` の権限を付けたコンテキストで測る。 +- 「日本語フォント取得(コールドキャッシュ)」: 新規コンテキストで、ナビゲーション開始から `data-font-state` が `ready` になるまで。あわせて、Python の準備完了(`data-app-state=ready`)から `ready` までの追加待ち時間も記録する。 + +### 結果(ミリ秒、中央値 / 最小 / 最大) + +| 項目 | Phase 2 後 | Phase 3 後 | Phase 2 後比 | +|---|---:|---:|---| +| コールド起動 | 3,607 / 3,578 / 6,407 | 3,564 / 3,526 / 7,098 | 同等(-1%) | +| ウォーム起動 | 3,452 / 3,440 / 3,458 | 3,441 / 3,424 / 3,484 | 同等(-0%) | +| 初回ファイル読込 → 最初のプロット | 178 / 175 / 181 | 177 / 175 / 179 | 同等(-1%) | +| 設定変更 → プロット更新(デバウンス込み) | 466 / 460 / 477 | 471 / 468 / 473 | 同等(+1%) | +| 同上(デバウンス 250ms 除く) | 216 / 210 / 227 | 221 / 218 / 223 | 同等(+2%) | +| 保存(PNG、クリック → ダウンロード開始。既定の 300 dpi) | 246 / 211 / 253(120 dpi) | 405 / 364 / 408 | 悪い(+65%)。dpi の違い | +| 初回 .xlsx 読込 → 最初のプロット | 329 / 327 / 332 | 329 / 325 / 330 | 同等(0%) | +| 編集モードでの実行(Ctrl/Cmd+Enter → プロット更新) | 438 / 431 / 464 | 579 / 575 / 596 | 悪い(+32%)。dpi の違い | +| 保存(PNG、120 dpi)【新】 | 246 / 211 / 253 | 246 / 242 / 257 | 同等(0%) | +| 保存(PNG、300 dpi)【新。別測定】 | - | 403 / 369 / 412 | - | +| 欧文フォント選択 → プロット更新、初回(Arimo を jsDelivr から取得)【新】 | - | 468 / 456 / 472 | - | +| 同上、Cache Storage にあるとき【新】 | - | 467 / 462 / 469 | - | +| 参考: 欧文フォント初回の、選択 → `data-latin-font-state=ready`【新】 | - | 23 / 21 / 25 | - | +| クリップボードへのコピー(クリック → done、300 dpi)【新】 | - | 422 / 406 / 424 | - | +| 日本語フォント取得、コールド(ナビゲーション開始 → `font-state=ready`)【新】 | - | 3,622 / 3,606 / 3,623 | - | +| 同上、Python 準備完了からの追加待ち【新】 | - | 67 / 56 / 77 | - | + +コールド起動の最大値は1回目の反復のみ(キャッシュが冷えていたため)。2回目以降は約3.6秒。 + +### 要件の確認 + +要件「設定変更後 1 秒以内の再描画(10,000 行 × 5 系列)」: 設定変更 → プロット更新は、デバウンス 250 ms を含めて中央値 471 ms(最大 473 ms)で、**満たしている**。編集モードの実行も、300 dpi の既定で中央値 579 ms(最大 596 ms)で 1 秒以内。欧文フォントの選択からの更新も 468 ms で、同様に 1 秒以内。 + +### 分析 + +悪化した項目は2つで、どちらも保存 DPI の既定が 120 → 300 になったことによる(起動・再描画・xlsx 読込は Phase 2 後と同等)。 + +- **保存(PNG)+65%**: 300 dpi は 120 dpi の 6.25 倍の画素数(8×6 inch で 2400×1800 = 4.3 MP に対し 960×720 = 0.7 MP)。同じ設定で 120 dpi に戻した値は 246 ms(Phase 2 後の 246 ms と同じ)なので、ランタイムや生成スクリプトの増加による劣化はない。画素数が 6.25 倍になっても時間は 1.6 倍にとどまる。固定費(スクリプトの生成と実行、系列の描画)が大きく、画素数に比例する部分(Agg のラスタライズと PNG のエンコード)は一部のため。 +- **編集モードの実行 +32%**: 編集モードのスクリプトは、末尾の `fig.savefig(...)` を含めて全体を実行する。Phase 3 から生成コードの `savefig` が既定 300 dpi になったので、その分の PNG 書き出しが増えた。DPI を 120 にして同じ計測をすると 439 / 425 / 443 ms で、Phase 2 後(438 ms)と同じ(確認用に3回測った値)。 + +悪化していない項目の補足: + +- **日本語フォントの変更(16.5 MB の OTF → 5.7 MB の TTF、転送は約 3.2 MB の brotli)**: コールドキャッシュでも、Python の準備完了までにフォント取得がほぼ終わる(ナビゲーション開始 → `ready` が 3,622 ms、準備完了からの追加待ちは 67 ms)。取得は Pyodide の起動と並行して進むため、起動の体感を延ばさない(コールド起動 3,564 ms は Phase 2 後と同等)。以前は 16.5 MB を取得していたので、初回訪問のダウンロード量は約 1/3 になった。2回目以降は Cache Storage から読む点は変わらない。 +- **欧文フォントの初回選択(Arimo)**: 初回(取得あり)と2回目以降(Cache Storage から)で差がない(468 ms と 467 ms)。選択からフォントの登録完了までが約 23 ms で、250 ms のデバウンスの間に終わるため、描画は取得を待たない。フォントが約 0.3〜0.5 MB と小さいことと、デバウンスと並行して取得を始める作りのため。 +- **クリップボードへのコピー**: 422 ms で、300 dpi の保存(405 ms)とほぼ同じ。PNG の生成が大半で、クリップボードへの書き込みは十数 ms 程度とみられる。 +- 保存設定(`save.*`)の変更は画像を再描画せず、コードだけ更新する(計測行は設けていない)。 + +### 一回限りの確認: ラスタ画像の画素数の上限(`formats.MAX_RASTER_PIXELS` = 100,000,000) + +実アプリ(Chromium、Pyodide)で、上限付近の PNG 保存を試した。10,000 行 × 5 系列のデータで、図の大きさ(inch)と DPI を設定してから保存ボタンを押した。メモリは、Pyodide の WebAssembly メモリのバイト数(`WebAssembly.instantiate` の結果から `exports.memory` を捕まえて `buffer.byteLength` を読んだ)。保存前と保存直後の値。 + +| 設定 | 画素数 | 結果 | 時間 | 出力ファイル | wasm メモリ(保存前 → 後) | +|---|---:|---|---:|---|---:| +| 8×6 inch、1200 dpi(9600×7200) | 69.1 MP | 成功 | 2.1 秒 | 4.2 MB | 108 MB → 442 MB | +| 10×10 inch、999 dpi(9990×9990) | 99.8 MP | 成功 | 3.2〜3.3 秒 | 6.8 MB | 130 MB → 599 MB | +| 10×10 inch、1000 dpi(10000×10000) | 100.0 MP(上限ちょうど) | 成功 | 3.2 秒 | 6.8 MB | 130 MB → 600 MB | + +- どれも、メモリ不足やクラッシュは起きなかった。コンソールエラーもない。画像の大きさは、ファイルを開いて指定どおりであることを確かめた。 +- 上限付近では、RGBA の画素バッファ(1画素 4 バイト、100 MP で約 400 MB)が wasm のメモリに載るため、wasm メモリは約 600 MB まで広がった。wasm のメモリは縮まないので、保存後もこの大きさのまま残る。JS ヒープ(`performance.memory`)は約 93〜98 MB で変わらない。 +- この Mac(24 GB)の結果。メモリの少ない端末(スマートフォンなど)では、600 MB 級の確保が失敗する可能性があるが、今回は確かめていない。上限(1億画素)の変更は不要と判断し、触っていない。 diff --git a/tests/perf/bench.py b/tests/perf/bench.py index 01e7be6..8f6fdd3 100644 --- a/tests/perf/bench.py +++ b/tests/perf/bench.py @@ -19,7 +19,8 @@ sys.path.insert(0, str(REPO / "tests" / "e2e")) from helpers import ( # noqa: E402 enter_edit_mode, image_generation, load_fixture, plot_src, - wait_app_ready, wait_data_ready, wait_plot_changed, + set_save_dpi, wait_app_ready, wait_copy_state, wait_data_ready, wait_latin_state, + wait_plot_changed, ) from server import start_server # noqa: E402 from playwright.sync_api import sync_playwright # noqa: E402 @@ -176,6 +177,123 @@ def bench_xlsx(browser, url, reps, xlsx_path): return out +def _prepared_page(ctx, url, csv_path): + """新規ページで起動 → 10,000 行の CSV を読込 → 系列5本を設定 → 描画の収束を待つ。""" + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + page.set_input_files("#fileInput", str(csv_path)) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + setup_series(page) + page.wait_for_timeout(3000) + return page + + +def _warm_context(browser, url, csv_path, **kw): + """HTTP キャッシュ・Cache Storage(日本語フォント)を温めたコンテキスト。""" + ctx = browser.new_context(**kw) + _prepared_page(ctx, url, csv_path).close() + return ctx + + +def bench_save_dpi(browser, url, reps, csv_path): + """Phase 3: 保存 PNG を 120 dpi(Phase 2 の固定値。「保存」と直接比較できる)と 300 dpi(既定)で測る。 + 測り方は bench_render の「保存」と同じ(クリック → ダウンロード開始)。""" + save120, save300 = [], [] + ctx = _warm_context(browser, url, csv_path) + for dpi, out in ((120, save120), (300, save300)): + for _ in range(reps): + page = _prepared_page(ctx, url, csv_path) + if dpi != 300: + set_save_dpi(page, dpi) + page.wait_for_timeout(1500) + t = time.perf_counter() + with page.expect_download(timeout=TIMEOUT) as dl: + page.click("#savePlotBtn") + dl.value.path() + out.append((time.perf_counter() - t) * 1000) + page.close() + ctx.close() + return save120, save300 + + +def bench_copy(browser, url, reps, csv_path): + """Phase 3: #copyPlotBtn のクリック → data-copy-state が done(既定 300 dpi)。""" + out = [] + ctx = _warm_context(browser, url, csv_path, permissions=["clipboard-read", "clipboard-write"]) + for _ in range(reps): + page = _prepared_page(ctx, url, csv_path) + page.wait_for_timeout(1500) + t = time.perf_counter() + page.click("#copyPlotBtn") + wait_copy_state(page, "done", TIMEOUT) + out.append((time.perf_counter() - t) * 1000) + page.close() + ctx.close() + return out + + +def bench_latin_font(browser, url, reps, csv_path): + """Phase 3: 欧文フォント(Arimo)の選択 → 新しい画像の表示。「設定変更」と同じ CHANGE_JS で測る + (select の input イベント発行から。250 ms のデバウンスを含む)。 + (a) 初回: 反復ごとに新規コンテキスト(Cache Storage も HTTP キャッシュも空)で jsDelivr から実取得。 + (b) 2回目以降: Cache Storage に Arimo がある状態(同一コンテキストで1回選んで保存済み)。""" + first, cached, first_fetch = [], [], [] + for _ in range(reps): + ctx = browser.new_context() + page = _prepared_page(ctx, url, csv_path) + page.wait_for_timeout(1500) + # 参考: 選択から data-latin-font-state が ready になるまで(取得 + 登録。デバウンスと並行して進む) + page.evaluate("""() => { + const root = document.documentElement; + window.__latinReady = new Promise((resolve) => { + const t0 = performance.now(); + const obs = new MutationObserver(() => { + if (root.dataset.latinFontState === 'ready') { obs.disconnect(); resolve(performance.now() - t0); } + }); + obs.observe(root, {attributes: true}); + }); + }""") + first.append(page.evaluate(CHANGE_JS, ["#latinFont", "arimo"])) + first_fetch.append(page.evaluate("window.__latinReady")) + ctx.close() + ctx = browser.new_context() + page = _prepared_page(ctx, url, csv_path) + page.evaluate(CHANGE_JS, ["#latinFont", "arimo"]) + wait_latin_state(page, "ready", TIMEOUT) + page.close() + for _ in range(reps): + page = _prepared_page(ctx, url, csv_path) + page.wait_for_timeout(1500) + cached.append(page.evaluate(CHANGE_JS, ["#latinFont", "arimo"])) + wait_latin_state(page, "ready", TIMEOUT) + page.close() + ctx.close() + return first, cached, first_fetch + + +def bench_japanese_font(browser, url, reps): + """Phase 3(新規): コールドキャッシュでの日本語フォント取得。ナビゲーション開始 → data-font-state が ready、 + および Python 準備完了(app ready)からの追加待ち時間。""" + total, after_ready = [], [] + for _ in range(reps): + ctx = browser.new_context() + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + t = time.perf_counter() + page.goto(url) + wait_app_ready(page, TIMEOUT) + t_ready = time.perf_counter() + page.wait_for_function("document.documentElement.dataset.fontState === 'ready'", timeout=TIMEOUT) + t_font = time.perf_counter() + total.append((t_font - t) * 1000) + after_ready.append((t_font - t_ready) * 1000) + ctx.close() + return total, after_ready + + 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']})" @@ -198,6 +316,10 @@ def main(): 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) browser.close() res = { "cold_startup_ms": cold, "warm_startup_ms": warm, @@ -206,6 +328,15 @@ def main(): "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))